@object-ui/plugin-timeline 3.0.3 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -5
- package/dist/index.js +1252 -1214
- package/dist/index.umd.cjs +21 -21
- package/dist/src/ObjectTimeline.d.ts +15 -1
- package/dist/src/ObjectTimeline.d.ts.map +1 -1
- package/dist/src/index.d.ts +13 -3
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/ObjectTimeline.test.tsx +57 -1
- package/src/ObjectTimeline.tsx +71 -22
- package/src/__tests__/ObjectTimeline.spec-config.test.tsx +232 -0
- package/src/__tests__/timeline-variants.test.tsx +150 -0
- package/src/index.tsx +42 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-timeline",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Timeline component plugin for Object UI",
|
|
@@ -24,26 +24,27 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@objectstack/spec": "^3.0
|
|
27
|
+
"@objectstack/spec": "^3.2.0",
|
|
28
|
+
"class-variance-authority": "^0.7.1",
|
|
28
29
|
"zod": "^4.3.6",
|
|
29
|
-
"@object-ui/
|
|
30
|
-
"@object-ui/
|
|
31
|
-
"@object-ui/mobile": "3.0
|
|
32
|
-
"@object-ui/react": "3.0
|
|
33
|
-
"@object-ui/types": "3.0
|
|
30
|
+
"@object-ui/components": "3.1.0",
|
|
31
|
+
"@object-ui/core": "3.1.0",
|
|
32
|
+
"@object-ui/mobile": "3.1.0",
|
|
33
|
+
"@object-ui/react": "3.1.0",
|
|
34
|
+
"@object-ui/types": "3.1.0"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"react": "^18.0.0 || ^19.0.0",
|
|
37
38
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"@types/react": "19.2.
|
|
41
|
+
"@types/react": "19.2.14",
|
|
41
42
|
"@types/react-dom": "19.2.3",
|
|
42
43
|
"@vitejs/plugin-react": "^5.1.4",
|
|
43
44
|
"typescript": "^5.9.3",
|
|
44
45
|
"vite": "^7.3.1",
|
|
45
46
|
"vite-plugin-dts": "^4.5.4",
|
|
46
|
-
"@object-ui/data-objectstack": "3.0
|
|
47
|
+
"@object-ui/data-objectstack": "3.1.0"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
50
|
"build": "vite build",
|
|
@@ -15,6 +15,7 @@ vi.mock('@object-ui/react', () => ({
|
|
|
15
15
|
close: vi.fn(),
|
|
16
16
|
setIsOpen: vi.fn(),
|
|
17
17
|
mode: 'overlay',
|
|
18
|
+
view: undefined,
|
|
18
19
|
}),
|
|
19
20
|
}));
|
|
20
21
|
|
|
@@ -66,7 +67,7 @@ describe('ObjectTimeline', () => {
|
|
|
66
67
|
type: 'timeline',
|
|
67
68
|
objectName: 'events',
|
|
68
69
|
titleField: 'name',
|
|
69
|
-
dateField: 'date' //
|
|
70
|
+
dateField: 'date' // Backward-compat: legacy dateField still works
|
|
70
71
|
};
|
|
71
72
|
|
|
72
73
|
render(<ObjectTimeline schema={schema} dataSource={mockDataSource} />);
|
|
@@ -79,4 +80,59 @@ describe('ObjectTimeline', () => {
|
|
|
79
80
|
expect(screen.getByText('Event 1')).toBeDefined();
|
|
80
81
|
});
|
|
81
82
|
});
|
|
83
|
+
|
|
84
|
+
it('uses spec-compliant startDateField property', async () => {
|
|
85
|
+
const dataWithDates = [
|
|
86
|
+
{ id: '1', name: 'Sprint 1', start_date: '2024-01-01', end_date: '2024-01-14', team: 'Alpha' },
|
|
87
|
+
];
|
|
88
|
+
(mockDataSource.find as any).mockResolvedValue(dataWithDates);
|
|
89
|
+
|
|
90
|
+
const schema: any = {
|
|
91
|
+
type: 'timeline',
|
|
92
|
+
objectName: 'sprints',
|
|
93
|
+
titleField: 'name',
|
|
94
|
+
startDateField: 'start_date',
|
|
95
|
+
endDateField: 'end_date',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
render(<ObjectTimeline schema={schema} dataSource={mockDataSource} />);
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(screen.getByText('Sprint 1')).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('supports groupByField and colorField properties', async () => {
|
|
106
|
+
const dataWithGroups = [
|
|
107
|
+
{ id: '1', name: 'Task A', start_date: '2024-01-01', team: 'Alpha', priority: 'high' },
|
|
108
|
+
];
|
|
109
|
+
(mockDataSource.find as any).mockResolvedValue(dataWithGroups);
|
|
110
|
+
|
|
111
|
+
const schema: any = {
|
|
112
|
+
type: 'timeline',
|
|
113
|
+
objectName: 'tasks',
|
|
114
|
+
titleField: 'name',
|
|
115
|
+
startDateField: 'start_date',
|
|
116
|
+
groupByField: 'team',
|
|
117
|
+
colorField: 'priority',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
render(<ObjectTimeline schema={schema} dataSource={mockDataSource} />);
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getByText('Task A')).toBeDefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('supports scale property', () => {
|
|
128
|
+
const schema: any = {
|
|
129
|
+
type: 'timeline',
|
|
130
|
+
items: [
|
|
131
|
+
{ title: 'Weekly Event', date: '2024-01-01' }
|
|
132
|
+
],
|
|
133
|
+
scale: 'week',
|
|
134
|
+
};
|
|
135
|
+
render(<ObjectTimeline schema={schema} />);
|
|
136
|
+
expect(screen.getByText('Weekly Event')).toBeDefined();
|
|
137
|
+
});
|
|
82
138
|
});
|
package/src/ObjectTimeline.tsx
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
10
|
-
import type { DataSource, TimelineSchema } from '@object-ui/types';
|
|
10
|
+
import type { DataSource, TimelineSchema, TimelineConfig } from '@object-ui/types';
|
|
11
11
|
import { useDataScope, useNavigationOverlay } from '@object-ui/react';
|
|
12
12
|
import { NavigationOverlay } from '@object-ui/components';
|
|
13
|
+
import { extractRecords, buildExpandFields } from '@object-ui/core';
|
|
13
14
|
import { usePullToRefresh } from '@object-ui/mobile';
|
|
14
15
|
import { z } from 'zod';
|
|
15
16
|
import { TimelineRenderer } from './renderer';
|
|
@@ -25,16 +26,36 @@ const TimelineExtensionSchema = z.object({
|
|
|
25
26
|
mapping: TimelineMappingSchema.optional(),
|
|
26
27
|
objectName: z.string().optional(),
|
|
27
28
|
titleField: z.string().optional(),
|
|
29
|
+
/** @deprecated Use startDateField instead */
|
|
28
30
|
dateField: z.string().optional(),
|
|
31
|
+
startDateField: z.string().optional(),
|
|
32
|
+
endDateField: z.string().optional(),
|
|
29
33
|
descriptionField: z.string().optional(),
|
|
34
|
+
groupByField: z.string().optional(),
|
|
35
|
+
colorField: z.string().optional(),
|
|
36
|
+
scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).optional(),
|
|
30
37
|
});
|
|
31
38
|
|
|
32
39
|
export interface ObjectTimelineProps {
|
|
33
40
|
schema: TimelineSchema & {
|
|
34
41
|
objectName?: string;
|
|
42
|
+
/** Spec-compliant nested timeline config */
|
|
43
|
+
timeline?: TimelineConfig;
|
|
44
|
+
/** @deprecated Use timeline.titleField instead */
|
|
35
45
|
titleField?: string;
|
|
46
|
+
/** @deprecated Use timeline.startDateField instead */
|
|
36
47
|
dateField?: string;
|
|
48
|
+
/** @deprecated Use timeline.startDateField instead */
|
|
49
|
+
startDateField?: string;
|
|
50
|
+
/** @deprecated Use timeline.endDateField instead */
|
|
51
|
+
endDateField?: string;
|
|
37
52
|
descriptionField?: string;
|
|
53
|
+
/** @deprecated Use timeline.groupByField instead */
|
|
54
|
+
groupByField?: string;
|
|
55
|
+
/** @deprecated Use timeline.colorField instead */
|
|
56
|
+
colorField?: string;
|
|
57
|
+
/** @deprecated Use timeline.scale instead */
|
|
58
|
+
scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
38
59
|
// Map data fields to timeline item properties
|
|
39
60
|
mapping?: {
|
|
40
61
|
title?: string;
|
|
@@ -61,6 +82,10 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
61
82
|
const [loading, setLoading] = useState(false);
|
|
62
83
|
const [error, setError] = useState<Error | null>(null);
|
|
63
84
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
85
|
+
const [objectDef, setObjectDef] = useState<any>(null);
|
|
86
|
+
|
|
87
|
+
// Resolve nested TimelineConfig (spec-compliant)
|
|
88
|
+
const timelineConfig = schema.timeline;
|
|
64
89
|
|
|
65
90
|
useEffect(() => {
|
|
66
91
|
const result = TimelineExtensionSchema.safeParse(schema);
|
|
@@ -71,24 +96,35 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
71
96
|
|
|
72
97
|
const boundData = useDataScope(schema.bind);
|
|
73
98
|
|
|
99
|
+
// Fetch object definition for metadata
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let isMounted = true;
|
|
102
|
+
const fetchMeta = async () => {
|
|
103
|
+
if (!dataSource || typeof dataSource.getObjectSchema !== 'function' || !schema.objectName) return;
|
|
104
|
+
try {
|
|
105
|
+
const def = await dataSource.getObjectSchema(schema.objectName);
|
|
106
|
+
if (isMounted) setObjectDef(def);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.warn('Failed to fetch object def for ObjectTimeline', e);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
fetchMeta();
|
|
112
|
+
return () => { isMounted = false; };
|
|
113
|
+
}, [schema.objectName, dataSource]);
|
|
114
|
+
|
|
74
115
|
useEffect(() => {
|
|
75
116
|
const fetchData = async () => {
|
|
76
|
-
if (!dataSource || !schema.objectName) return;
|
|
117
|
+
if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
|
|
77
118
|
setLoading(true);
|
|
78
119
|
try {
|
|
120
|
+
// Auto-inject $expand for lookup/master_detail fields
|
|
121
|
+
const expand = buildExpandFields(objectDef?.fields);
|
|
79
122
|
const results = await dataSource.find(schema.objectName, {
|
|
80
|
-
options: { $top: 100 }
|
|
123
|
+
options: { $top: 100 },
|
|
124
|
+
...(expand.length > 0 ? { $expand: expand } : {}),
|
|
81
125
|
});
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
data = (results as any).records;
|
|
85
|
-
} else if ((results as any).data) {
|
|
86
|
-
data = (results as any).data;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (Array.isArray(data)) {
|
|
90
|
-
setFetchedData(data);
|
|
91
|
-
}
|
|
126
|
+
const data = extractRecords(results);
|
|
127
|
+
setFetchedData(data);
|
|
92
128
|
} catch (e) {
|
|
93
129
|
console.error(e);
|
|
94
130
|
setError(e as Error);
|
|
@@ -100,7 +136,7 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
100
136
|
if (schema.objectName && !boundData && !schema.items && !(props as any).data) {
|
|
101
137
|
fetchData();
|
|
102
138
|
}
|
|
103
|
-
}, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey]);
|
|
139
|
+
}, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDef]);
|
|
104
140
|
|
|
105
141
|
const rawData = (props as any).data || boundData || fetchedData;
|
|
106
142
|
|
|
@@ -108,19 +144,27 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
108
144
|
let effectiveItems = schema.items;
|
|
109
145
|
|
|
110
146
|
if (!effectiveItems && rawData && Array.isArray(rawData)) {
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
const
|
|
147
|
+
// Resolve TimelineConfig with backwards-compatible fallbacks
|
|
148
|
+
const titleField = timelineConfig?.titleField ?? schema.mapping?.title ?? schema.titleField ?? 'name';
|
|
149
|
+
// Spec-compliant: prefer timeline.startDateField, fallback to flat props
|
|
150
|
+
const startDateField = timelineConfig?.startDateField ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField ?? 'date';
|
|
151
|
+
const endDateField = timelineConfig?.endDateField ?? schema.endDateField ?? startDateField;
|
|
152
|
+
const descField = schema.mapping?.description ?? schema.descriptionField ?? 'description';
|
|
153
|
+
const variantField = schema.mapping?.variant ?? 'variant';
|
|
154
|
+
const groupByField = timelineConfig?.groupByField ?? schema.groupByField;
|
|
155
|
+
const colorField = timelineConfig?.colorField ?? schema.colorField;
|
|
115
156
|
|
|
116
157
|
effectiveItems = rawData.map(item => ({
|
|
117
158
|
title: item[titleField],
|
|
118
159
|
// Support both 'time' (vertical) and 'startDate' (gantt)
|
|
119
|
-
time: item[
|
|
120
|
-
startDate: item[
|
|
121
|
-
endDate: item[
|
|
160
|
+
time: item[startDateField],
|
|
161
|
+
startDate: item[startDateField],
|
|
162
|
+
endDate: item[endDateField],
|
|
122
163
|
description: item[descField],
|
|
123
164
|
variant: item[variantField] || 'default',
|
|
165
|
+
// Spec-compliant: group and color support
|
|
166
|
+
...(groupByField ? { group: item[groupByField] } : {}),
|
|
167
|
+
...(colorField ? { color: item[colorField] } : {}),
|
|
124
168
|
// Pass original item for click handlers
|
|
125
169
|
_data: item
|
|
126
170
|
}));
|
|
@@ -141,10 +185,15 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
141
185
|
onRowClick: onRowClick ?? onItemClick,
|
|
142
186
|
});
|
|
143
187
|
|
|
188
|
+
// Resolve scale: spec timeline.scale takes priority over flat schema.scale
|
|
189
|
+
const resolvedScale = timelineConfig?.scale ?? schema.scale;
|
|
190
|
+
|
|
144
191
|
const effectiveSchema = {
|
|
145
192
|
...schema,
|
|
146
193
|
items: effectiveItems || [],
|
|
147
194
|
className: className || schema.className,
|
|
195
|
+
// Map spec 'scale' to renderer 'timeScale' (used by gantt variant)
|
|
196
|
+
...(resolvedScale ? { timeScale: resolvedScale } : {}),
|
|
148
197
|
onItemClick: (item: any) => {
|
|
149
198
|
const record = item._data || item;
|
|
150
199
|
navigation.handleClick(record);
|
|
@@ -161,7 +210,7 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
|
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
return (
|
|
164
|
-
<div ref={pullRef} className="relative overflow-auto h-full">
|
|
213
|
+
<div ref={pullRef} className="relative overflow-auto h-full min-w-0">
|
|
165
214
|
{pullDistance > 0 && (
|
|
166
215
|
<div
|
|
167
216
|
className="flex items-center justify-center text-xs text-muted-foreground"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { ObjectTimeline } from '../ObjectTimeline';
|
|
13
|
+
|
|
14
|
+
// Mock dependencies
|
|
15
|
+
vi.mock('@object-ui/react', () => ({
|
|
16
|
+
useDataScope: () => undefined,
|
|
17
|
+
useNavigationOverlay: () => ({
|
|
18
|
+
isOverlay: false,
|
|
19
|
+
handleClick: vi.fn(),
|
|
20
|
+
selectedRecord: null,
|
|
21
|
+
isOpen: false,
|
|
22
|
+
close: vi.fn(),
|
|
23
|
+
setIsOpen: vi.fn(),
|
|
24
|
+
mode: 'page' as const,
|
|
25
|
+
width: undefined,
|
|
26
|
+
view: undefined,
|
|
27
|
+
open: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('@object-ui/components', () => ({
|
|
32
|
+
NavigationOverlay: () => null,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('@object-ui/mobile', () => ({
|
|
36
|
+
usePullToRefresh: () => ({
|
|
37
|
+
ref: { current: null },
|
|
38
|
+
isRefreshing: false,
|
|
39
|
+
pullDistance: 0,
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Mock the TimelineRenderer to inspect the schema it receives
|
|
44
|
+
const mockTimelineRenderer = vi.fn(() => <div data-testid="timeline-renderer" />);
|
|
45
|
+
vi.mock('../renderer', () => ({
|
|
46
|
+
TimelineRenderer: (props: any) => mockTimelineRenderer(props),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
describe('ObjectTimeline Spec Config', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mockTimelineRenderer.mockClear();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const mockData = [
|
|
55
|
+
{ id: '1', name: 'Event A', start_date: '2024-01-01', end_date: '2024-01-15', category: 'work', priority_color: 'red' },
|
|
56
|
+
{ id: '2', name: 'Event B', start_date: '2024-02-01', end_date: '2024-02-28', category: 'personal', priority_color: 'blue' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
describe('nested timeline config', () => {
|
|
60
|
+
it('uses timeline.startDateField to resolve start dates', () => {
|
|
61
|
+
const schema: any = {
|
|
62
|
+
type: 'timeline' as const,
|
|
63
|
+
objectName: 'events',
|
|
64
|
+
timeline: { startDateField: 'start_date', titleField: 'name' },
|
|
65
|
+
};
|
|
66
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
67
|
+
expect(mockTimelineRenderer).toHaveBeenCalled();
|
|
68
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
69
|
+
expect(renderedSchema.items[0].time).toBe('2024-01-01');
|
|
70
|
+
expect(renderedSchema.items[0].startDate).toBe('2024-01-01');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('uses timeline.titleField to resolve titles', () => {
|
|
74
|
+
const schema: any = {
|
|
75
|
+
type: 'timeline' as const,
|
|
76
|
+
objectName: 'events',
|
|
77
|
+
timeline: { startDateField: 'start_date', titleField: 'name' },
|
|
78
|
+
};
|
|
79
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
80
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
81
|
+
expect(renderedSchema.items[0].title).toBe('Event A');
|
|
82
|
+
expect(renderedSchema.items[1].title).toBe('Event B');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses timeline.endDateField to resolve end dates', () => {
|
|
86
|
+
const schema: any = {
|
|
87
|
+
type: 'timeline' as const,
|
|
88
|
+
objectName: 'events',
|
|
89
|
+
timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' },
|
|
90
|
+
};
|
|
91
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
92
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
93
|
+
expect(renderedSchema.items[0].endDate).toBe('2024-01-15');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('uses timeline.groupByField to add group property', () => {
|
|
97
|
+
const schema: any = {
|
|
98
|
+
type: 'timeline' as const,
|
|
99
|
+
objectName: 'events',
|
|
100
|
+
timeline: { startDateField: 'start_date', titleField: 'name', groupByField: 'category' },
|
|
101
|
+
};
|
|
102
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
103
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
104
|
+
expect(renderedSchema.items[0].group).toBe('work');
|
|
105
|
+
expect(renderedSchema.items[1].group).toBe('personal');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('uses timeline.colorField to add color property', () => {
|
|
109
|
+
const schema: any = {
|
|
110
|
+
type: 'timeline' as const,
|
|
111
|
+
objectName: 'events',
|
|
112
|
+
timeline: { startDateField: 'start_date', titleField: 'name', colorField: 'priority_color' },
|
|
113
|
+
};
|
|
114
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
115
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
116
|
+
expect(renderedSchema.items[0].color).toBe('red');
|
|
117
|
+
expect(renderedSchema.items[1].color).toBe('blue');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses timeline.scale to set timeScale on rendered schema', () => {
|
|
121
|
+
const schema: any = {
|
|
122
|
+
type: 'timeline' as const,
|
|
123
|
+
objectName: 'events',
|
|
124
|
+
timeline: { startDateField: 'start_date', titleField: 'name', scale: 'month' },
|
|
125
|
+
};
|
|
126
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
127
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
128
|
+
expect(renderedSchema.timeScale).toBe('month');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('timeline.scale supports all valid values', () => {
|
|
132
|
+
const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const;
|
|
133
|
+
scales.forEach((scale) => {
|
|
134
|
+
mockTimelineRenderer.mockClear();
|
|
135
|
+
const schema: any = {
|
|
136
|
+
type: 'timeline' as const,
|
|
137
|
+
objectName: 'events',
|
|
138
|
+
timeline: { startDateField: 'start_date', titleField: 'name', scale },
|
|
139
|
+
};
|
|
140
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
141
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
142
|
+
expect(renderedSchema.timeScale).toBe(scale);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('backward compatibility', () => {
|
|
148
|
+
it('falls back to flat startDateField when timeline config is not set', () => {
|
|
149
|
+
const schema: any = {
|
|
150
|
+
type: 'timeline' as const,
|
|
151
|
+
objectName: 'events',
|
|
152
|
+
startDateField: 'start_date',
|
|
153
|
+
titleField: 'name',
|
|
154
|
+
};
|
|
155
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
156
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
157
|
+
expect(renderedSchema.items[0].time).toBe('2024-01-01');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('falls back to legacy dateField when startDateField is not set', () => {
|
|
161
|
+
const data = [{ id: '1', name: 'Event', legacy_date: '2024-03-15' }];
|
|
162
|
+
const schema: any = {
|
|
163
|
+
type: 'timeline' as const,
|
|
164
|
+
objectName: 'events',
|
|
165
|
+
dateField: 'legacy_date',
|
|
166
|
+
titleField: 'name',
|
|
167
|
+
};
|
|
168
|
+
render(<ObjectTimeline schema={schema} data={data} />);
|
|
169
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
170
|
+
expect(renderedSchema.items[0].time).toBe('2024-03-15');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('falls back to flat groupByField when timeline.groupByField is not set', () => {
|
|
174
|
+
const schema: any = {
|
|
175
|
+
type: 'timeline' as const,
|
|
176
|
+
objectName: 'events',
|
|
177
|
+
startDateField: 'start_date',
|
|
178
|
+
titleField: 'name',
|
|
179
|
+
groupByField: 'category',
|
|
180
|
+
};
|
|
181
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
182
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
183
|
+
expect(renderedSchema.items[0].group).toBe('work');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('falls back to flat colorField when timeline.colorField is not set', () => {
|
|
187
|
+
const schema: any = {
|
|
188
|
+
type: 'timeline' as const,
|
|
189
|
+
objectName: 'events',
|
|
190
|
+
startDateField: 'start_date',
|
|
191
|
+
titleField: 'name',
|
|
192
|
+
colorField: 'priority_color',
|
|
193
|
+
};
|
|
194
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
195
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
196
|
+
expect(renderedSchema.items[0].color).toBe('red');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('falls back to flat scale when timeline.scale is not set', () => {
|
|
200
|
+
const schema: any = {
|
|
201
|
+
type: 'timeline' as const,
|
|
202
|
+
objectName: 'events',
|
|
203
|
+
startDateField: 'start_date',
|
|
204
|
+
titleField: 'name',
|
|
205
|
+
scale: 'week',
|
|
206
|
+
};
|
|
207
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
208
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
209
|
+
expect(renderedSchema.timeScale).toBe('week');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('spec timeline config takes priority over flat props', () => {
|
|
213
|
+
const schema: any = {
|
|
214
|
+
type: 'timeline' as const,
|
|
215
|
+
objectName: 'events',
|
|
216
|
+
startDateField: 'end_date', // flat — should be overridden
|
|
217
|
+
titleField: 'id', // flat — should be overridden
|
|
218
|
+
groupByField: 'priority_color', // flat — should be overridden
|
|
219
|
+
timeline: {
|
|
220
|
+
startDateField: 'start_date', // spec — takes priority
|
|
221
|
+
titleField: 'name', // spec — takes priority
|
|
222
|
+
groupByField: 'category', // spec — takes priority
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
render(<ObjectTimeline schema={schema} data={mockData} />);
|
|
226
|
+
const renderedSchema = mockTimelineRenderer.mock.calls[0][0].schema;
|
|
227
|
+
expect(renderedSchema.items[0].time).toBe('2024-01-01');
|
|
228
|
+
expect(renderedSchema.items[0].title).toBe('Event A');
|
|
229
|
+
expect(renderedSchema.items[0].group).toBe('work');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { render } from '@testing-library/react';
|
|
12
|
+
import '@testing-library/jest-dom';
|
|
13
|
+
import {
|
|
14
|
+
Timeline,
|
|
15
|
+
TimelineItem,
|
|
16
|
+
TimelineMarker,
|
|
17
|
+
timelineItemVariants,
|
|
18
|
+
timelineMarkerVariants,
|
|
19
|
+
} from '../index';
|
|
20
|
+
|
|
21
|
+
describe('TimelineItem density variants', () => {
|
|
22
|
+
it('renders with default density (mb-10)', () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<Timeline>
|
|
25
|
+
<TimelineItem data-testid="item">Content</TimelineItem>
|
|
26
|
+
</Timeline>
|
|
27
|
+
);
|
|
28
|
+
const item = container.querySelector('li');
|
|
29
|
+
expect(item?.className).toContain('mb-10');
|
|
30
|
+
expect(item?.className).toContain('ml-6');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders with compact density (mb-3)', () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<Timeline>
|
|
36
|
+
<TimelineItem density="compact">Content</TimelineItem>
|
|
37
|
+
</Timeline>
|
|
38
|
+
);
|
|
39
|
+
const item = container.querySelector('li');
|
|
40
|
+
expect(item?.className).toContain('mb-3');
|
|
41
|
+
expect(item?.className).toContain('ml-6');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders with comfortable density (mb-6)', () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Timeline>
|
|
47
|
+
<TimelineItem density="comfortable">Content</TimelineItem>
|
|
48
|
+
</Timeline>
|
|
49
|
+
);
|
|
50
|
+
const item = container.querySelector('li');
|
|
51
|
+
expect(item?.className).toContain('mb-6');
|
|
52
|
+
expect(item?.className).toContain('ml-6');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('allows className override', () => {
|
|
56
|
+
const { container } = render(
|
|
57
|
+
<Timeline>
|
|
58
|
+
<TimelineItem density="compact" className="custom-class">Content</TimelineItem>
|
|
59
|
+
</Timeline>
|
|
60
|
+
);
|
|
61
|
+
const item = container.querySelector('li');
|
|
62
|
+
expect(item?.className).toContain('custom-class');
|
|
63
|
+
expect(item?.className).toContain('mb-3');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('cva function produces expected classes', () => {
|
|
67
|
+
expect(timelineItemVariants({ density: 'default' })).toContain('mb-10');
|
|
68
|
+
expect(timelineItemVariants({ density: 'compact' })).toContain('mb-3');
|
|
69
|
+
expect(timelineItemVariants({ density: 'comfortable' })).toContain('mb-6');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('TimelineMarker semantic color variants', () => {
|
|
74
|
+
it('renders with default variant', () => {
|
|
75
|
+
const { container } = render(<TimelineMarker />);
|
|
76
|
+
const marker = container.firstChild as HTMLElement;
|
|
77
|
+
expect(marker.className).toContain('bg-blue-200');
|
|
78
|
+
expect(marker.className).toContain('border-blue-400');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders with success variant', () => {
|
|
82
|
+
const { container } = render(<TimelineMarker variant="success" />);
|
|
83
|
+
const marker = container.firstChild as HTMLElement;
|
|
84
|
+
expect(marker.className).toContain('bg-emerald-200');
|
|
85
|
+
expect(marker.className).toContain('border-emerald-500');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders with warning variant', () => {
|
|
89
|
+
const { container } = render(<TimelineMarker variant="warning" />);
|
|
90
|
+
const marker = container.firstChild as HTMLElement;
|
|
91
|
+
expect(marker.className).toContain('bg-amber-200');
|
|
92
|
+
expect(marker.className).toContain('border-amber-500');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders with danger variant', () => {
|
|
96
|
+
const { container } = render(<TimelineMarker variant="danger" />);
|
|
97
|
+
const marker = container.firstChild as HTMLElement;
|
|
98
|
+
expect(marker.className).toContain('bg-red-200');
|
|
99
|
+
expect(marker.className).toContain('border-red-500');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders with info variant', () => {
|
|
103
|
+
const { container } = render(<TimelineMarker variant="info" />);
|
|
104
|
+
const marker = container.firstChild as HTMLElement;
|
|
105
|
+
expect(marker.className).toContain('bg-purple-200');
|
|
106
|
+
expect(marker.className).toContain('border-purple-500');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('renders with todo status variant', () => {
|
|
110
|
+
const { container } = render(<TimelineMarker variant="todo" />);
|
|
111
|
+
const marker = container.firstChild as HTMLElement;
|
|
112
|
+
expect(marker.className).toContain('bg-slate-200');
|
|
113
|
+
expect(marker.className).toContain('border-slate-400');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('renders with in-progress status variant', () => {
|
|
117
|
+
const { container } = render(<TimelineMarker variant="in-progress" />);
|
|
118
|
+
const marker = container.firstChild as HTMLElement;
|
|
119
|
+
expect(marker.className).toContain('bg-blue-200');
|
|
120
|
+
expect(marker.className).toContain('border-blue-500');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('renders with done status variant', () => {
|
|
124
|
+
const { container } = render(<TimelineMarker variant="done" />);
|
|
125
|
+
const marker = container.firstChild as HTMLElement;
|
|
126
|
+
expect(marker.className).toContain('bg-emerald-200');
|
|
127
|
+
expect(marker.className).toContain('border-emerald-500');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('cva function produces expected classes for status variants', () => {
|
|
131
|
+
expect(timelineMarkerVariants({ variant: 'todo' })).toContain('bg-slate-200');
|
|
132
|
+
expect(timelineMarkerVariants({ variant: 'in-progress' })).toContain('bg-blue-200');
|
|
133
|
+
expect(timelineMarkerVariants({ variant: 'done' })).toContain('bg-emerald-200');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('applies common base classes', () => {
|
|
137
|
+
const { container } = render(<TimelineMarker variant="default" />);
|
|
138
|
+
const marker = container.firstChild as HTMLElement;
|
|
139
|
+
expect(marker.className).toContain('rounded-full');
|
|
140
|
+
expect(marker.className).toContain('border-2');
|
|
141
|
+
expect(marker.className).toContain('absolute');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('allows className override', () => {
|
|
145
|
+
const { container } = render(<TimelineMarker variant="success" className="extra-class" />);
|
|
146
|
+
const marker = container.firstChild as HTMLElement;
|
|
147
|
+
expect(marker.className).toContain('extra-class');
|
|
148
|
+
expect(marker.className).toContain('bg-emerald-200');
|
|
149
|
+
});
|
|
150
|
+
});
|