Add Timeline View Support to ObjectTable and Enhance CSS Styles
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:
Tom Butcher 2026-09-03 22:25:46 +01:00
parent 789e66b0f3
commit d02895b78d
12 changed files with 1245 additions and 228 deletions

View File

@ -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 {

View File

@ -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'

View File

@ -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,14 +236,71 @@ 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>
</Flex>
<Text>
Select the property used to group cards into columns:
<Text strong>
{normalizedViewMode.type === 'timeline'
? 'Timeline settings'
: 'Kanban settings'}
</Text>
</Flex>
{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'
@ -206,6 +314,8 @@ const ObjectTableViewButton = ({
label: property.label || property.name
}))}
/>
</>
)}
<Flex justify='end' gap='small'>
<Button type='default' onClick={() => setSettingsOpen(false)}>
Cancel

View 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

View 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

View 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

View File

@ -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])

View 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
}
}

View File

@ -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) => {

View File

@ -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 = {

View File

@ -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)) {

View 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