Add Timeline View Support to ObjectTable and Enhance CSS Styles
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new timeline view in the ObjectTable component, allowing users to visualize data over time. - Updated view mode utilities to include timeline-specific settings for start and end dates, and color properties. - Enhanced ObjectTableViewButton to manage timeline settings and integrate timeline icon. - Added CSS styles for timeline components to improve layout and visual feedback. - Refactored StateTag component to utilize a utility function for state representation, improving maintainability.
This commit is contained in:
parent
789e66b0f3
commit
d02895b78d
@ -1693,6 +1693,105 @@ body.objectKanbanColumnResizing * {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.objectTimelineContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.objectTimelineContainer .simplebar-mask {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.objectTimelineItemLabelContainer {
|
||||
border-radius: 3px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.objectTimelineRow {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.objectTimelineRowHeaderLabelContainer {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.objectTimelineRowHeaderLabel {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 44px;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.objectTimelineRowHeaderLabel:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-table-header-bg) 0%,
|
||||
var(--color-table-header-bg) 70%,
|
||||
transparent calc(100% - 40px)
|
||||
);
|
||||
}
|
||||
|
||||
.objectTimelineShadow .objectTimelineRowHeaderLabel {
|
||||
background: var(--color-table-header-bg);
|
||||
}
|
||||
|
||||
.objectTimelineRowLabelContainer {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.objectTimelineRowLabel {
|
||||
height: 100%;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.3s;
|
||||
background: var(--layout-header-bg, #fff);
|
||||
}
|
||||
|
||||
.dark-mode .objectTimelineShadow .objectTimelineRowLabel,
|
||||
.dark-mode .objectTimelineShadow .objectTimelineRowHeaderLabel {
|
||||
box-shadow: -2px -1px 8px 4px rgba(253, 253, 253, 0.12);
|
||||
}
|
||||
|
||||
.objectTimelineRow:hover,
|
||||
.objectTimelineRow:hover .objectTimelineRowLabel {
|
||||
background-color: var(--color-table-header-bg);
|
||||
}
|
||||
|
||||
.objectTimelineItemLabel {
|
||||
display: block;
|
||||
margin-top: -1.5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.objectTimelineItemLabel-reference {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.objectTimelineItemWithEndDate {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.electron-body .ant-modal-wrap,
|
||||
.electron-body .ant-modal-mask,
|
||||
.electron-body .ant-drawer {
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
import ObjectCard from './ObjectCard'
|
||||
import ObjectKanban from './ObjectKanban'
|
||||
import ObjectTimeline from './ObjectTimeline'
|
||||
import FilterSidebar from './FilterSidebar'
|
||||
import SortSidebar from './SortSidebar'
|
||||
import CheckIcon from '../../Icons/CheckIcon'
|
||||
@ -59,7 +60,12 @@ import { hasActionPermission } from '../../../database/permissions'
|
||||
import Tooltip from './Tooltip'
|
||||
import { ObjectTableFilterContext } from './ObjectTableFilterContext'
|
||||
import ObjectListViewContext from '../context/ObjectListViewContext'
|
||||
import { isCardsView, isKanbanView, normalizeViewMode } from './viewModeUtils'
|
||||
import {
|
||||
isCardsView,
|
||||
isKanbanView,
|
||||
isTimelineView,
|
||||
normalizeViewMode
|
||||
} from './viewModeUtils'
|
||||
import { areValuesEqual } from '../utils/Utils'
|
||||
import MissingPlaceholder from './MissingPlaceholder'
|
||||
import LoadingPlaceholder from './LoadingPlaceholder'
|
||||
@ -319,6 +325,7 @@ const ObjectTable = forwardRef(
|
||||
)
|
||||
const isCards = isCardsView(viewMode)
|
||||
const isKanban = isKanbanView(viewMode)
|
||||
const isTimeline = isTimelineView(viewMode)
|
||||
const kanbanRef = useRef(null)
|
||||
const prevViewModeKeyRef = useRef(null)
|
||||
const { token, userProfile } = useContext(AuthContext)
|
||||
@ -379,7 +386,7 @@ const ObjectTable = forwardRef(
|
||||
if (isMobile) {
|
||||
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
|
||||
}
|
||||
if (isCards || isKanban) {
|
||||
if (isCards || isKanban || isTimeline) {
|
||||
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'
|
||||
}
|
||||
if (isElectron) {
|
||||
@ -388,7 +395,7 @@ const ObjectTable = forwardRef(
|
||||
if (isMobile && isElectron) {
|
||||
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
|
||||
}
|
||||
if ((isCards || isKanban) && isElectron) {
|
||||
if ((isCards || isKanban || isTimeline) && isElectron) {
|
||||
adjustedScrollHeight = 'calc(var(--unit-100vh) - 258px)'
|
||||
}
|
||||
const tableRef = useRef(null)
|
||||
@ -1514,7 +1521,13 @@ const ObjectTable = forwardRef(
|
||||
}, [token, initialized, connected])
|
||||
|
||||
useEffect(() => {
|
||||
const viewModeKey = `${viewMode.type}:${viewMode?.settings?.categoryProperty || ''}`
|
||||
const viewModeKey = [
|
||||
viewMode.type,
|
||||
viewMode?.settings?.categoryProperty || '',
|
||||
viewMode?.settings?.startDate || '',
|
||||
viewMode?.settings?.endDate || '',
|
||||
viewMode?.settings?.colorProperty || ''
|
||||
].join(':')
|
||||
|
||||
if (connected !== true || token == null || !initialized) {
|
||||
prevViewModeKeyRef.current = viewModeKey
|
||||
@ -1525,6 +1538,7 @@ const ObjectTable = forwardRef(
|
||||
prevViewModeKeyRef.current = viewModeKey
|
||||
|
||||
if (isKanban && !viewMode?.settings?.categoryProperty) return
|
||||
if (isTimeline && !viewMode?.settings?.startDate) return
|
||||
|
||||
loadPage(
|
||||
initialPage,
|
||||
@ -1533,13 +1547,17 @@ const ObjectTable = forwardRef(
|
||||
)
|
||||
}, [
|
||||
isKanban,
|
||||
isTimeline,
|
||||
connected,
|
||||
token,
|
||||
initialized,
|
||||
loadPage,
|
||||
initialPage,
|
||||
viewMode.type,
|
||||
viewMode?.settings?.categoryProperty
|
||||
viewMode?.settings?.categoryProperty,
|
||||
viewMode?.settings?.startDate,
|
||||
viewMode?.settings?.endDate,
|
||||
viewMode?.settings?.colorProperty
|
||||
])
|
||||
|
||||
// Watch for changes in type and masterFilter, reset component state when they change
|
||||
@ -2135,6 +2153,38 @@ const ObjectTable = forwardRef(
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
) : isTimeline ? (
|
||||
<div style={{ width: '100%', height: '100%', minHeight: 0 }}>
|
||||
<Spin spinning={loading}>
|
||||
<ObjectTimeline
|
||||
type={type}
|
||||
records={tableData}
|
||||
model={model}
|
||||
startDate={viewMode.settings?.startDate}
|
||||
endDate={viewMode.settings?.endDate}
|
||||
colorProperty={viewMode.settings?.colorProperty}
|
||||
filter={effectiveFilter}
|
||||
masterFilter={resolvedMasterFilter}
|
||||
isEditing={isEditing}
|
||||
rowActions={rowActions}
|
||||
renderActions={renderActions}
|
||||
lazyLoading={lazyLoading}
|
||||
loading={loading}
|
||||
skeletonBoundaryIds={skeletonBoundaryIds}
|
||||
onScroll={handleScroll}
|
||||
rowWrapper={(record, row) => (
|
||||
<RowForm
|
||||
key={record._id}
|
||||
record={record}
|
||||
isEditing={isEditing}
|
||||
onRegister={registerForm}
|
||||
>
|
||||
{row}
|
||||
</RowForm>
|
||||
)}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
) : isCards ? (
|
||||
<div
|
||||
className='objectTableCardsContainer'
|
||||
|
||||
@ -14,19 +14,23 @@ import {
|
||||
import GridIcon from '../../Icons/GridIcon'
|
||||
import ListIcon from '../../Icons/ListIcon'
|
||||
import KanbanIcon from '../../Icons/KanbanIcon'
|
||||
import TimelineIcon from '../../Icons/TimelineIcon'
|
||||
import SettingsIcon from '../../Icons/SettingsIcon'
|
||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
import {
|
||||
getViewModeType,
|
||||
isKanbanCategoryProperty,
|
||||
isTimelineDateProperty,
|
||||
isTimelineColorProperty,
|
||||
normalizeViewMode
|
||||
} from './viewModeUtils'
|
||||
|
||||
const VIEW_MODE_OPTIONS = [
|
||||
{ type: 'list', label: 'List' },
|
||||
{ type: 'cards', label: 'Cards' },
|
||||
{ type: 'kanban', label: 'Kanban' }
|
||||
{ type: 'kanban', label: 'Kanban' },
|
||||
{ type: 'timeline', label: 'Timeline' }
|
||||
]
|
||||
|
||||
const { Text } = Typography
|
||||
@ -52,18 +56,33 @@ const ObjectTableViewButton = ({
|
||||
) || [],
|
||||
[model]
|
||||
)
|
||||
const dateProperties = useMemo(
|
||||
() => model?.properties?.filter(isTimelineDateProperty) || [],
|
||||
[model]
|
||||
)
|
||||
|
||||
const colorProperties = useMemo(
|
||||
() => model?.properties?.filter(isTimelineColorProperty) || [],
|
||||
[model]
|
||||
)
|
||||
|
||||
const hasKanbanOption = categoryProperties.length > 0
|
||||
const hasTimelineOption = dateProperties.length > 0
|
||||
const defaultCategoryProperty =
|
||||
categoryProperties.find((property) => property.type === 'state')?.name ||
|
||||
categoryProperties[0]?.name
|
||||
const defaultStartDate = dateProperties[0]?.name
|
||||
const defaultColorProperty =
|
||||
colorProperties[0]?.color || colorProperties[0]?.state
|
||||
|
||||
const availableViewModes = useMemo(
|
||||
() =>
|
||||
VIEW_MODE_OPTIONS.filter(
|
||||
(option) => option.type !== 'kanban' || hasKanbanOption
|
||||
(option) =>
|
||||
(option.type !== 'kanban' || hasKanbanOption) &&
|
||||
(option.type !== 'timeline' || hasTimelineOption)
|
||||
).map((option) => option.type),
|
||||
[hasKanbanOption]
|
||||
[hasKanbanOption, hasTimelineOption]
|
||||
)
|
||||
|
||||
const handleTypeChange = (nextType) => {
|
||||
@ -87,6 +106,23 @@ const ObjectTableViewButton = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (nextType === 'timeline') {
|
||||
const existingSettings =
|
||||
normalizedViewMode.type === 'timeline'
|
||||
? normalizedViewMode.settings
|
||||
: undefined
|
||||
setViewMode({
|
||||
type: 'timeline',
|
||||
settings: {
|
||||
startDate: existingSettings?.startDate || defaultStartDate,
|
||||
...(existingSettings?.endDate
|
||||
? { endDate: existingSettings.endDate }
|
||||
: {})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setViewMode({ type: nextType })
|
||||
}
|
||||
|
||||
@ -97,6 +133,16 @@ const ObjectTableViewButton = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleTimelineSettingChange = (name, value) => {
|
||||
const settings = {
|
||||
...normalizedViewMode.settings,
|
||||
[name]: value
|
||||
}
|
||||
if (!settings.endDate) delete settings.endDate
|
||||
if (!settings.colorProperty) delete settings.colorProperty
|
||||
setViewMode({ type: 'timeline', settings })
|
||||
}
|
||||
|
||||
const { onClick: buttonOnClick, ...restButtonProps } = buttonProps
|
||||
|
||||
const handleCycleView = (event) => {
|
||||
@ -115,6 +161,8 @@ const ObjectTableViewButton = ({
|
||||
<GridIcon />
|
||||
) : normalizedViewMode.type === 'kanban' ? (
|
||||
<KanbanIcon />
|
||||
) : normalizedViewMode.type === 'timeline' ? (
|
||||
<TimelineIcon />
|
||||
) : (
|
||||
<ListIcon />
|
||||
)
|
||||
@ -130,7 +178,9 @@ const ObjectTableViewButton = ({
|
||||
>
|
||||
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
|
||||
{VIEW_MODE_OPTIONS.filter(
|
||||
(option) => option.type !== 'kanban' || hasKanbanOption
|
||||
(option) =>
|
||||
(option.type !== 'kanban' || hasKanbanOption) &&
|
||||
(option.type !== 'timeline' || hasTimelineOption)
|
||||
).map((option) => (
|
||||
<Radio key={option.type} value={option.type}>
|
||||
<Flex
|
||||
@ -139,7 +189,8 @@ const ObjectTableViewButton = ({
|
||||
style={{ marginLeft: '4px' }}
|
||||
>
|
||||
<Text>{option.label}</Text>
|
||||
{option.type === 'kanban' && (
|
||||
{(option.type === 'kanban' ||
|
||||
option.type === 'timeline') && (
|
||||
<Button
|
||||
type='text'
|
||||
size='small'
|
||||
@ -148,7 +199,7 @@ const ObjectTableViewButton = ({
|
||||
style={{ fontSize: '14px', marginTop: '2.5px' }}
|
||||
/>
|
||||
}
|
||||
disabled={normalizedViewMode.type !== 'kanban'}
|
||||
disabled={normalizedViewMode.type !== option.type}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@ -185,27 +236,86 @@ const ObjectTableViewButton = ({
|
||||
getContainer={() => document.body}
|
||||
width={520}
|
||||
>
|
||||
<Flex vertical gap='middle' onMouseDown={(event) => event.stopPropagation()}>
|
||||
<Flex
|
||||
vertical
|
||||
gap='middle'
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Flex gap='middle'>
|
||||
<InfoCircleIcon />
|
||||
<Text strong>Kanban settings</Text>
|
||||
<Text strong>
|
||||
{normalizedViewMode.type === 'timeline'
|
||||
? 'Timeline settings'
|
||||
: 'Kanban settings'}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text>
|
||||
Select the property used to group cards into columns:
|
||||
</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Select category property'
|
||||
value={
|
||||
normalizedViewMode.settings?.categoryProperty ||
|
||||
defaultCategoryProperty
|
||||
}
|
||||
onChange={handleCategoryPropertyChange}
|
||||
options={categoryProperties.map((property) => ({
|
||||
value: property.name,
|
||||
label: property.label || property.name
|
||||
}))}
|
||||
/>
|
||||
{normalizedViewMode.type === 'timeline' ? (
|
||||
<>
|
||||
<Text>
|
||||
Select the required start property and an optional end property:
|
||||
</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Select start date property'
|
||||
value={
|
||||
normalizedViewMode.settings?.startDate || defaultStartDate
|
||||
}
|
||||
onChange={(value) =>
|
||||
handleTimelineSettingChange('startDate', value)
|
||||
}
|
||||
options={dateProperties.map((property) => ({
|
||||
value: property.name,
|
||||
label: property.label || property.name
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Select optional end date property'
|
||||
value={normalizedViewMode.settings?.endDate}
|
||||
onChange={(value) =>
|
||||
handleTimelineSettingChange('endDate', value)
|
||||
}
|
||||
options={dateProperties.map((property) => ({
|
||||
value: property.name,
|
||||
label: property.label || property.name
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Select optional color property'
|
||||
value={
|
||||
normalizedViewMode.settings?.colorProperty ||
|
||||
defaultColorProperty
|
||||
}
|
||||
onChange={(value) =>
|
||||
handleTimelineSettingChange('colorProperty', value)
|
||||
}
|
||||
options={colorProperties.map((property) => ({
|
||||
value: property.name,
|
||||
label: property.label || property.name
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>Select the property used to group cards into columns:</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Select category property'
|
||||
value={
|
||||
normalizedViewMode.settings?.categoryProperty ||
|
||||
defaultCategoryProperty
|
||||
}
|
||||
onChange={handleCategoryPropertyChange}
|
||||
options={categoryProperties.map((property) => ({
|
||||
value: property.name,
|
||||
label: property.label || property.name
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Flex justify='end' gap='small'>
|
||||
<Button type='default' onClick={() => setSettingsOpen(false)}>
|
||||
Cancel
|
||||
|
||||
350
src/components/Dashboard/common/ObjectTimeline.jsx
Normal file
350
src/components/Dashboard/common/ObjectTimeline.jsx
Normal file
@ -0,0 +1,350 @@
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Flex, Typography, Card, Divider } from 'antd'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import ScrollBox from './ScrollBox'
|
||||
import MissingPlaceholder from './MissingPlaceholder'
|
||||
import LoadingPlaceholder from './LoadingPlaceholder'
|
||||
import ObjectTimelineRow from './ObjectTimelineRow'
|
||||
import {
|
||||
getTimelineRange,
|
||||
getTimelineRangeFromValues,
|
||||
getTimelineTicks
|
||||
} from './timelineUtils'
|
||||
import cn from 'classnames'
|
||||
import { getStateTagInfo } from '../utils/Utils'
|
||||
|
||||
const { Text } = Typography
|
||||
const LABEL_WIDTH = 226
|
||||
const SHADOW_PADDING = 12
|
||||
const MIN_TICK_SECTION_WIDTH = 140
|
||||
|
||||
const ObjectTimeline = ({
|
||||
type,
|
||||
records,
|
||||
model,
|
||||
startDate,
|
||||
endDate,
|
||||
colorProperty,
|
||||
filter = {},
|
||||
masterFilter = {},
|
||||
isEditing = false,
|
||||
rowActions = [],
|
||||
renderActions,
|
||||
lazyLoading = false,
|
||||
loading = false,
|
||||
skeletonBoundaryIds = {},
|
||||
onScroll,
|
||||
rowWrapper
|
||||
}) => {
|
||||
const { getModelPropertyValues } = useContext(ApiServerContext)
|
||||
const getModelPropertyValuesRef = useRef(getModelPropertyValues)
|
||||
getModelPropertyValuesRef.current = getModelPropertyValues
|
||||
|
||||
const [startValues, setStartValues] = useState([])
|
||||
const [endValues, setEndValues] = useState([])
|
||||
|
||||
const rangeQueryKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
type,
|
||||
startDate,
|
||||
endDate,
|
||||
filter,
|
||||
masterFilter
|
||||
}),
|
||||
[type, startDate, endDate, filter, masterFilter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !startDate) {
|
||||
setStartValues([])
|
||||
setEndValues([])
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const { filter: activeFilter, masterFilter: activeMasterFilter } =
|
||||
JSON.parse(rangeQueryKey)
|
||||
|
||||
const loadRangeValues = async () => {
|
||||
setStartValues([])
|
||||
setEndValues([])
|
||||
try {
|
||||
const startPromise = getModelPropertyValuesRef.current(
|
||||
type,
|
||||
startDate,
|
||||
{
|
||||
filter: activeFilter,
|
||||
masterFilter: activeMasterFilter
|
||||
}
|
||||
)
|
||||
const endPromise = endDate
|
||||
? getModelPropertyValuesRef.current(type, endDate, {
|
||||
filter: activeFilter,
|
||||
masterFilter: activeMasterFilter
|
||||
})
|
||||
: Promise.resolve([])
|
||||
const [nextStartValues, nextEndValues] = await Promise.all([
|
||||
startPromise,
|
||||
endPromise
|
||||
])
|
||||
if (cancelled) return
|
||||
setStartValues(Array.isArray(nextStartValues) ? nextStartValues : [])
|
||||
setEndValues(Array.isArray(nextEndValues) ? nextEndValues : [])
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
console.error('Error fetching timeline date values:', error)
|
||||
setStartValues([])
|
||||
setEndValues([])
|
||||
}
|
||||
}
|
||||
|
||||
loadRangeValues()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [endDate, rangeQueryKey, startDate, type])
|
||||
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event) => {
|
||||
const { scrollLeft } = event.target
|
||||
setShowShadow(scrollLeft > 0)
|
||||
onScroll?.(event)
|
||||
},
|
||||
[onScroll]
|
||||
)
|
||||
|
||||
const timelineRange = useMemo(() => {
|
||||
const fromValues = getTimelineRangeFromValues(startValues, endValues)
|
||||
if (fromValues) return fromValues
|
||||
return getTimelineRange(records, startDate, endDate)
|
||||
}, [endDate, endValues, records, startDate, startValues])
|
||||
|
||||
const range = useMemo(
|
||||
() => timelineRange || getTimelineRangeFromValues([new Date()]),
|
||||
[timelineRange]
|
||||
)
|
||||
|
||||
const calculatedLabelWidth = useMemo(() => {
|
||||
return LABEL_WIDTH + rowActions.length * 32
|
||||
}, [rowActions.length])
|
||||
|
||||
const ticks = useMemo(() => getTimelineTicks(range.start, range.end), [range])
|
||||
const tickCount = Math.max(1, ticks.length - 1)
|
||||
const minTrackWidth = MIN_TICK_SECTION_WIDTH * tickCount
|
||||
const minContentWidth = calculatedLabelWidth + minTrackWidth
|
||||
|
||||
const nameProperty = model.properties?.find(
|
||||
(property) => property.name === 'name'
|
||||
)
|
||||
|
||||
const referenceProperty = model.properties?.find(
|
||||
(property) => property.name === '_reference'
|
||||
)
|
||||
|
||||
const colorPropertyObject = model.properties?.find(
|
||||
(property) => property.name === colorProperty
|
||||
)
|
||||
|
||||
if (!startDate) {
|
||||
return (
|
||||
<MissingPlaceholder
|
||||
message='Select a start date property in timeline settings.'
|
||||
hasBackground
|
||||
hasBorder={false}
|
||||
height='260px'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loading && !lazyLoading && records.length === 0) {
|
||||
return (
|
||||
<MissingPlaceholder
|
||||
message='No data.'
|
||||
hasBackground
|
||||
hasBorder={false}
|
||||
height='260px'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<LoadingPlaceholder
|
||||
message='Loading, please wait...'
|
||||
hasBackground
|
||||
hasBorder={false}
|
||||
height='260px'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ height: '100%' }}
|
||||
variant='borderless'
|
||||
styles={{ body: { height: '100%', padding: 0 } }}
|
||||
>
|
||||
<div
|
||||
className={cn('objectTimelineContainer', {
|
||||
objectTimelineShadow: showShadow
|
||||
})}
|
||||
>
|
||||
<ScrollBox
|
||||
style={{ width: '100%', height: '100%', zIndex: 2 }}
|
||||
scrollableNodeProps={{ onScroll: handleScroll }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: minContentWidth,
|
||||
minHeight: '100%'
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
width: '100%',
|
||||
minWidth: minContentWidth,
|
||||
height: 47,
|
||||
background: 'var(--color-table-header-bg, #fff)',
|
||||
borderBottom:
|
||||
'1px solid color-mix(in srgb, var(--color-text) 12%, transparent)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='objectTimelineRowHeaderLabelContainer'
|
||||
style={{ minWidth: calculatedLabelWidth + SHADOW_PADDING }}
|
||||
>
|
||||
<Flex
|
||||
align='center'
|
||||
className='objectTimelineRowHeaderLabel'
|
||||
style={{
|
||||
width: calculatedLabelWidth,
|
||||
minWidth: calculatedLabelWidth
|
||||
}}
|
||||
>
|
||||
<Divider
|
||||
type='vertical'
|
||||
style={{ margin: '0 8px 0 0', height: 22.7 }}
|
||||
/>
|
||||
<Text strong>
|
||||
{nameProperty?.label
|
||||
? nameProperty?.label
|
||||
: referenceProperty?.label}
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
marginLeft: SHADOW_PADDING * -1,
|
||||
minWidth: minTrackWidth
|
||||
}}
|
||||
>
|
||||
{ticks.map((tick, index) => {
|
||||
return (
|
||||
<Text
|
||||
key={tick.value}
|
||||
type='secondary'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 13,
|
||||
left: `${(index / tickCount) * 100}%`,
|
||||
padding: '0 4px',
|
||||
fontSize: 11,
|
||||
whiteSpace: 'nowrap',
|
||||
transform:
|
||||
index === tickCount
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
{tick.label}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Flex>
|
||||
{records.map((record) => {
|
||||
const skeletonBoundary =
|
||||
record._id === skeletonBoundaryIds.next
|
||||
? 'next'
|
||||
: record._id === skeletonBoundaryIds.previous
|
||||
? 'previous'
|
||||
: undefined
|
||||
var color = 'var(--color-primary, #1677ff)'
|
||||
if (colorPropertyObject) {
|
||||
if (
|
||||
colorPropertyObject.type === 'state' &&
|
||||
record[colorPropertyObject.name]?.type
|
||||
) {
|
||||
console.log('GOT STATE')
|
||||
color = `var(--color-${getStateTagInfo(record[colorPropertyObject.name].type).status}, #1677ff)`
|
||||
} else {
|
||||
color = record[colorPropertyObject.name]
|
||||
}
|
||||
}
|
||||
const row = (
|
||||
<ObjectTimelineRow
|
||||
key={record._id}
|
||||
model={model}
|
||||
record={record}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
rangeStart={range.start}
|
||||
rangeEnd={range.end}
|
||||
labelWidth={calculatedLabelWidth}
|
||||
minTrackWidth={minTrackWidth}
|
||||
tickCount={tickCount}
|
||||
isEditing={isEditing}
|
||||
rowActions={rowActions}
|
||||
renderActions={renderActions}
|
||||
lazyLoading={lazyLoading}
|
||||
skeletonBoundary={skeletonBoundary}
|
||||
shadowPadding={SHADOW_PADDING}
|
||||
color={color}
|
||||
/>
|
||||
)
|
||||
return rowWrapper ? rowWrapper(record, row) : row
|
||||
})}
|
||||
</div>
|
||||
</ScrollBox>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
ObjectTimeline.propTypes = {
|
||||
type: PropTypes.string,
|
||||
records: PropTypes.array.isRequired,
|
||||
model: PropTypes.object.isRequired,
|
||||
startDate: PropTypes.string,
|
||||
endDate: PropTypes.string,
|
||||
colorProperty: PropTypes.string,
|
||||
filter: PropTypes.object,
|
||||
masterFilter: PropTypes.object,
|
||||
isEditing: PropTypes.bool,
|
||||
rowActions: PropTypes.array,
|
||||
renderActions: PropTypes.func.isRequired,
|
||||
lazyLoading: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
skeletonBoundaryIds: PropTypes.object,
|
||||
onScroll: PropTypes.func,
|
||||
rowWrapper: PropTypes.func
|
||||
}
|
||||
|
||||
export default ObjectTimeline
|
||||
121
src/components/Dashboard/common/ObjectTimelineItem.jsx
Normal file
121
src/components/Dashboard/common/ObjectTimelineItem.jsx
Normal file
@ -0,0 +1,121 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Flex, Typography } from 'antd'
|
||||
import classNames from 'classnames'
|
||||
import { getTimelineItemPosition } from './timelineUtils'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const ObjectTimelineItem = ({
|
||||
label,
|
||||
startValue,
|
||||
endValue,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
labelIsReference = false,
|
||||
isSkeleton = false,
|
||||
color = 'var(--color-primary, #1677ff)'
|
||||
}) => {
|
||||
if (isSkeleton) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '5%',
|
||||
width: '35%',
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--color-text) 8%, transparent)'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const position = getTimelineItemPosition(
|
||||
startValue,
|
||||
endValue,
|
||||
rangeStart,
|
||||
rangeEnd
|
||||
)
|
||||
if (!position) {
|
||||
return <Text type='secondary'>No valid start date</Text>
|
||||
}
|
||||
|
||||
const startLabel = dayjs(startValue).format('YYYY-MM-DD HH:mm')
|
||||
const endLabel =
|
||||
!position.isPoint && endValue && dayjs(endValue).isValid()
|
||||
? dayjs(endValue).format('YYYY-MM-DD HH:mm')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align='center'
|
||||
title={endLabel ? `${startLabel} – ${endLabel}` : startLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${position.left}%`,
|
||||
width: position.isPoint ? 'max-content' : `${position.width}%`,
|
||||
minWidth: position.isPoint ? undefined : 28,
|
||||
maxWidth: `calc(${100 - position.left}% - 4px)`,
|
||||
height: 30,
|
||||
padding: '0 10px',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: '0 0 auto',
|
||||
borderRadius: 8,
|
||||
color: 'white',
|
||||
background: color,
|
||||
boxShadow:
|
||||
'0 1px 3px color-mix(in srgb, var(--color-primary) 35%, transparent)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='objectTimelineItemLabelContainer'
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to right,
|
||||
${color} 20%,
|
||||
color-mix(in srgb, ${color} 35%, transparent) 100%
|
||||
)`
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={classNames('objectTimelineItemLabel', {
|
||||
'objectTimelineItemLabel-reference': labelIsReference,
|
||||
objectTimelineItemWithEndDate: endValue
|
||||
})}
|
||||
style={{
|
||||
flex: position.isPoint ? '0 0 auto' : '1 1 auto',
|
||||
color: 'inherit'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
ObjectTimelineItem.propTypes = {
|
||||
label: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
startValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.instanceOf(Date)
|
||||
]),
|
||||
endValue: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
|
||||
rangeStart: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.instanceOf(Date)
|
||||
]).isRequired,
|
||||
rangeEnd: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.instanceOf(Date)
|
||||
]).isRequired,
|
||||
isSkeleton: PropTypes.bool,
|
||||
labelIsReference: PropTypes.bool
|
||||
}
|
||||
|
||||
export default ObjectTimelineItem
|
||||
160
src/components/Dashboard/common/ObjectTimelineRow.jsx
Normal file
160
src/components/Dashboard/common/ObjectTimelineRow.jsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { createElement } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Flex, Skeleton } from 'antd'
|
||||
import { getPropertyValue } from '../../../database/ObjectModels'
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
import ObjectTimelineItem from './ObjectTimelineItem'
|
||||
|
||||
const ObjectTimelineRow = ({
|
||||
model,
|
||||
record,
|
||||
startDate,
|
||||
endDate,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
labelWidth,
|
||||
minTrackWidth,
|
||||
tickCount,
|
||||
isEditing = false,
|
||||
rowActions = [],
|
||||
renderActions,
|
||||
lazyLoading = false,
|
||||
skeletonBoundary,
|
||||
color = 'var(--color-primary, #1677ff)',
|
||||
shadowPadding = 12
|
||||
}) => {
|
||||
const isSkeleton = record?.isSkeleton === true
|
||||
const nameProperty = model.properties?.find(
|
||||
(property) => property.name === 'name'
|
||||
)
|
||||
const referenceProperty = model.properties?.find(
|
||||
(property) => property.name === '_reference'
|
||||
)
|
||||
const nameValue = getPropertyValue(record, 'name')
|
||||
const modelPrefix = model.prefix
|
||||
const label =
|
||||
typeof nameValue === 'string'
|
||||
? nameValue
|
||||
: modelPrefix + ':' + String(record?._reference || '')
|
||||
const startValue = getPropertyValue(record, startDate)
|
||||
const endValue = endDate ? getPropertyValue(record, endDate) : undefined
|
||||
const actions =
|
||||
!isSkeleton && rowActions.length > 0
|
||||
? renderActions(record, lazyLoading)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Flex
|
||||
data-skeleton-boundary={skeletonBoundary}
|
||||
className='objectTimelineRow'
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: labelWidth + minTrackWidth,
|
||||
minHeight: 49.5,
|
||||
borderBottom: '1px solid var(--color-table-row-border)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='objectTimelineRowLabelContainer'
|
||||
style={{ minWidth: labelWidth + shadowPadding }}
|
||||
>
|
||||
<Flex
|
||||
align='center'
|
||||
gap='small'
|
||||
className='objectTimelineRowLabel'
|
||||
style={{
|
||||
width: labelWidth,
|
||||
minWidth: labelWidth,
|
||||
flex: '0 0 auto',
|
||||
padding: '4px 12px 4px 8px',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{isSkeleton ? (
|
||||
<Skeleton.Input active size='small' style={{ width: '75%' }} />
|
||||
) : (
|
||||
<>
|
||||
<Flex style={{ width: 29 }} justify='center'>
|
||||
{createElement(model.icon, {
|
||||
style: { fontSize: 14, flex: '0 0 auto' }
|
||||
})}
|
||||
</Flex>
|
||||
<Flex
|
||||
vertical
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: '8px',
|
||||
minWidth: 168
|
||||
}}
|
||||
>
|
||||
{nameProperty ? (
|
||||
<ObjectProperty
|
||||
{...nameProperty}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<ObjectProperty
|
||||
{...referenceProperty}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
style={{ fontWeight: 600, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{actions}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
<Flex
|
||||
align='center'
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
minWidth: minTrackWidth,
|
||||
padding: '0 4px',
|
||||
marginLeft: shadowPadding * -1,
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, color-mix(in srgb, var(--color-text) 7%, transparent) 1px, transparent 1px)',
|
||||
backgroundSize: `${100 / tickCount}% 100%`
|
||||
}}
|
||||
>
|
||||
<ObjectTimelineItem
|
||||
label={label}
|
||||
labelIsReference={nameProperty == undefined}
|
||||
startValue={startValue}
|
||||
endValue={endValue}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
isSkeleton={isSkeleton}
|
||||
color={color}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
ObjectTimelineRow.propTypes = {
|
||||
model: PropTypes.object.isRequired,
|
||||
record: PropTypes.object.isRequired,
|
||||
color: PropTypes.string,
|
||||
startDate: PropTypes.string.isRequired,
|
||||
endDate: PropTypes.string,
|
||||
rangeStart: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
rangeEnd: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
labelWidth: PropTypes.number.isRequired,
|
||||
minTrackWidth: PropTypes.number.isRequired,
|
||||
tickCount: PropTypes.number.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
rowActions: PropTypes.array,
|
||||
renderActions: PropTypes.func.isRequired,
|
||||
lazyLoading: PropTypes.bool,
|
||||
skeletonBoundary: PropTypes.string,
|
||||
shadowPadding: PropTypes.number
|
||||
}
|
||||
|
||||
export default ObjectTimelineRow
|
||||
@ -2,6 +2,7 @@ import PropTypes from 'prop-types'
|
||||
import { Badge, Flex, Tag } from 'antd'
|
||||
import { useMemo } from 'react'
|
||||
import LoadingIcon from '../../Icons/LoadingIcon'
|
||||
import { getStateTagInfo } from '../utils/Utils'
|
||||
|
||||
const StateTag = ({
|
||||
state,
|
||||
@ -11,198 +12,7 @@ const StateTag = ({
|
||||
style = {}
|
||||
}) => {
|
||||
const { badgeStatus, badgeText } = useMemo(() => {
|
||||
let status = 'default'
|
||||
let text = 'Unknown'
|
||||
|
||||
switch (state) {
|
||||
case 'online':
|
||||
status = 'success'
|
||||
text = 'Online'
|
||||
break
|
||||
case 'standby':
|
||||
status = 'success'
|
||||
text = 'Standby'
|
||||
break
|
||||
case 'complete':
|
||||
status = 'success'
|
||||
text = 'Complete'
|
||||
break
|
||||
case 'offline':
|
||||
status = 'default'
|
||||
text = 'Offline'
|
||||
break
|
||||
case 'shutdown':
|
||||
status = 'default'
|
||||
text = 'Shutdown'
|
||||
break
|
||||
case 'initializing':
|
||||
status = 'warning'
|
||||
text = 'Initializing'
|
||||
break
|
||||
case 'connecting':
|
||||
status = 'warning'
|
||||
text = 'Connecting'
|
||||
break
|
||||
case 'deploying':
|
||||
status = 'warning'
|
||||
text = 'Deploying'
|
||||
break
|
||||
case 'printing':
|
||||
status = 'processing'
|
||||
text = 'Printing'
|
||||
break
|
||||
case 'paused':
|
||||
status = 'warning'
|
||||
text = 'Paused'
|
||||
break
|
||||
case 'cancelled':
|
||||
status = 'error'
|
||||
text = 'Cancelled'
|
||||
break
|
||||
case 'loading':
|
||||
status = 'processing'
|
||||
text = 'Uploading'
|
||||
break
|
||||
case 'processing':
|
||||
status = 'processing'
|
||||
text = 'Processing'
|
||||
break
|
||||
case 'ready':
|
||||
status = 'success'
|
||||
text = 'Ready'
|
||||
break
|
||||
case 'new':
|
||||
status = 'success'
|
||||
text = 'New'
|
||||
break
|
||||
case 'error':
|
||||
status = 'error'
|
||||
text = 'Error'
|
||||
break
|
||||
case 'startup':
|
||||
status = 'warning'
|
||||
text = 'Startup'
|
||||
break
|
||||
case 'draft':
|
||||
status = 'default'
|
||||
text = 'Draft'
|
||||
break
|
||||
case 'active':
|
||||
status = 'success'
|
||||
text = 'Active'
|
||||
break
|
||||
case 'inactive':
|
||||
status = 'default'
|
||||
text = 'Inactive'
|
||||
break
|
||||
case 'deleted':
|
||||
status = 'error'
|
||||
text = 'Deleted'
|
||||
break
|
||||
case 'suspended':
|
||||
status = 'warning'
|
||||
text = 'Suspended'
|
||||
break
|
||||
case 'syncing':
|
||||
status = 'processing'
|
||||
text = 'Syncing'
|
||||
break
|
||||
case 'publishing':
|
||||
status = 'processing'
|
||||
text = 'Publishing'
|
||||
break
|
||||
case 'unpublishing':
|
||||
status = 'processing'
|
||||
text = 'Unpublishing'
|
||||
break
|
||||
case 'disconnected':
|
||||
status = 'default'
|
||||
text = 'Disconnected'
|
||||
break
|
||||
case 'failed':
|
||||
status = 'error'
|
||||
text = 'Failed'
|
||||
break
|
||||
case 'queued':
|
||||
status = 'warning'
|
||||
text = 'Queued'
|
||||
break
|
||||
case 'pending':
|
||||
status = 'default'
|
||||
text = 'Pending'
|
||||
break
|
||||
case 'used':
|
||||
status = 'warning'
|
||||
text = 'Used'
|
||||
break
|
||||
case 'consumed':
|
||||
status = 'default'
|
||||
text = 'Consumed'
|
||||
break
|
||||
case 'unconsumed':
|
||||
status = 'success'
|
||||
text = 'Unconsumed'
|
||||
break
|
||||
case 'sent':
|
||||
status = 'cyan'
|
||||
text = 'Sent'
|
||||
break
|
||||
case 'acknowledged':
|
||||
status = 'purple'
|
||||
text = 'Acknowledged'
|
||||
break
|
||||
case 'confirmed':
|
||||
status = 'purple'
|
||||
text = 'Confirmed'
|
||||
break
|
||||
case 'ordered':
|
||||
status = 'cyan'
|
||||
text = 'Ordered'
|
||||
break
|
||||
case 'posted':
|
||||
status = 'magenta'
|
||||
text = 'Posted'
|
||||
break
|
||||
case 'authorised':
|
||||
status = 'success'
|
||||
text = 'Authorised'
|
||||
break
|
||||
case 'declined':
|
||||
status = 'error'
|
||||
text = 'Declined'
|
||||
break
|
||||
case 'received':
|
||||
status = 'success'
|
||||
text = 'Received'
|
||||
break
|
||||
case 'invoiced':
|
||||
status = 'warning'
|
||||
text = 'Invoiced'
|
||||
break
|
||||
case 'planned':
|
||||
status = 'warning'
|
||||
text = 'Planned'
|
||||
break
|
||||
case 'partiallyShipped':
|
||||
status = 'processing'
|
||||
text = 'Partially Shipped'
|
||||
break
|
||||
case 'shipped':
|
||||
status = 'processing'
|
||||
text = 'Shipped'
|
||||
break
|
||||
case 'delivered':
|
||||
status = 'success'
|
||||
text = 'Delivered'
|
||||
break
|
||||
case 'paid':
|
||||
status = 'success'
|
||||
text = 'Paid'
|
||||
break
|
||||
default:
|
||||
status = 'default'
|
||||
text = state || 'Unknown'
|
||||
}
|
||||
const { status, text } = getStateTagInfo(state)
|
||||
|
||||
return { badgeStatus: status, badgeText: text }
|
||||
}, [state])
|
||||
|
||||
95
src/components/Dashboard/common/timelineUtils.js
Normal file
95
src/components/Dashboard/common/timelineUtils.js
Normal file
@ -0,0 +1,95 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { getPropertyValue } from '../../../database/ObjectModels'
|
||||
|
||||
const toTimestamp = (value) => {
|
||||
if (value == null || value === '') return null
|
||||
const date = dayjs(value)
|
||||
return date.isValid() ? date.valueOf() : null
|
||||
}
|
||||
|
||||
export const getTimelineDates = (values = []) => {
|
||||
const timestamps = []
|
||||
for (const value of values) {
|
||||
const timestamp = toTimestamp(value)
|
||||
if (timestamp != null) timestamps.push(timestamp)
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
export const getTimelineRangeFromValues = (startValues = [], endValues = []) => {
|
||||
const timestamps = [
|
||||
...getTimelineDates(startValues),
|
||||
...getTimelineDates(endValues)
|
||||
]
|
||||
if (timestamps.length === 0) return null
|
||||
|
||||
const minimum = Math.min(...timestamps)
|
||||
const maximum = Math.max(...timestamps)
|
||||
const start = dayjs(minimum).startOf('day').subtract(1, 'day')
|
||||
const end = dayjs(maximum).startOf('day').add(2, 'day')
|
||||
|
||||
return {
|
||||
start: start.valueOf(),
|
||||
end: end.valueOf()
|
||||
}
|
||||
}
|
||||
|
||||
export const getTimelineRange = (records, startDate, endDate) => {
|
||||
const startValues = []
|
||||
const endValues = []
|
||||
|
||||
records.forEach((record) => {
|
||||
if (record?.isSkeleton) return
|
||||
if (startDate) startValues.push(getPropertyValue(record, startDate))
|
||||
if (endDate) endValues.push(getPropertyValue(record, endDate))
|
||||
})
|
||||
|
||||
return getTimelineRangeFromValues(startValues, endValues)
|
||||
}
|
||||
|
||||
export const getTimelineTicks = (rangeStart, rangeEnd) => {
|
||||
if (rangeStart == null || rangeEnd == null) return []
|
||||
|
||||
const start = dayjs(rangeStart).startOf('day')
|
||||
const end = dayjs(rangeEnd).startOf('day')
|
||||
const dayCount = Math.max(1, end.diff(start, 'day'))
|
||||
const lastDay = end.subtract(1, 'day')
|
||||
const includeYear = start.year() !== lastDay.year() || start.year() !== end.year()
|
||||
const format = includeYear ? 'MMM D YYYY' : 'MMM D'
|
||||
|
||||
return Array.from({ length: dayCount + 1 }, (_, index) => {
|
||||
const value = start.add(index, 'day')
|
||||
return {
|
||||
value: value.valueOf(),
|
||||
label: value.format(format)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getTimelineItemPosition = (
|
||||
startValue,
|
||||
endValue,
|
||||
rangeStart,
|
||||
rangeEnd
|
||||
) => {
|
||||
if (startValue == null || startValue === '') return null
|
||||
const start = dayjs(startValue)
|
||||
const rangeStartMs = dayjs(rangeStart).valueOf()
|
||||
const rangeEndMs = dayjs(rangeEnd).valueOf()
|
||||
const duration = rangeEndMs - rangeStartMs
|
||||
|
||||
if (!start.isValid() || duration <= 0) return null
|
||||
|
||||
const startMs = Math.min(rangeEndMs, Math.max(rangeStartMs, start.valueOf()))
|
||||
const end = endValue == null || endValue === '' ? null : dayjs(endValue)
|
||||
const hasValidEnd = end?.isValid() && end.valueOf() > startMs
|
||||
const endMs = hasValidEnd
|
||||
? Math.min(rangeEndMs, Math.max(startMs, end.valueOf()))
|
||||
: startMs
|
||||
|
||||
return {
|
||||
left: ((startMs - rangeStartMs) / duration) * 100,
|
||||
width: ((endMs - startMs) / duration) * 100,
|
||||
isPoint: !hasValidEnd
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ export const DEFAULT_VIEW_MODE = { type: 'list' }
|
||||
|
||||
/** Property types that can be used as kanban column categories. */
|
||||
export const KANBAN_CATEGORY_PROPERTY_TYPES = ['state', 'tags']
|
||||
export const TIMELINE_COLOR_PROPERTY_TYPES = ['color', 'state']
|
||||
export const TIMELINE_DATE_PROPERTY_TYPES = ['dateTime']
|
||||
|
||||
export const KANBAN_DEFAULT_COLUMN_WIDTH = 360
|
||||
export const KANBAN_MIN_COLUMN_WIDTH = 200
|
||||
@ -9,11 +11,17 @@ export const KANBAN_MIN_COLUMN_WIDTH = 200
|
||||
export const isKanbanCategoryProperty = (property) =>
|
||||
KANBAN_CATEGORY_PROPERTY_TYPES.includes(property?.type)
|
||||
|
||||
export const isTimelineColorProperty = (property) =>
|
||||
TIMELINE_COLOR_PROPERTY_TYPES.includes(property?.type)
|
||||
|
||||
export const isTimelineDateProperty = (property) =>
|
||||
TIMELINE_DATE_PROPERTY_TYPES.includes(property?.type)
|
||||
|
||||
export const normalizeViewMode = (value) => {
|
||||
if (!value) return DEFAULT_VIEW_MODE
|
||||
if (typeof value === 'string') {
|
||||
return {
|
||||
type: value === 'cards' ? 'cards' : value === 'kanban' ? 'kanban' : 'list'
|
||||
type: ['cards', 'kanban', 'timeline'].includes(value) ? value : 'list'
|
||||
}
|
||||
}
|
||||
if (value?.type) return value
|
||||
@ -22,6 +30,7 @@ export const normalizeViewMode = (value) => {
|
||||
|
||||
export const isCardsView = (vm) => normalizeViewMode(vm).type === 'cards'
|
||||
export const isKanbanView = (vm) => normalizeViewMode(vm).type === 'kanban'
|
||||
export const isTimelineView = (vm) => normalizeViewMode(vm).type === 'timeline'
|
||||
export const getViewModeType = (vm) => normalizeViewMode(vm).type
|
||||
|
||||
export const toCategoryFilterValue = (value) => {
|
||||
|
||||
@ -192,6 +192,14 @@ export const ThemeProvider = ({ children }) => {
|
||||
'--color-button-border',
|
||||
isDarkMode ? '#424242' : '#d9d9d9'
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--color-table-header-bg',
|
||||
isDarkMode ? '#1d1d1d' : '#fafafa'
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--color-table-row-border',
|
||||
isDarkMode ? '#303030' : '#f0f0f0'
|
||||
)
|
||||
}, [isDarkMode, primaryColorOverride])
|
||||
|
||||
const themeConfig = {
|
||||
|
||||
@ -96,6 +96,202 @@ export function timeStringToMinutes(timeString) {
|
||||
return Math.floor(totalMinutes)
|
||||
}
|
||||
|
||||
export function getStateTagInfo(state) {
|
||||
let status = 'default'
|
||||
let text = 'Unknown'
|
||||
|
||||
switch (state) {
|
||||
case 'online':
|
||||
status = 'success'
|
||||
text = 'Online'
|
||||
break
|
||||
case 'standby':
|
||||
status = 'success'
|
||||
text = 'Standby'
|
||||
break
|
||||
case 'complete':
|
||||
status = 'success'
|
||||
text = 'Complete'
|
||||
break
|
||||
case 'offline':
|
||||
status = 'default'
|
||||
text = 'Offline'
|
||||
break
|
||||
case 'shutdown':
|
||||
status = 'default'
|
||||
text = 'Shutdown'
|
||||
break
|
||||
case 'initializing':
|
||||
status = 'warning'
|
||||
text = 'Initializing'
|
||||
break
|
||||
case 'connecting':
|
||||
status = 'warning'
|
||||
text = 'Connecting'
|
||||
break
|
||||
case 'deploying':
|
||||
status = 'warning'
|
||||
text = 'Deploying'
|
||||
break
|
||||
case 'printing':
|
||||
status = 'processing'
|
||||
text = 'Printing'
|
||||
break
|
||||
case 'paused':
|
||||
status = 'warning'
|
||||
text = 'Paused'
|
||||
break
|
||||
case 'cancelled':
|
||||
status = 'error'
|
||||
text = 'Cancelled'
|
||||
break
|
||||
case 'loading':
|
||||
status = 'processing'
|
||||
text = 'Uploading'
|
||||
break
|
||||
case 'processing':
|
||||
status = 'processing'
|
||||
text = 'Processing'
|
||||
break
|
||||
case 'ready':
|
||||
status = 'success'
|
||||
text = 'Ready'
|
||||
break
|
||||
case 'new':
|
||||
status = 'success'
|
||||
text = 'New'
|
||||
break
|
||||
case 'error':
|
||||
status = 'error'
|
||||
text = 'Error'
|
||||
break
|
||||
case 'startup':
|
||||
status = 'warning'
|
||||
text = 'Startup'
|
||||
break
|
||||
case 'draft':
|
||||
status = 'default'
|
||||
text = 'Draft'
|
||||
break
|
||||
case 'active':
|
||||
status = 'success'
|
||||
text = 'Active'
|
||||
break
|
||||
case 'inactive':
|
||||
status = 'default'
|
||||
text = 'Inactive'
|
||||
break
|
||||
case 'deleted':
|
||||
status = 'error'
|
||||
text = 'Deleted'
|
||||
break
|
||||
case 'suspended':
|
||||
status = 'warning'
|
||||
text = 'Suspended'
|
||||
break
|
||||
case 'syncing':
|
||||
status = 'processing'
|
||||
text = 'Syncing'
|
||||
break
|
||||
case 'publishing':
|
||||
status = 'processing'
|
||||
text = 'Publishing'
|
||||
break
|
||||
case 'unpublishing':
|
||||
status = 'processing'
|
||||
text = 'Unpublishing'
|
||||
break
|
||||
case 'disconnected':
|
||||
status = 'default'
|
||||
text = 'Disconnected'
|
||||
break
|
||||
case 'failed':
|
||||
status = 'error'
|
||||
text = 'Failed'
|
||||
break
|
||||
case 'queued':
|
||||
status = 'warning'
|
||||
text = 'Queued'
|
||||
break
|
||||
case 'pending':
|
||||
status = 'default'
|
||||
text = 'Pending'
|
||||
break
|
||||
case 'used':
|
||||
status = 'warning'
|
||||
text = 'Used'
|
||||
break
|
||||
case 'consumed':
|
||||
status = 'default'
|
||||
text = 'Consumed'
|
||||
break
|
||||
case 'unconsumed':
|
||||
status = 'success'
|
||||
text = 'Unconsumed'
|
||||
break
|
||||
case 'sent':
|
||||
status = 'cyan'
|
||||
text = 'Sent'
|
||||
break
|
||||
case 'acknowledged':
|
||||
status = 'purple'
|
||||
text = 'Acknowledged'
|
||||
break
|
||||
case 'confirmed':
|
||||
status = 'purple'
|
||||
text = 'Confirmed'
|
||||
break
|
||||
case 'ordered':
|
||||
status = 'cyan'
|
||||
text = 'Ordered'
|
||||
break
|
||||
case 'posted':
|
||||
status = 'magenta'
|
||||
text = 'Posted'
|
||||
break
|
||||
case 'authorised':
|
||||
status = 'success'
|
||||
text = 'Authorised'
|
||||
break
|
||||
case 'declined':
|
||||
status = 'error'
|
||||
text = 'Declined'
|
||||
break
|
||||
case 'received':
|
||||
status = 'success'
|
||||
text = 'Received'
|
||||
break
|
||||
case 'invoiced':
|
||||
status = 'warning'
|
||||
text = 'Invoiced'
|
||||
break
|
||||
case 'planned':
|
||||
status = 'warning'
|
||||
text = 'Planned'
|
||||
break
|
||||
case 'partiallyShipped':
|
||||
status = 'processing'
|
||||
text = 'Partially Shipped'
|
||||
break
|
||||
case 'shipped':
|
||||
status = 'processing'
|
||||
text = 'Shipped'
|
||||
break
|
||||
case 'delivered':
|
||||
status = 'success'
|
||||
text = 'Delivered'
|
||||
break
|
||||
case 'paid':
|
||||
status = 'success'
|
||||
text = 'Paid'
|
||||
break
|
||||
default:
|
||||
status = 'default'
|
||||
text = state || 'Unknown'
|
||||
}
|
||||
return { status, text }
|
||||
}
|
||||
|
||||
export function round(num, decimals) {
|
||||
return Math.round(num * 10 ** decimals) / 10 ** decimals
|
||||
}
|
||||
@ -177,7 +373,10 @@ const stripProperties = (data, properties) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(property.properties) || property.properties.length === 0) {
|
||||
if (
|
||||
!Array.isArray(property.properties) ||
|
||||
property.properties.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -213,7 +412,10 @@ const collectModelPropertyPaths = (modelDefinition) => {
|
||||
if (property?.name) {
|
||||
paths.push(property.name)
|
||||
}
|
||||
if (Array.isArray(property?.properties) && property.properties.length > 0) {
|
||||
if (
|
||||
Array.isArray(property?.properties) &&
|
||||
property.properties.length > 0
|
||||
) {
|
||||
visit(property.properties)
|
||||
}
|
||||
})
|
||||
@ -311,10 +513,7 @@ export function calculateModelComputedEntries(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(property.properties) &&
|
||||
property.properties.length > 0
|
||||
) {
|
||||
if (Array.isArray(property.properties) && property.properties.length > 0) {
|
||||
if (property.type === 'objectChildren') {
|
||||
const childValues = getValueAtPath(workingData, propertyPath)
|
||||
if (Array.isArray(childValues)) {
|
||||
|
||||
6
src/components/Icons/TimelineIcon.jsx
Normal file
6
src/components/Icons/TimelineIcon.jsx
Normal file
@ -0,0 +1,6 @@
|
||||
import Icon from '@ant-design/icons'
|
||||
import CustomIconSvg from '../../../assets/icons/timelineicon.svg?react'
|
||||
|
||||
const TimelineIcon = (props) => <Icon component={CustomIconSvg} {...props} />
|
||||
|
||||
export default TimelineIcon
|
||||
Loading…
x
Reference in New Issue
Block a user