From b7132db84ffba43eec0d86a0dcdf17559db7bf3f Mon Sep 17 00:00:00 2001 From: Jiann Date: Fri, 17 Apr 2026 16:00:18 +0800 Subject: [PATCH] feat: calendar block v2 (#9092) * feat: calender v2 * fix: open model * docs: add calendar docs * fix: code clean --- docs/docs/cn/interface-builder/_meta.json | 5 + .../blocks/data-blocks/calendar.md | 122 +++ docs/docs/en/interface-builder/_meta.json | 5 + .../blocks/data-blocks/calendar.md | 122 +++ .../client/__tests__/calendarV2Utils.test.ts | 99 +++ .../src/client/calendar/Calendar.tsx | 69 +- .../plugin-calendar/src/client/index.tsx | 20 + .../src/client/models/CalendarBlockModel.tsx | 707 ++++++++++++++++++ .../models/actions/CalendarActionModels.tsx | 241 ++++++ .../models/actions/CalendarPopupModels.tsx | 47 ++ .../models/components/CalendarBlock.tsx | 543 ++++++++++++++ .../models/components/CalendarHeader.tsx | 71 ++ .../models/components/CalendarToolbar.tsx | 75 ++ .../models/components/CalendarViewContext.tsx | 32 + .../src/client/models/components/index.ts | 13 + .../src/client/models/components/style.ts | 24 + .../src/client/models/index.ts | 12 + .../src/client/models/utils.ts | 131 ++++ 18 files changed, 2310 insertions(+), 28 deletions(-) create mode 100644 docs/docs/cn/interface-builder/blocks/data-blocks/calendar.md create mode 100644 docs/docs/en/interface-builder/blocks/data-blocks/calendar.md create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/__tests__/calendarV2Utils.test.ts create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/CalendarBlockModel.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/actions/CalendarActionModels.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/actions/CalendarPopupModels.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarBlock.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarHeader.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarToolbar.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarViewContext.tsx create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/index.ts create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/components/style.ts create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/index.ts create mode 100644 packages/plugins/@nocobase/plugin-calendar/src/client/models/utils.ts diff --git a/docs/docs/cn/interface-builder/_meta.json b/docs/docs/cn/interface-builder/_meta.json index 77c6c822884..45a9efabb9c 100644 --- a/docs/docs/cn/interface-builder/_meta.json +++ b/docs/docs/cn/interface-builder/_meta.json @@ -47,6 +47,11 @@ "type": "custom-link", "label": "图表", "link": "/interface-builder/blocks/data-blocks/chart" + }, + { + "type": "custom-link", + "label": "日历", + "link": "/interface-builder/blocks/data-blocks/calendar" } ] }, diff --git a/docs/docs/cn/interface-builder/blocks/data-blocks/calendar.md b/docs/docs/cn/interface-builder/blocks/data-blocks/calendar.md new file mode 100644 index 00000000000..8dfe7b635dd --- /dev/null +++ b/docs/docs/cn/interface-builder/blocks/data-blocks/calendar.md @@ -0,0 +1,122 @@ +--- +pkg: "@nocobase/plugin-calendar" +title: "日历区块" +description: "日历区块以日历视图展示事件与日期数据,适用于会议安排、活动计划等场景,支持配置标题字段、起止时间、农历显示及数据范围等。" +keywords: "日历区块, 日历视图, 事件管理, 会议安排, Calendar, NocoBase" +--- + +# 日历区块 + +## 介绍 + +日历区块以直观的日历视图展示事件及日期相关数据,适用于会议安排、活动计划等典型场景。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_27_PM.png) + +## 安装 + +该区块为内置插件,无需额外安装。 + +## 添加区块 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_39_PM.png) + +选择 "日历" 区块,并指定对应的数据表,即可完成创建。 + +## 区块配置项 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_43_PM.png) + +### 标题字段 + +用于展示在日历事件条上的标题信息。 + +当前支持的字段类型包括:`input`、`select`、`phone`、`email`、`radioGroup`、`sequence` 等,也支持通过插件扩展更多类型。 + +### 开始日期字段 + +用于指定事件的开始时间。 + +### 结束日期字段 + +用于指定事件的结束时间。 + +### 快速创建事件 + +点击日历中的空白日期区域,可快速弹出浮层创建事件。 + +![](https://static-docs.nocobase.com/View-record-04-13-2026_03_27_PM.png) + +点击已有事件时: +- 当前事件会高亮显示 +- 同时弹出详情窗口,便于查看或编辑 + +### 显示农历 + +开启后,日历中将显示对应的农历信息。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_28_PM.png) + +### 数据范围 + +用于限制日历区块中展示的数据范围。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_30_PM.png) + +更多说明请参考:[设置数据范围](/interface-builder/blocks/block-settings/data-scope) + +### 区块高度 + +可自定义日历区块高度,避免内部出现滚动条,提升整体布局体验。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_30_PM%20(1).png) + +更多说明请参考:[区块高度](/interface-builder/blocks/block-settings/block-height) + +### 颜色字段 + +用于配置日历事件的背景颜色,提升视觉区分度。 + +使用步骤: + +1. 在数据表中新增一个 **下拉单选(Single select)** 或 **单选框(Radio group)** 字段,并为选项配置颜色; +2. 在日历区块配置中,将该字段设置为「颜色字段」; +3. 在创建或编辑事件时选择颜色,即可在日历中生效。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_31_PM.png) + +### 周起始日 + +支持设置每周的起始日,可选择: +- 周日 +- 周一(默认) + +可根据不同地区的使用习惯进行调整,使日历展示更符合实际需求。 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_31_PM%20(2).png) + +## 配置操作 + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_32_PM.png) + +### 今天 + +点击 "今天" 按钮,可快速跳转回当前日期所在的日历视图。 + +### 切换页面 + +根据当前视图模式进行时间切换: +- 月视图:上一月 / 下一月 +- 周视图:上一周 / 下一周 +- 日视图:昨天 / 明天 + +### 选择视图 + +支持在以下视图间切换: +- 月视图(默认) +- 周视图 +- 日视图 + +### 标题 + +用于显示当前视图对应的日期。 \ No newline at end of file diff --git a/docs/docs/en/interface-builder/_meta.json b/docs/docs/en/interface-builder/_meta.json index c3228460345..b60b906861e 100644 --- a/docs/docs/en/interface-builder/_meta.json +++ b/docs/docs/en/interface-builder/_meta.json @@ -47,6 +47,11 @@ "type": "custom-link", "label": "Chart", "link": "/interface-builder/blocks/data-blocks/chart" + }, + { + "type": "custom-link", + "label": "Calendar", + "link": "/interface-builder/blocks/data-blocks/calendar" } ] }, diff --git a/docs/docs/en/interface-builder/blocks/data-blocks/calendar.md b/docs/docs/en/interface-builder/blocks/data-blocks/calendar.md new file mode 100644 index 00000000000..9012287aafc --- /dev/null +++ b/docs/docs/en/interface-builder/blocks/data-blocks/calendar.md @@ -0,0 +1,122 @@ +--- +pkg: "@nocobase/plugin-calendar" +title: "Calendar Block" +description: "The Calendar block displays events and date-based data in a calendar view. It is suitable for scenarios such as meeting scheduling and activity planning, and supports configuration of title fields, start and end dates, lunar calendar display, and data scope." +keywords: "Calendar Block, Calendar View, Event Management, Meeting Scheduling, Calendar, NocoBase" +--- + +# Calendar block + +## Introduction + +The Calendar block provides an intuitive calendar view to display events and date-related data. It is suitable for typical scenarios such as meeting scheduling and activity planning. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_27_PM.png) + +## Installation + +This block is a built-in plugin and does not require additional installation. + +## Add block + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_39_PM.png) + +Select the "Calendar" block and choose the corresponding data table to create the block. + +## Block settings + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_43_PM.png) + +### Title field + +Used to display the title information on calendar event bars. + +Currently supported field types include `input`, `select`, `phone`, `email`, `radioGroup`, `sequence`, etc. Additional types can be supported via plugin extensions. + +### Start date field + +Specifies the start time of an event. + +### End date field + +Specifies the end time of an event. + +### Quick create event + +Click on an empty date area in the calendar to quickly open a pop-up for creating an event. + +![](https://static-docs.nocobase.com/View-record-04-13-2026_03_27_PM.png) + +When clicking on an existing event: +- The selected event will be highlighted +- A detail modal will open for viewing or editing + +### Show lunar + +When enabled, the calendar will display corresponding lunar date information. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_28_PM.png) + +### Data scope + +Used to restrict the data displayed in the calendar block. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_30_PM.png) + +For more details, see: [Set Data Scope](/interface-builder/blocks/block-settings/data-scope) + +### Block height + +Allows customization of the calendar block height to avoid internal scrollbars and improve layout experience. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_30_PM%20(1).png) + +For more details, see: [Block Height](/interface-builder/blocks/block-settings/block-height) + +### Color field + +Used to configure the background color of calendar events for better visual distinction. + +Steps: + +1. Add a **Single select** or **Radio group** field in the data table and configure colors for its options; +2. In the calendar block settings, set this field as the "Color Field"; +3. When creating or editing events, select a color to see it applied in the calendar. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_31_PM.png) + +### Week start day + +Supports setting the first day of the week: +- Sunday +- Monday (default) + +This can be adjusted based on regional preferences for a more natural calendar experience. + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_31_PM%20(2).png) + +## Actions + +![](https://static-docs.nocobase.com/Calendar-04-13-2026_03_32_PM.png) + +### Today + +Click the "Today" button to quickly navigate back to the current date in the calendar view. + +### Turn pages + +Switch between dates based on the current view mode: +- Month view: previous month / next month +- Week view: previous week / next week +- Day view: yesterday / tomorrow + +### Select view + +Supports switching between: +- Month view (default) +- Week view +- Day view + +### Title + +Displays the current date based on the selected view. \ No newline at end of file diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/__tests__/calendarV2Utils.test.ts b/packages/plugins/@nocobase/plugin-calendar/src/client/__tests__/calendarV2Utils.test.ts new file mode 100644 index 00000000000..cb0cfb397a9 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/__tests__/calendarV2Utils.test.ts @@ -0,0 +1,99 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import dayjs from 'dayjs'; +import { + createCalendarRangeFilter, + formatDate, + getCalendarVisibleRange, + normalizeCalendarFieldPath, + parseCalendarWeekStart, +} from '../models/utils'; + +describe('calendar v2 utils', () => { + it('should normalize field path values', () => { + expect(normalizeCalendarFieldPath(['event', 'startAt'])).toBe('event.startAt'); + expect(normalizeCalendarFieldPath('startAt')).toBe('startAt'); + expect(normalizeCalendarFieldPath()).toBeUndefined(); + }); + + it('should parse week start consistently', () => { + expect(parseCalendarWeekStart(0)).toBe(0); + expect(parseCalendarWeekStart('0')).toBe(0); + expect(parseCalendarWeekStart(1)).toBe(1); + expect(parseCalendarWeekStart(undefined)).toBe(1); + expect(parseCalendarWeekStart('unexpected')).toBe(1); + }); + + it('should calculate visible month range with monday as week start', () => { + const range = getCalendarVisibleRange(new Date(2024, 5, 15, 12, 0, 0), 'month', 1); + + expect(dayjs(range.start).format('YYYY-MM-DD HH:mm:ss')).toBe('2024-05-27 00:00:00'); + expect(dayjs(range.end).format('YYYY-MM-DD HH:mm:ss')).toBe('2024-06-30 23:59:59'); + }); + + it('should calculate visible month range with sunday as week start', () => { + const range = getCalendarVisibleRange(new Date(2024, 5, 15, 12, 0, 0), 'month', 0); + + expect(dayjs(range.start).format('YYYY-MM-DD HH:mm:ss')).toBe('2024-05-26 00:00:00'); + expect(dayjs(range.end).format('YYYY-MM-DD HH:mm:ss')).toBe('2024-07-06 23:59:59'); + }); + + it('should create a simple range filter when only a start field exists', () => { + const range = { + start: new Date(2024, 5, 1, 0, 0, 0), + end: new Date(2024, 5, 30, 23, 59, 59), + }; + + expect(createCalendarRangeFilter({ start: 'startAt' }, range)).toEqual({ + startAt: { + $gte: formatDate(dayjs(range.start)), + $lte: formatDate(dayjs(range.end)), + }, + }); + }); + + it('should create an overlap-aware range filter when start and end fields differ', () => { + const range = { + start: new Date(2024, 5, 1, 0, 0, 0), + end: new Date(2024, 5, 30, 23, 59, 59), + }; + + expect(createCalendarRangeFilter({ start: ['event', 'startAt'], end: ['event', 'endAt'] }, range)).toEqual({ + $or: [ + { + 'event.startAt': { + $gte: formatDate(dayjs(range.start)), + $lte: formatDate(dayjs(range.end)), + }, + }, + { + 'event.endAt': { + $gte: formatDate(dayjs(range.start)), + $lte: formatDate(dayjs(range.end)), + }, + }, + { + $and: [ + { + 'event.startAt': { + $lte: formatDate(dayjs(range.start)), + }, + }, + { + 'event.endAt': { + $gte: formatDate(dayjs(range.end)), + }, + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx index a67c4e161ca..0102435ff86 100644 --- a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calendar.tsx @@ -81,6 +81,7 @@ function Toolbar(props: ToolbarProps) { }, null), [], ); + return ( @@ -282,6 +283,7 @@ export const Calendar: any = withDynamicSchemaProps( const parentRecordData = useCollectionParentRecordData(); const fieldSchema = useFieldSchema(); const field = useField(); + const hasSchemaRuntime = fieldSchema?.properties; //nint deal with slot select to show create popup const { parseAction } = useACLRoleContext(); const collection = useCollection(); @@ -423,8 +425,6 @@ export const Calendar: any = withDynamicSchemaProps( // 快速创建行程 const useCreateFormBlockProps = () => { const ctx = useFormBlockContext(); - let startDateValue = currentSelectDate.start; - let endDataValue = currentSelectDate.end; const startCollectionField = collection.getField(startFieldName); const endCollectionField = collection.getField(endFieldName); @@ -443,7 +443,7 @@ export const Calendar: any = withDynamicSchemaProps( ...ctx.form?.query(endFieldName).take()?.componentProps, }; - startDateValue = handleDateChangeOnForm( + const startDateValue = handleDateChangeOnForm( currentSelectDate.start, startFieldProps.dateOnly, startFieldProps.utc, @@ -451,7 +451,7 @@ export const Calendar: any = withDynamicSchemaProps( startFieldProps.showTime, startFieldProps.gtm, ); - endDataValue = handleDateChangeOnForm( + const endDataValue = handleDateChangeOnForm( currentSelectDate.end, endFieldProps.dateOnly, endFieldProps.utc, @@ -466,7 +466,13 @@ export const Calendar: any = withDynamicSchemaProps( form.setInitialValuesIn([endFieldName], endDataValue); } } - }, [ctx.form, ctx.service?.data?.data, ctx.service?.loading]); + }, [ + ctx.form, + ctx.service?.data?.data, + ctx.service?.loading, + endCollectionField?.uiSchema, + startCollectionField?.uiSchema, + ]); return { form: ctx.form, }; @@ -494,7 +500,8 @@ export const Calendar: any = withDynamicSchemaProps( onView={setView} onSelectSlot={(slotInfo) => { setCurrentSelectDate(slotInfo); - if (canCreate && enableQuickCreateEvent) { + props.onSelectSlot?.(slotInfo); + if (hasSchemaRuntime && canCreate && enableQuickCreateEvent) { insertAddNewer(addNew); setVisibleAddNewer(true); } @@ -507,6 +514,10 @@ export const Calendar: any = withDynamicSchemaProps( if (!record) { return; } + props.onSelectEvent?.({ event, record }); + if (!hasSchemaRuntime) { + return; + } record.__event = { ...omit(event, 'title'), start: formatDate(dayjs(event.start)), @@ -516,7 +527,7 @@ export const Calendar: any = withDynamicSchemaProps( setRecord(record); openPopup({ recordData: record, - customActionSchema: findEventSchema(fieldSchema), + customActionSchema: fieldSchema ? findEventSchema(fieldSchema) : undefined, }); }} formats={formats} @@ -539,27 +550,29 @@ export const Calendar: any = withDynamicSchemaProps( }} /> - - - - { - return s['x-component'] === 'AssociationField.AddNewer'; - }} - /> - - - + {hasSchemaRuntime && collection ? ( + + + + { + return s['x-component'] === 'AssociationField.AddNewer'; + }} + /> + + + + ) : null} , ); }, diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx index 5dc9ba6ca8a..82af84685a8 100644 --- a/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/index.tsx @@ -13,6 +13,16 @@ import { CalendarV2 } from './calendar'; import { calendarBlockSettings } from './calendar/Calender.Settings'; import { CalendarCollectionTemplate } from './collection-templates/calendar'; import { useCalendarBlockDecoratorProps } from './hooks/useCalendarBlockDecoratorProps'; +import { + CalendarBlockModel, + CalendarCollectionActionGroupModel, + CalendarEventViewActionModel, + CalendarNavActionModel, + CalendarQuickCreateActionModel, + CalendarTitleActionModel, + CalendarTodayActionModel, + CalendarViewSelectActionModel, +} from './models'; import { CalendarBlockProvider, useCalendarBlockProps } from './schema-initializer/CalendarBlockProvider'; import { CalendarActionInitializers_deprecated, @@ -101,6 +111,16 @@ export class PluginCalendarClient extends Plugin { return this.colorFieldInterfaces[type]; } async load() { + this.flowEngine.registerModels({ + CalendarBlockModel, + CalendarCollectionActionGroupModel, + CalendarEventViewActionModel, + CalendarNavActionModel, + CalendarQuickCreateActionModel, + CalendarTitleActionModel, + CalendarTodayActionModel, + CalendarViewSelectActionModel, + }); this.app.dataSourceManager.addCollectionTemplates([CalendarCollectionTemplate]); this.app.schemaInitializerManager.addItem('page:addBlock', 'dataBlocks.calendar', { title: generateNTemplate('Calendar'), diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/CalendarBlockModel.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/models/CalendarBlockModel.tsx new file mode 100644 index 00000000000..8191c0dd51d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/CalendarBlockModel.tsx @@ -0,0 +1,707 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { SettingOutlined } from '@ant-design/icons'; +import { ActionModel, BlockSceneEnum, CollectionBlockModel } from '@nocobase/client'; +import { + AddSubModelButton, + DndProvider, + DragHandler, + Droppable, + FlowModelRenderer, + FlowSettingsButton, + MultiRecordResource, + observer, + tExpr, +} from '@nocobase/flow-engine'; +import { Select, Space } from 'antd'; +import React from 'react'; +import { CalendarBlock } from './components'; +import { + createCalendarEventViewActionOptions, + createCalendarQuickCreateActionOptions, +} from './actions/CalendarPopupModels'; +import { + CALENDAR_RANGE_FILTER_GROUP, + createCalendarRangeFilter, + getCalendarVisibleRange, + normalizeCalendarFieldPath, + parseCalendarWeekStart, +} from './utils'; + +const DEFAULT_TITLE_INTERFACES = ['input', 'select', 'phone', 'email', 'radioGroup']; +const DEFAULT_COLOR_INTERFACES = ['select', 'radioGroup']; +const DEFAULT_DATE_TIME_FIELD_TYPES = [ + 'date', + 'datetime', + 'dateOnly', + 'datetimeNoTz', + 'unixTimestamp', + 'createdAt', + 'updatedAt', +]; + +const VIEW_OPTIONS = [ + { label: tExpr('Month'), value: 'month' }, + { label: tExpr('Week'), value: 'week' }, + { label: tExpr('Day'), value: 'day' }, +]; + +const WEEK_START_OPTIONS = [ + { label: tExpr('Monday'), value: 1 }, + { label: tExpr('Sunday'), value: 0 }, +]; + +const EVENT_OPEN_MODE_OPTIONS = [ + { label: tExpr('Drawer'), value: 'drawer' }, + { label: tExpr('Dialog'), value: 'dialog' }, + { label: tExpr('Page'), value: 'embed' }, +]; + +const normalizeEventOpenMode = (value?: string) => { + if (value === 'modal') { + return 'dialog'; + } + + if (value === 'page') { + return 'embed'; + } + + return value === 'dialog' || value === 'embed' ? value : 'drawer'; +}; + +const isSupportedByValues = (value: string | undefined, supportedValues: string[]) => { + if (!value) { + return false; + } + + return supportedValues.includes(value); +}; + +type CalendarCollectionField = { + name: string; + title?: string; + interface?: string; + type?: string; + uiSchema?: { + title?: string; + type?: string; + }; +}; + +type CalendarActionSubModel = ActionModel & { + hidden?: boolean; + props?: { + position?: string; + }; + uid: string; +}; + +const DRAG_HANDLER_TOOLBAR_ITEMS = [ + { + key: 'drag-handler', + component: DragHandler as React.ComponentType, + sort: 1, + }, +]; + +export class CalendarBlockModel extends CollectionBlockModel { + static scene = BlockSceneEnum.many; + + customModelClasses = { + CollectionActionGroupModel: 'CalendarCollectionActionGroupModel', + }; + + static filterCollection(collection: any) { + if (!super.filterCollection(collection)) { + return false; + } + + return !!collection?.getFields?.()?.some?.((field: CalendarCollectionField) => { + return ( + isSupportedByValues(field?.interface, DEFAULT_DATE_TIME_FIELD_TYPES) || + isSupportedByValues(field?.type, DEFAULT_DATE_TIME_FIELD_TYPES) + ); + }); + } + + get calendarPlugin() { + return this.context.app.pm.get('calendar') as any; + } + + getTitleInterfaces() { + return Object.keys(this.calendarPlugin?.titleFieldInterfaces || {}).length + ? Object.keys(this.calendarPlugin.titleFieldInterfaces) + : DEFAULT_TITLE_INTERFACES; + } + + getColorInterfaces() { + return Object.keys(this.calendarPlugin?.colorFieldInterfaces || {}).length + ? Object.keys(this.calendarPlugin.colorFieldInterfaces) + : DEFAULT_COLOR_INTERFACES; + } + + getDateFieldTypes() { + const types = this.calendarPlugin?.dateTimeFieldInterfaces; + return Array.isArray(types) && types.length ? types : DEFAULT_DATE_TIME_FIELD_TYPES; + } + + getCollectionFields() { + return (this.collection?.getFields?.() || []) as CalendarCollectionField[]; + } + + getTitleFieldOptions() { + const supportedInterfaces = this.getTitleInterfaces(); + return this.getCollectionFields() + .filter((field) => isSupportedByValues(field.interface, supportedInterfaces)) + .map((field) => ({ + label: field.title || field.uiSchema?.title || field.name, + value: field.name, + })); + } + + getColorFieldOptions() { + const supportedInterfaces = this.getColorInterfaces(); + return this.getCollectionFields() + .filter((field) => isSupportedByValues(field.interface, supportedInterfaces)) + .map((field) => ({ + label: field.title || field.uiSchema?.title || field.name, + value: field.name, + })); + } + + getDateFieldOptions() { + const supportedTypes = this.getDateFieldTypes(); + return this.getCollectionFields() + .filter((field) => { + return ( + isSupportedByValues(field.interface, supportedTypes) || + isSupportedByValues(field.type, supportedTypes) || + isSupportedByValues(field.uiSchema?.type, supportedTypes) + ); + }) + .map((field) => ({ + label: field.title || field.uiSchema?.title || field.name, + value: field.name, + })); + } + + getDefaultTitleFieldName() { + return this.getTitleFieldOptions()[0]?.value || this.collection?.filterTargetKey || 'id'; + } + + getDefaultStartFieldName() { + const options = this.getDateFieldOptions(); + return options.find((item) => item.value === 'createdAt')?.value || options[0]?.value; + } + + getDefaultEndFieldName() { + const startFieldName = this.getDefaultStartFieldName(); + return this.getDateFieldOptions().find((item) => item.value !== startFieldName)?.value; + } + + getDefaultView() { + return this.props?.defaultView || 'month'; + } + + getEventOpenMode() { + return normalizeEventOpenMode(this.props?.eventOpenMode); + } + + getWeekStart() { + return parseCalendarWeekStart(this.props?.weekStart ?? 1); + } + + getFieldNames() { + const fieldNames = this.props?.fieldNames || {}; + + return { + id: 'id', + title: fieldNames.title || this.getDefaultTitleFieldName(), + start: fieldNames.start || this.getDefaultStartFieldName(), + end: fieldNames.end || this.getDefaultEndFieldName(), + colorFieldName: fieldNames.colorFieldName, + }; + } + + createResource() { + const resource = this.context.createResource(MultiRecordResource); + resource.addRequestParameter('paginate', false); + + const { start, end } = this.getFieldNames(); + [start, end].forEach((fieldPath) => { + if (Array.isArray(fieldPath) && fieldPath.length >= 2) { + resource.addAppends(fieldPath[0]); + } + }); + + const rangeFilter = createCalendarRangeFilter( + this.getFieldNames(), + getCalendarVisibleRange(new Date(), this.getDefaultView(), this.getWeekStart()), + ); + + if (rangeFilter) { + resource.addFilterGroup(CALENDAR_RANGE_FILTER_GROUP, rangeFilter); + } + + return resource; + } + + renderComponent() { + return ; + } + + getQuickCreateAction() { + return this.subModels?.quickCreateAction as any; + } + + getEventViewAction() { + return this.subModels?.eventViewAction as any; + } + + getPopupActionUid(actionKey: 'quickCreateAction' | 'eventViewAction') { + return `${this.uid}-${actionKey}`; + } + + async syncPopupActionOpenMode(action: any) { + if (!action) { + return; + } + + const nextMode = this.getEventOpenMode(); + const currentParams = action.getStepParams?.('popupSettings', 'openView') || {}; + + if (currentParams.mode === nextMode) { + return; + } + + action.setStepParams('popupSettings', 'openView', { + ...currentParams, + mode: nextMode, + }); + + if (this.context.flowSettingsEnabled && action?.saveStepParams) { + await action.saveStepParams(); + } + } + + async ensurePopupAction(actionKey: 'quickCreateAction' | 'eventViewAction') { + const buildActionOptions = + actionKey === 'quickCreateAction' + ? () => createCalendarQuickCreateActionOptions(this.getPopupActionUid(actionKey)) + : () => createCalendarEventViewActionOptions(this.getPopupActionUid(actionKey)); + let action = this.subModels?.[actionKey] as any; + + if (!action) { + this.setSubModel(actionKey, buildActionOptions()); + action = this.subModels?.[actionKey] as any; + } + + if (this.context.flowSettingsEnabled && action?.save) { + await action.save(); + } + + await this.syncPopupActionOpenMode(action); + + return action; + } + + async openQuickCreate(slotInfo: any) { + if (this.props?.enableQuickCreateEvent === false) { + return; + } + + const action = await this.ensurePopupAction('quickCreateAction'); + if (!action) { + return; + } + + await action.dispatchEvent( + 'click', + { + mode: this.getEventOpenMode(), + defineProperties: { + calendarSelectedSlot: { value: slotInfo }, + calendarFieldNames: { value: this.getFieldNames() }, + }, + }, + { debounce: true }, + ); + } + + async openEvent(record: any) { + const action = await this.ensurePopupAction('eventViewAction'); + if (!action || !record) { + return; + } + + const filterByTk = this.collection?.getFilterByTK?.(record) ?? record?.[this.collection?.filterTargetKey || 'id']; + if (!filterByTk) { + return; + } + + await action.dispatchEvent( + 'click', + { + mode: this.getEventOpenMode(), + filterByTk, + }, + { debounce: true }, + ); + } +} + +const useDefaultGetColor = () => { + return { + getBackgroundColor: () => null, + getFontColor: () => null, + loading: false, + }; +}; + +const CalendarBlockRenderer = observer( + ({ + model, + fieldNames, + colorCollectionField, + }: { + model: CalendarBlockModel; + fieldNames: any; + colorCollectionField: any; + }) => { + const colorFieldInterface = colorCollectionField?.interface + ? model.calendarPlugin?.getColorFieldInterface?.(colorCollectionField.interface) + : null; + const useGetColor = colorFieldInterface?.useGetColor || useDefaultGetColor; + const colorFunctions = useGetColor(colorCollectionField); + const isConfigMode = !!model.context.flowSettingsEnabled; + + const leftActions = ((model as any).mapSubModels('actions', (action: CalendarActionSubModel) => { + if (action.hidden && !isConfigMode) { + return null; + } + + if (action.props.position === 'left') { + return ( + + + + ); + } + + return null; + }) || []) as React.ReactNode[]; + + const rightActions = ((model as any).mapSubModels('actions', (action: CalendarActionSubModel) => { + if (action.hidden && !isConfigMode) { + return null; + } + + if (action.props.position !== 'left') { + return ( + + + + ); + } + + return null; + }) || []) as React.ReactNode[]; + + const hasActions = [...leftActions, ...rightActions].some(Boolean); + const actionBar = + hasActions || isConfigMode ? ( + +
+ + {leftActions} + + + + {rightActions} + + }>{model.translate('Actions')} + + +
+
+ ) : null; + + return ( + { + void model.openQuickCreate(slotInfo); + }} + onSelectEvent={({ record }) => { + void model.openEvent(record); + }} + {...colorFunctions} + /> + ); + }, +); + +const CalendarBlockView = observer(({ model }: { model: CalendarBlockModel }) => { + const fieldNames = model.getFieldNames(); + const colorFieldName = normalizeCalendarFieldPath(fieldNames.colorFieldName); + const colorCollectionField = colorFieldName ? model.collection?.getField?.(colorFieldName) : null; + + return ( + + ); +}); + +CalendarBlockModel.registerFlow({ + key: 'calendarSettings', + sort: 500, + title: tExpr('Calendar', { ns: 'calendar' }), + steps: { + titleField: { + title: tExpr('Title field', { ns: 'calendar' }), + uiMode(ctx) { + return { + type: 'select', + key: 'titleField', + props: { + options: (ctx.model as CalendarBlockModel).getTitleFieldOptions(), + }, + }; + }, + defaultParams(ctx) { + return { + titleField: (ctx.model as CalendarBlockModel).getFieldNames().title, + }; + }, + handler(ctx, params) { + const model = ctx.model as CalendarBlockModel; + model.setProps({ + fieldNames: { + ...(model.props?.fieldNames || {}), + title: params.titleField, + }, + }); + }, + }, + colorField: { + title: tExpr('Color field', { ns: 'calendar' }), + uiMode(ctx) { + const model = ctx.model as CalendarBlockModel; + return { + type: 'select', + key: 'colorFieldName', + props: { + options: [{ label: ctx.t('Not selected'), value: '' }, ...model.getColorFieldOptions()], + }, + }; + }, + defaultParams(ctx) { + return { + colorFieldName: (ctx.model as CalendarBlockModel).getFieldNames().colorFieldName || '', + }; + }, + handler(ctx, params) { + const model = ctx.model as CalendarBlockModel; + model.setProps({ + fieldNames: { + ...(model.props?.fieldNames || {}), + colorFieldName: params.colorFieldName || undefined, + }, + }); + }, + }, + startDateField: { + title: tExpr('Start date field', { ns: 'calendar' }), + uiMode(ctx) { + return { + type: 'select', + key: 'start', + props: { + options: (ctx.model as CalendarBlockModel).getDateFieldOptions(), + }, + }; + }, + defaultParams(ctx) { + return { + start: (ctx.model as CalendarBlockModel).getFieldNames().start, + }; + }, + handler(ctx, params) { + const model = ctx.model as CalendarBlockModel; + model.setProps({ + fieldNames: { + ...(model.props?.fieldNames || {}), + start: params.start, + }, + }); + }, + }, + endDateField: { + title: tExpr('End date field', { ns: 'calendar' }), + uiMode(ctx) { + return { + type: 'select', + key: 'end', + props: { + options: [ + { label: ctx.t('Not selected'), value: '' }, + ...(ctx.model as CalendarBlockModel).getDateFieldOptions(), + ], + }, + }; + }, + defaultParams(ctx) { + return { + end: (ctx.model as CalendarBlockModel).getFieldNames().end || '', + }; + }, + handler(ctx, params) { + const model = ctx.model as CalendarBlockModel; + model.setProps({ + fieldNames: { + ...(model.props?.fieldNames || {}), + end: params.end || undefined, + }, + }); + }, + }, + defaultView: { + title: tExpr('Default view', { ns: 'calendar' }), + uiMode: { + type: 'select', + key: 'defaultView', + props: { + options: VIEW_OPTIONS, + }, + }, + defaultParams(ctx) { + return { + defaultView: (ctx.model as CalendarBlockModel).getDefaultView(), + }; + }, + handler(ctx, params) { + (ctx.model as CalendarBlockModel).setProps({ defaultView: params.defaultView || 'month' }); + }, + }, + quickCreateEvent: { + title: tExpr('Quick create event', { ns: 'calendar' }), + uiMode: { type: 'switch', key: 'enableQuickCreateEvent' }, + defaultParams(ctx) { + return { + enableQuickCreateEvent: (ctx.model as CalendarBlockModel).props?.enableQuickCreateEvent ?? true, + }; + }, + handler(ctx, params) { + (ctx.model as CalendarBlockModel).setProps({ enableQuickCreateEvent: params.enableQuickCreateEvent !== false }); + }, + }, + eventOpenMode: { + title: tExpr('Event open mode', { ns: 'calendar' }), + uiMode: { + type: 'select', + key: 'eventOpenMode', + props: { + options: EVENT_OPEN_MODE_OPTIONS, + }, + }, + defaultParams(ctx) { + return { + eventOpenMode: (ctx.model as CalendarBlockModel).getEventOpenMode(), + }; + }, + async handler(ctx, params) { + const model = ctx.model as CalendarBlockModel; + model.setProps({ eventOpenMode: normalizeEventOpenMode(params.eventOpenMode) }); + + await Promise.all([ + model.syncPopupActionOpenMode(model.getQuickCreateAction()), + model.syncPopupActionOpenMode(model.getEventViewAction()), + ]); + }, + }, + showLunar: { + title: tExpr('Show lunar', { ns: 'calendar' }), + uiMode: { type: 'switch', key: 'showLunar' }, + defaultParams(ctx) { + return { + showLunar: (ctx.model as CalendarBlockModel).props?.showLunar === true, + }; + }, + handler(ctx, params) { + (ctx.model as CalendarBlockModel).setProps({ showLunar: params.showLunar === true }); + }, + }, + weekStart: { + title: tExpr('Week start day', { ns: 'calendar' }), + uiMode: { + type: 'select', + key: 'weekStart', + props: { + options: WEEK_START_OPTIONS, + }, + }, + defaultParams(ctx) { + return { + weekStart: (ctx.model as CalendarBlockModel).getWeekStart(), + }; + }, + handler(ctx, params) { + (ctx.model as CalendarBlockModel).setProps({ weekStart: params.weekStart }); + }, + }, + dataScope: { + use: 'dataScope', + title: tExpr('Data scope'), + }, + }, +}); + +CalendarBlockModel.define({ + label: tExpr('Calendar', { ns: 'calendar' }), + searchable: true, + searchPlaceholder: tExpr('Search'), + createModelOptions: { + use: 'CalendarBlockModel', + }, + sort: 490, +}); diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/actions/CalendarActionModels.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/models/actions/CalendarActionModels.tsx new file mode 100644 index 00000000000..f3ef9f325ff --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/actions/CalendarActionModels.tsx @@ -0,0 +1,241 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { LeftOutlined, RightOutlined } from '@ant-design/icons'; +import { ActionModel, CollectionActionGroupModel, Icon } from '@nocobase/client'; +import { observer, tExpr, useFlowModel, type FlowModelContext } from '@nocobase/flow-engine'; +import { Button, Segmented, Typography } from 'antd'; +import React, { useMemo } from 'react'; +import { useTranslation } from '../../../locale'; +import { getLunarDay } from '../../calendar/utils'; +import { useCalendarViewContext } from '../components/CalendarViewContext'; +import dayjs from 'dayjs'; + +const FILTERED_ACTION_MODEL_NAMES = new Set([ + 'DeleteActionModel', + 'BulkDeleteActionModel', + 'BulkEditActionModel', + 'BulkUpdateActionModel', + 'ImportActionModel', + 'ExportActionModel', + 'PrintActionModel', +]); + +const CALENDAR_ACTION_ORDER = [ + 'CalendarTodayActionModel', + 'CalendarNavActionModel', + 'CalendarTitleActionModel', + 'CalendarViewSelectActionModel', +]; + +const CALENDAR_ACTION_ORDER_MAP = new Map(CALENDAR_ACTION_ORDER.map((name, index) => [name, index])); + +const shouldFilterActionItem = (item: any) => { + if (FILTERED_ACTION_MODEL_NAMES.has(item.useModel)) { + return true; + } + + const identity = [item.key, item.useModel, typeof item.label === 'string' ? item.label : ''] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return identity.includes('print'); +}; + +const CalendarTodayAction = observer(() => { + const model = useFlowModel(); + const calendarView = useCalendarViewContext(); + + if (!calendarView) { + return null; + } + + const icon = model.getIcon() ? : undefined; + + return ( + + ); +}); + +const CalendarNavAction = observer(() => { + const calendarView = useCalendarViewContext(); + + if (!calendarView) { + return null; + } + + return ( + + + + + + + + + {label} + {lunarLabel ? {lunarLabel} : null} + + + {views.map((name) => ( + + ))} + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarViewContext.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarViewContext.tsx new file mode 100644 index 00000000000..7bee32be56c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/CalendarViewContext.tsx @@ -0,0 +1,32 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { createContext, useContext } from 'react'; +import type { View } from 'react-big-calendar'; + +export type CalendarNavigationAction = 'TODAY' | 'PREV' | 'NEXT'; + +export type CalendarViewContextValue = { + date: Date; + label: string; + navigate: (action: CalendarNavigationAction) => void; + showLunar?: boolean; + view: View; + views: View[]; + setView: (view: View) => void; +}; + +const CalendarViewContext = createContext(null); +CalendarViewContext.displayName = 'CalendarViewContext'; + +export const CalendarViewProvider = CalendarViewContext.Provider; + +export const useCalendarViewContext = () => { + return useContext(CalendarViewContext); +}; diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/index.ts b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/index.ts new file mode 100644 index 00000000000..79d35bfe617 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/index.ts @@ -0,0 +1,13 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './CalendarBlock'; +export * from './CalendarHeader'; +export * from './CalendarToolbar'; +export * from './CalendarViewContext'; diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/style.ts b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/style.ts new file mode 100644 index 00000000000..0d5db597272 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/components/style.ts @@ -0,0 +1,24 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { genStyleHook } from '@nocobase/client'; + +export default genStyleHook('nb-calendar-v2', (token) => { + const { componentCls } = token; + + return { + [componentCls]: { + '.rbc-month-view': { + height: '100%', + minHeight: '0', + overflow: 'hidden', + }, + }, + }; +}); diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/index.ts b/packages/plugins/@nocobase/plugin-calendar/src/client/models/index.ts new file mode 100644 index 00000000000..52c2b84d82b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './CalendarBlockModel'; +export * from './actions/CalendarActionModels'; +export * from './actions/CalendarPopupModels'; diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/models/utils.ts b/packages/plugins/@nocobase/plugin-calendar/src/client/models/utils.ts new file mode 100644 index 00000000000..dfb5800350f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/models/utils.ts @@ -0,0 +1,131 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import dayjs from 'dayjs'; + +export const CALENDAR_RANGE_FILTER_GROUP = '__calendar_view_range__'; + +export type CalendarVisibleRange = { + start: Date; + end: Date; +}; + +export const normalizeCalendarFieldPath = (fieldPath?: string | string[]) => { + if (Array.isArray(fieldPath)) { + return fieldPath.join('.'); + } + + return fieldPath; +}; + +export const parseCalendarWeekStart = (weekStart?: number | string) => { + const parsed = Number(weekStart); + return parsed === 0 ? 0 : 1; +}; + +const alignCalendarWeekStart = (value: dayjs.Dayjs, weekStart = 1) => { + const offset = (value.day() - weekStart + 7) % 7; + return value.subtract(offset, 'day').startOf('day'); +}; + +const alignCalendarWeekEnd = (value: dayjs.Dayjs, weekStart = 1) => { + const weekEnd = (weekStart + 6) % 7; + const offset = (weekEnd - value.day() + 7) % 7; + return value.add(offset, 'day').endOf('day'); +}; + +export const getCalendarVisibleRange = ( + value: Date | string, + view: 'month' | 'week' | 'day' | string = 'month', + weekStart?: number | string, +): CalendarVisibleRange => { + const current = dayjs(value); + const normalizedWeekStart = parseCalendarWeekStart(weekStart); + + if (view === 'day') { + return { + start: current.startOf('day').toDate(), + end: current.endOf('day').toDate(), + }; + } + + if (view === 'week') { + const start = alignCalendarWeekStart(current, normalizedWeekStart); + return { + start: start.toDate(), + end: start.add(6, 'day').endOf('day').toDate(), + }; + } + + const monthStart = alignCalendarWeekStart(current.startOf('month'), normalizedWeekStart); + const monthEnd = alignCalendarWeekEnd(current.endOf('month'), normalizedWeekStart); + + return { + start: monthStart.toDate(), + end: monthEnd.toDate(), + }; +}; + +export const formatDate = (date: dayjs.Dayjs) => { + return date.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); +}; + +export const createCalendarRangeFilter = ( + fieldNames: { + start?: string | string[]; + end?: string | string[]; + }, + range: CalendarVisibleRange, +) => { + const startField = normalizeCalendarFieldPath(fieldNames?.start); + const endField = normalizeCalendarFieldPath(fieldNames?.end); + + if (!startField) { + return null; + } + + const startValue = formatDate(dayjs(range.start)); + const endValue = formatDate(dayjs(range.end)); + const startBetween = { + [startField]: { + $gte: startValue, + $lte: endValue, + }, + }; + + if (!endField || endField === startField) { + return startBetween; + } + + return { + $or: [ + startBetween, + { + [endField]: { + $gte: startValue, + $lte: endValue, + }, + }, + { + $and: [ + { + [startField]: { + $lte: startValue, + }, + }, + { + [endField]: { + $gte: endValue, + }, + }, + ], + }, + ], + }; +};