@object-ui/plugin-calendar 0.3.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.
@@ -0,0 +1,385 @@
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
+ /**
10
+ * ObjectCalendar Component
11
+ *
12
+ * A specialized calendar component that works with ObjectQL data sources.
13
+ * Displays records as calendar events based on date field configuration.
14
+ * Implements the calendar view type from @objectstack/spec view.zod ListView schema.
15
+ *
16
+ * Features:
17
+ * - Month/week/day calendar views
18
+ * - Auto-mapping of records to calendar events
19
+ * - Date range filtering
20
+ * - Event click handling
21
+ * - Color coding support
22
+ * - Works with object/api/value data providers
23
+ */
24
+
25
+ import React, { useEffect, useState, useCallback, useMemo } from 'react';
26
+ import type { ObjectGridSchema, DataSource, ViewData, CalendarConfig } from '@object-ui/types';
27
+
28
+ export interface ObjectCalendarProps {
29
+ schema: ObjectGridSchema;
30
+ dataSource?: DataSource;
31
+ className?: string;
32
+ onEventClick?: (record: any) => void;
33
+ onDateClick?: (date: Date) => void;
34
+ onEdit?: (record: any) => void;
35
+ onDelete?: (record: any) => void;
36
+ }
37
+
38
+ /**
39
+ * Helper to get data configuration from schema
40
+ */
41
+ function getDataConfig(schema: ObjectGridSchema): ViewData | null {
42
+ if (schema.data) {
43
+ return schema.data;
44
+ }
45
+
46
+ if (schema.staticData) {
47
+ return {
48
+ provider: 'value',
49
+ items: schema.staticData,
50
+ };
51
+ }
52
+
53
+ if (schema.objectName) {
54
+ return {
55
+ provider: 'object',
56
+ object: schema.objectName,
57
+ };
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Helper to convert sort config to QueryParams format
65
+ */
66
+ function convertSortToQueryParams(sort: string | any[] | undefined): Record<string, 'asc' | 'desc'> | undefined {
67
+ if (!sort) return undefined;
68
+
69
+ // If it's a string like "name desc"
70
+ if (typeof sort === 'string') {
71
+ const parts = sort.split(' ');
72
+ const field = parts[0];
73
+ const order = (parts[1]?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
74
+ return { [field]: order };
75
+ }
76
+
77
+ // If it's an array of SortConfig objects
78
+ if (Array.isArray(sort)) {
79
+ return sort.reduce((acc, item) => {
80
+ if (item.field && item.order) {
81
+ acc[item.field] = item.order;
82
+ }
83
+ return acc;
84
+ }, {} as Record<string, 'asc' | 'desc'>);
85
+ }
86
+
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Helper to get calendar configuration from schema
92
+ */
93
+ function getCalendarConfig(schema: ObjectGridSchema): CalendarConfig | null {
94
+ // Check if schema has calendar configuration
95
+ if (schema.filter && typeof schema.filter === 'object' && 'calendar' in schema.filter) {
96
+ return (schema.filter as any).calendar as CalendarConfig;
97
+ }
98
+
99
+ // For backward compatibility, check if schema has calendar config at root
100
+ if ((schema as any).calendar) {
101
+ return (schema as any).calendar as CalendarConfig;
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
108
+ schema,
109
+ dataSource,
110
+ className,
111
+ onEventClick,
112
+ onDateClick,
113
+ }) => {
114
+ const [data, setData] = useState<any[]>([]);
115
+ const [loading, setLoading] = useState(true);
116
+ const [error, setError] = useState<Error | null>(null);
117
+ const [objectSchema, setObjectSchema] = useState<any>(null);
118
+ const [currentDate, setCurrentDate] = useState(new Date());
119
+ const [view, setView] = useState<'month' | 'week' | 'day'>('month');
120
+
121
+ const dataConfig = getDataConfig(schema);
122
+ const calendarConfig = getCalendarConfig(schema);
123
+ const hasInlineData = dataConfig?.provider === 'value';
124
+
125
+ // Fetch data based on provider
126
+ useEffect(() => {
127
+ const fetchData = async () => {
128
+ try {
129
+ setLoading(true);
130
+
131
+ if (hasInlineData && dataConfig?.provider === 'value') {
132
+ setData(dataConfig.items as any[]);
133
+ setLoading(false);
134
+ return;
135
+ }
136
+
137
+ if (!dataSource) {
138
+ throw new Error('DataSource required for object/api providers');
139
+ }
140
+
141
+ if (dataConfig?.provider === 'object') {
142
+ const objectName = dataConfig.object;
143
+ const result = await dataSource.find(objectName, {
144
+ $filter: schema.filter,
145
+ $orderby: convertSortToQueryParams(schema.sort),
146
+ });
147
+ setData(result?.data || []);
148
+ } else if (dataConfig?.provider === 'api') {
149
+ console.warn('API provider not yet implemented for ObjectCalendar');
150
+ setData([]);
151
+ }
152
+
153
+ setLoading(false);
154
+ } catch (err) {
155
+ setError(err as Error);
156
+ setLoading(false);
157
+ }
158
+ };
159
+
160
+ fetchData();
161
+ }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
162
+
163
+ // Fetch object schema for field metadata
164
+ useEffect(() => {
165
+ const fetchObjectSchema = async () => {
166
+ try {
167
+ if (!dataSource) return;
168
+
169
+ const objectName = dataConfig?.provider === 'object'
170
+ ? dataConfig.object
171
+ : schema.objectName;
172
+
173
+ if (!objectName) return;
174
+
175
+ const schemaData = await dataSource.getObjectSchema(objectName);
176
+ setObjectSchema(schemaData);
177
+ } catch (err) {
178
+ console.error('Failed to fetch object schema:', err);
179
+ }
180
+ };
181
+
182
+ if (!hasInlineData && dataSource) {
183
+ fetchObjectSchema();
184
+ }
185
+ }, [schema.objectName, dataSource, hasInlineData, dataConfig]);
186
+
187
+ // Transform data to calendar events
188
+ const events = useMemo(() => {
189
+ if (!calendarConfig || !data.length) {
190
+ return [];
191
+ }
192
+
193
+ const { startDateField, endDateField, titleField, colorField } = calendarConfig;
194
+
195
+ return data.map((record, index) => {
196
+ const startDate = record[startDateField];
197
+ const endDate = endDateField ? record[endDateField] : null;
198
+ const title = record[titleField] || 'Untitled';
199
+ const color = colorField ? record[colorField] : undefined;
200
+
201
+ return {
202
+ id: record.id || record._id || `event-${index}`,
203
+ title,
204
+ start: startDate ? new Date(startDate) : new Date(),
205
+ end: endDate ? new Date(endDate) : undefined,
206
+ color,
207
+ allDay: !endDate, // If no end date, treat as all-day event
208
+ data: record,
209
+ };
210
+ }).filter(event => !isNaN(event.start.getTime())); // Filter out invalid dates
211
+ }, [data, calendarConfig]);
212
+
213
+ // Get days in current month view
214
+ const calendarDays = useMemo(() => {
215
+ const year = currentDate.getFullYear();
216
+ const month = currentDate.getMonth();
217
+
218
+ const firstDay = new Date(year, month, 1);
219
+ const lastDay = new Date(year, month + 1, 0);
220
+ const startDay = new Date(firstDay);
221
+ startDay.setDate(startDay.getDate() - startDay.getDay()); // Start from Sunday
222
+
223
+ const days: Date[] = [];
224
+ const current = new Date(startDay);
225
+
226
+ // Get 6 weeks worth of days
227
+ for (let i = 0; i < 42; i++) {
228
+ days.push(new Date(current));
229
+ current.setDate(current.getDate() + 1);
230
+ }
231
+
232
+ return days;
233
+ }, [currentDate]);
234
+
235
+ // Get events for a specific day
236
+ const getEventsForDay = useCallback((day: Date) => {
237
+ return events.filter(event => {
238
+ const eventStart = new Date(event.start);
239
+ eventStart.setHours(0, 0, 0, 0);
240
+ const eventEnd = event.end ? new Date(event.end) : eventStart;
241
+ eventEnd.setHours(23, 59, 59, 999);
242
+
243
+ const checkDay = new Date(day);
244
+ checkDay.setHours(0, 0, 0, 0);
245
+
246
+ return checkDay >= eventStart && checkDay <= eventEnd;
247
+ });
248
+ }, [events]);
249
+
250
+ const navigateMonth = useCallback((direction: 'prev' | 'next') => {
251
+ setCurrentDate(prev => {
252
+ const newDate = new Date(prev);
253
+ newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
254
+ return newDate;
255
+ });
256
+ }, []);
257
+
258
+ if (loading) {
259
+ return (
260
+ <div className={className}>
261
+ <div className="flex items-center justify-center h-96">
262
+ <div className="text-muted-foreground">Loading calendar...</div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (error) {
269
+ return (
270
+ <div className={className}>
271
+ <div className="flex items-center justify-center h-96">
272
+ <div className="text-destructive">Error: {error.message}</div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
277
+
278
+ if (!calendarConfig) {
279
+ return (
280
+ <div className={className}>
281
+ <div className="flex items-center justify-center h-96">
282
+ <div className="text-muted-foreground">
283
+ Calendar configuration required. Please specify startDateField and titleField.
284
+ </div>
285
+ </div>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
291
+ 'July', 'August', 'September', 'October', 'November', 'December'];
292
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
293
+
294
+ return (
295
+ <div className={className}>
296
+ <div className="border rounded-lg bg-background">
297
+ {/* Calendar Header */}
298
+ <div className="flex items-center justify-between p-4 border-b">
299
+ <h2 className="text-xl font-semibold">
300
+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
301
+ </h2>
302
+ <div className="flex gap-2">
303
+ <button
304
+ onClick={() => navigateMonth('prev')}
305
+ className="px-3 py-1 border rounded hover:bg-muted"
306
+ >
307
+ Previous
308
+ </button>
309
+ <button
310
+ onClick={() => setCurrentDate(new Date())}
311
+ className="px-3 py-1 border rounded hover:bg-muted"
312
+ >
313
+ Today
314
+ </button>
315
+ <button
316
+ onClick={() => navigateMonth('next')}
317
+ className="px-3 py-1 border rounded hover:bg-muted"
318
+ >
319
+ Next
320
+ </button>
321
+ </div>
322
+ </div>
323
+
324
+ {/* Calendar Grid */}
325
+ <div className="p-4">
326
+ {/* Day Headers */}
327
+ <div className="grid grid-cols-7 gap-px mb-px">
328
+ {dayNames.map(day => (
329
+ <div
330
+ key={day}
331
+ className="text-center text-sm font-medium text-muted-foreground py-2"
332
+ >
333
+ {day}
334
+ </div>
335
+ ))}
336
+ </div>
337
+
338
+ {/* Calendar Days */}
339
+ <div className="grid grid-cols-7 gap-px bg-border">
340
+ {calendarDays.map((day, index) => {
341
+ const dayEvents = getEventsForDay(day);
342
+ const isCurrentMonth = day.getMonth() === currentDate.getMonth();
343
+ const isToday =
344
+ day.getDate() === new Date().getDate() &&
345
+ day.getMonth() === new Date().getMonth() &&
346
+ day.getFullYear() === new Date().getFullYear();
347
+
348
+ return (
349
+ <div
350
+ key={index}
351
+ className={`min-h-24 bg-background p-2 ${
352
+ !isCurrentMonth ? 'text-muted-foreground bg-muted/30' : ''
353
+ } ${isToday ? 'ring-2 ring-primary' : ''}`}
354
+ onClick={() => onDateClick?.(day)}
355
+ >
356
+ <div className="text-sm font-medium mb-1">{day.getDate()}</div>
357
+ <div className="space-y-1">
358
+ {dayEvents.slice(0, 3).map(event => (
359
+ <div
360
+ key={event.id}
361
+ className="text-xs px-1 py-0.5 rounded bg-primary/10 hover:bg-primary/20 cursor-pointer truncate"
362
+ onClick={(e) => {
363
+ e.stopPropagation();
364
+ onEventClick?.(event.data);
365
+ }}
366
+ style={event.color ? { borderLeft: `3px solid ${event.color}` } : undefined}
367
+ >
368
+ {event.title}
369
+ </div>
370
+ ))}
371
+ {dayEvents.length > 3 && (
372
+ <div className="text-xs text-muted-foreground">
373
+ +{dayEvents.length - 3} more
374
+ </div>
375
+ )}
376
+ </div>
377
+ </div>
378
+ );
379
+ })}
380
+ </div>
381
+ </div>
382
+ </div>
383
+ </div>
384
+ );
385
+ };
@@ -0,0 +1,231 @@
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 { ComponentRegistry } from '@object-ui/core';
10
+ import type { CalendarViewSchema, CalendarEvent } from '@object-ui/types';
11
+ import { CalendarView } from './CalendarView';
12
+ import React from 'react';
13
+
14
+ // Calendar View Renderer - Airtable-style calendar for displaying records as events
15
+ ComponentRegistry.register('calendar-view',
16
+ ({ schema, className, onAction, ...props }: { schema: CalendarViewSchema; className?: string; onAction?: (action: any) => void; [key: string]: any }) => {
17
+ // Transform schema data to CalendarEvent format
18
+ const events = React.useMemo(() => {
19
+ if (!schema.data || !Array.isArray(schema.data)) return [];
20
+
21
+ return schema.data.map((record: any, index: number) => {
22
+ /** Field name to use for event title display */
23
+ const titleField = schema.titleField || 'title';
24
+ /** Field name containing the event start date/time */
25
+ const startField = schema.startDateField || 'start';
26
+ /** Field name containing the event end date/time (optional) */
27
+ const endField = schema.endDateField || 'end';
28
+ /** Field name to determine event color or color category */
29
+ const colorField = schema.colorField || 'color';
30
+ /** Field name indicating if event is all-day */
31
+ const allDayField = schema.allDayField || 'allDay';
32
+
33
+ const title = record[titleField] || 'Untitled';
34
+ const start = record[startField] ? new Date(record[startField]) : new Date();
35
+ const end = record[endField] ? new Date(record[endField]) : undefined;
36
+ const allDay = record[allDayField] !== undefined ? record[allDayField] : false;
37
+
38
+ // Handle color mapping
39
+ let color = record[colorField];
40
+ if (color && schema.colorMapping && schema.colorMapping[color]) {
41
+ color = schema.colorMapping[color];
42
+ }
43
+
44
+ return {
45
+ id: String(record.id || record._id || index),
46
+ title,
47
+ start,
48
+ end,
49
+ allDay,
50
+ color,
51
+ data: record,
52
+ };
53
+ });
54
+ }, [schema.data, schema.titleField, schema.startDateField, schema.endDateField, schema.colorField, schema.allDayField, schema.colorMapping]);
55
+
56
+ const handleEventClick = React.useCallback((event: any) => {
57
+ if (onAction) {
58
+ onAction({
59
+ type: 'event_click',
60
+ payload: { event: event.data, eventId: event.id }
61
+ });
62
+ }
63
+ if (schema.onEventClick) {
64
+ schema.onEventClick(event.data);
65
+ }
66
+ }, [onAction, schema]);
67
+
68
+ const handleDateClick = React.useCallback((date: Date) => {
69
+ if (onAction) {
70
+ onAction({
71
+ type: 'date_click',
72
+ payload: { date }
73
+ });
74
+ }
75
+ if (schema.onDateClick) {
76
+ schema.onDateClick(date);
77
+ }
78
+ }, [onAction, schema]);
79
+
80
+ const handleViewChange = React.useCallback((view: "month" | "week" | "day") => {
81
+ if (onAction) {
82
+ onAction({
83
+ type: 'view_change',
84
+ payload: { view }
85
+ });
86
+ }
87
+ if (schema.onViewChange) {
88
+ schema.onViewChange(view);
89
+ }
90
+ }, [onAction, schema]);
91
+
92
+ const handleNavigate = React.useCallback((date: Date) => {
93
+ if (onAction) {
94
+ onAction({
95
+ type: 'navigate',
96
+ payload: { date }
97
+ });
98
+ }
99
+ if (schema.onNavigate) {
100
+ schema.onNavigate(date);
101
+ }
102
+ }, [onAction, schema]);
103
+
104
+ const validView = (schema.view && ['month', 'week', 'day'].includes(schema.view))
105
+ ? (schema.view as "month" | "week" | "day")
106
+ : 'month';
107
+
108
+ return (
109
+ <CalendarView
110
+ events={events}
111
+ view={validView}
112
+ currentDate={schema.currentDate ? new Date(schema.currentDate) : undefined}
113
+ onEventClick={handleEventClick}
114
+ onDateClick={schema.allowCreate || schema.onDateClick ? handleDateClick : undefined}
115
+ onViewChange={handleViewChange}
116
+ onNavigate={handleNavigate}
117
+ className={className}
118
+ {...props}
119
+ />
120
+ );
121
+ },
122
+ {
123
+ label: 'Calendar View',
124
+ inputs: [
125
+ {
126
+ name: 'data',
127
+ type: 'array',
128
+ label: 'Data',
129
+ description: 'Array of record objects to display as events'
130
+ },
131
+ {
132
+ name: 'titleField',
133
+ type: 'string',
134
+ label: 'Title Field',
135
+ defaultValue: 'title',
136
+ description: 'Field name to use for event title'
137
+ },
138
+ {
139
+ name: 'startDateField',
140
+ type: 'string',
141
+ label: 'Start Date Field',
142
+ defaultValue: 'start',
143
+ description: 'Field name for event start date'
144
+ },
145
+ {
146
+ name: 'endDateField',
147
+ type: 'string',
148
+ label: 'End Date Field',
149
+ defaultValue: 'end',
150
+ description: 'Field name for event end date (optional)'
151
+ },
152
+ {
153
+ name: 'allDayField',
154
+ type: 'string',
155
+ label: 'All Day Field',
156
+ defaultValue: 'allDay',
157
+ description: 'Field name for all-day flag'
158
+ },
159
+ {
160
+ name: 'colorField',
161
+ type: 'string',
162
+ label: 'Color Field',
163
+ defaultValue: 'color',
164
+ description: 'Field name for event color'
165
+ },
166
+ {
167
+ name: 'colorMapping',
168
+ type: 'object',
169
+ label: 'Color Mapping',
170
+ description: 'Map field values to colors (e.g., {meeting: "blue", deadline: "red"})'
171
+ },
172
+ {
173
+ name: 'view',
174
+ type: 'enum',
175
+ enum: ['month', 'week', 'day'],
176
+ defaultValue: 'month',
177
+ label: 'View Mode',
178
+ description: 'Calendar view mode (month, week, or day)'
179
+ },
180
+ {
181
+ name: 'currentDate',
182
+ type: 'string',
183
+ label: 'Current Date',
184
+ description: 'ISO date string for initial calendar date'
185
+ },
186
+ {
187
+ name: 'allowCreate',
188
+ type: 'boolean',
189
+ label: 'Allow Create',
190
+ defaultValue: false,
191
+ description: 'Allow creating events by clicking on dates'
192
+ },
193
+ { name: 'className', type: 'string', label: 'CSS Class' }
194
+ ],
195
+ defaultProps: {
196
+ view: 'month',
197
+ titleField: 'title',
198
+ startDateField: 'start',
199
+ endDateField: 'end',
200
+ allDayField: 'allDay',
201
+ colorField: 'color',
202
+ allowCreate: false,
203
+ data: [
204
+ {
205
+ id: 1,
206
+ title: 'Team Meeting',
207
+ start: new Date(new Date().setHours(10, 0, 0, 0)).toISOString(),
208
+ end: new Date(new Date().setHours(11, 0, 0, 0)).toISOString(),
209
+ color: '#3b82f6',
210
+ allDay: false
211
+ },
212
+ {
213
+ id: 2,
214
+ title: 'Project Deadline',
215
+ start: new Date(new Date().setDate(new Date().getDate() + 3)).toISOString(),
216
+ color: '#ef4444',
217
+ allDay: true
218
+ },
219
+ {
220
+ id: 3,
221
+ title: 'Conference',
222
+ start: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString(),
223
+ end: new Date(new Date().setDate(new Date().getDate() + 9)).toISOString(),
224
+ color: '#10b981',
225
+ allDay: true
226
+ }
227
+ ],
228
+ className: 'h-[600px] border rounded-lg'
229
+ }
230
+ }
231
+ );
package/src/index.tsx ADDED
@@ -0,0 +1,37 @@
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 { ComponentRegistry } from '@object-ui/core';
11
+ import { ObjectCalendar } from './ObjectCalendar';
12
+ import type { ObjectCalendarProps } from './ObjectCalendar';
13
+
14
+ // Export ObjectCalendar component
15
+ export { ObjectCalendar };
16
+ export type { ObjectCalendarProps };
17
+
18
+ // Export CalendarView component (merged from plugin-calendar-view)
19
+ export { CalendarView } from './CalendarView';
20
+ export type { CalendarViewProps, CalendarEvent } from './CalendarView';
21
+
22
+ // Import and register calendar-view renderer
23
+ import './calendar-view-renderer';
24
+
25
+ // Register object-calendar component
26
+ const ObjectCalendarRenderer: React.FC<{ schema: any }> = ({ schema }) => {
27
+ return <ObjectCalendar schema={schema} dataSource={null as any} />;
28
+ };
29
+
30
+ ComponentRegistry.register('object-calendar', ObjectCalendarRenderer, {
31
+ label: 'Object Calendar',
32
+ category: 'plugin',
33
+ inputs: [
34
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
35
+ { name: 'calendar', type: 'object', label: 'Calendar Config', description: 'startDateField, endDateField, titleField, colorField' },
36
+ ],
37
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "jsx": "react-jsx",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@/*": ["src/*"]
9
+ },
10
+ "noEmit": false,
11
+ "declaration": true,
12
+ "composite": true,
13
+ "declarationMap": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
18
+ }