mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
Merge branch 'next' into develop
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
pkg: "@nocobase/plugin-calendar"
|
||||
title: "日历区块"
|
||||
description: "日历区块以日历视图展示事件与日期数据,适用于会议安排、活动计划等场景,支持配置标题字段、起止时间、农历显示及数据范围等。"
|
||||
keywords: "日历区块, 日历视图, 事件管理, 会议安排, Calendar, NocoBase"
|
||||
---
|
||||
|
||||
# 日历区块
|
||||
|
||||
## 介绍
|
||||
|
||||
日历区块以直观的日历视图展示事件及日期相关数据,适用于会议安排、活动计划等典型场景。
|
||||
|
||||

|
||||
|
||||
## 安装
|
||||
|
||||
该区块为内置插件,无需额外安装。
|
||||
|
||||
## 添加区块
|
||||
|
||||

|
||||
|
||||
选择 "日历" 区块,并指定对应的数据表,即可完成创建。
|
||||
|
||||
## 区块配置项
|
||||
|
||||

|
||||
|
||||
### 标题字段
|
||||
|
||||
用于展示在日历事件条上的标题信息。
|
||||
|
||||
当前支持的字段类型包括:`input`、`select`、`phone`、`email`、`radioGroup`、`sequence` 等,也支持通过插件扩展更多类型。
|
||||
|
||||
### 开始日期字段
|
||||
|
||||
用于指定事件的开始时间。
|
||||
|
||||
### 结束日期字段
|
||||
|
||||
用于指定事件的结束时间。
|
||||
|
||||
### 快速创建事件
|
||||
|
||||
点击日历中的空白日期区域,可快速弹出浮层创建事件。
|
||||
|
||||

|
||||
|
||||
点击已有事件时:
|
||||
- 当前事件会高亮显示
|
||||
- 同时弹出详情窗口,便于查看或编辑
|
||||
|
||||
### 显示农历
|
||||
|
||||
开启后,日历中将显示对应的农历信息。
|
||||
|
||||

|
||||
|
||||
### 数据范围
|
||||
|
||||
用于限制日历区块中展示的数据范围。
|
||||
|
||||

|
||||
|
||||
更多说明请参考:[设置数据范围](/interface-builder/blocks/block-settings/data-scope)
|
||||
|
||||
### 区块高度
|
||||
|
||||
可自定义日历区块高度,避免内部出现滚动条,提升整体布局体验。
|
||||
|
||||
.png)
|
||||
|
||||
更多说明请参考:[区块高度](/interface-builder/blocks/block-settings/block-height)
|
||||
|
||||
### 颜色字段
|
||||
|
||||
用于配置日历事件的背景颜色,提升视觉区分度。
|
||||
|
||||
使用步骤:
|
||||
|
||||
1. 在数据表中新增一个 **下拉单选(Single select)** 或 **单选框(Radio group)** 字段,并为选项配置颜色;
|
||||
2. 在日历区块配置中,将该字段设置为「颜色字段」;
|
||||
3. 在创建或编辑事件时选择颜色,即可在日历中生效。
|
||||
|
||||

|
||||
|
||||
### 周起始日
|
||||
|
||||
支持设置每周的起始日,可选择:
|
||||
- 周日
|
||||
- 周一(默认)
|
||||
|
||||
可根据不同地区的使用习惯进行调整,使日历展示更符合实际需求。
|
||||
|
||||
.png)
|
||||
|
||||
## 配置操作
|
||||
|
||||

|
||||
|
||||
### 今天
|
||||
|
||||
点击 "今天" 按钮,可快速跳转回当前日期所在的日历视图。
|
||||
|
||||
### 切换页面
|
||||
|
||||
根据当前视图模式进行时间切换:
|
||||
- 月视图:上一月 / 下一月
|
||||
- 周视图:上一周 / 下一周
|
||||
- 日视图:昨天 / 明天
|
||||
|
||||
### 选择视图
|
||||
|
||||
支持在以下视图间切换:
|
||||
- 月视图(默认)
|
||||
- 周视图
|
||||
- 日视图
|
||||
|
||||
### 标题
|
||||
|
||||
用于显示当前视图对应的日期。
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
This block is a built-in plugin and does not require additional installation.
|
||||
|
||||
## Add block
|
||||
|
||||

|
||||
|
||||
Select the "Calendar" block and choose the corresponding data table to create the block.
|
||||
|
||||
## Block settings
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### Data scope
|
||||
|
||||
Used to restrict the data displayed in the calendar block.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
.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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
.png)
|
||||
|
||||
## Actions
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
+99
@@ -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)),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ function Toolbar(props: ToolbarProps) {
|
||||
}, null),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<CalendarToolbarContext.Provider value={props}>
|
||||
<NocoBaseRecursionField name={toolBarSchema.name} schema={toolBarSchema} />
|
||||
@@ -274,6 +275,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();
|
||||
@@ -415,8 +417,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);
|
||||
|
||||
@@ -435,7 +435,7 @@ export const Calendar: any = withDynamicSchemaProps(
|
||||
...ctx.form?.query(endFieldName).take()?.componentProps,
|
||||
};
|
||||
|
||||
startDateValue = handleDateChangeOnForm(
|
||||
const startDateValue = handleDateChangeOnForm(
|
||||
currentSelectDate.start,
|
||||
startFieldProps.dateOnly,
|
||||
startFieldProps.utc,
|
||||
@@ -443,7 +443,7 @@ export const Calendar: any = withDynamicSchemaProps(
|
||||
startFieldProps.showTime,
|
||||
startFieldProps.gtm,
|
||||
);
|
||||
endDataValue = handleDateChangeOnForm(
|
||||
const endDataValue = handleDateChangeOnForm(
|
||||
currentSelectDate.end,
|
||||
endFieldProps.dateOnly,
|
||||
endFieldProps.utc,
|
||||
@@ -458,7 +458,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,
|
||||
};
|
||||
@@ -485,7 +491,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);
|
||||
}
|
||||
@@ -498,6 +505,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)),
|
||||
@@ -507,7 +518,7 @@ export const Calendar: any = withDynamicSchemaProps(
|
||||
setRecord(record);
|
||||
openPopup({
|
||||
recordData: record,
|
||||
customActionSchema: findEventSchema(fieldSchema),
|
||||
customActionSchema: fieldSchema ? findEventSchema(fieldSchema) : undefined,
|
||||
});
|
||||
}}
|
||||
formats={formats}
|
||||
@@ -532,27 +543,29 @@ export const Calendar: any = withDynamicSchemaProps(
|
||||
}}
|
||||
/>
|
||||
</PopupContextProvider>
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
...ctx,
|
||||
visible: visibleAddNewer,
|
||||
setVisible: setVisibleAddNewer,
|
||||
openMode: findEventSchema(fieldSchema)?.['x-component-props']?.['openMode'],
|
||||
}}
|
||||
>
|
||||
<CollectionProvider name={collection.name}>
|
||||
<SchemaComponentOptions scope={{ useCreateFormBlockProps }}>
|
||||
<NocoBaseRecursionField
|
||||
onlyRenderProperties
|
||||
basePath={field?.address}
|
||||
schema={fieldSchema}
|
||||
filterProperties={(s) => {
|
||||
return s['x-component'] === 'AssociationField.AddNewer';
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentOptions>
|
||||
</CollectionProvider>
|
||||
</ActionContextProvider>
|
||||
{hasSchemaRuntime && collection ? (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
...ctx,
|
||||
visible: visibleAddNewer,
|
||||
setVisible: setVisibleAddNewer,
|
||||
openMode: findEventSchema(fieldSchema)?.['x-component-props']?.['openMode'],
|
||||
}}
|
||||
>
|
||||
<CollectionProvider name={collection.name}>
|
||||
<SchemaComponentOptions scope={{ useCreateFormBlockProps }}>
|
||||
<NocoBaseRecursionField
|
||||
onlyRenderProperties
|
||||
basePath={field?.address}
|
||||
schema={fieldSchema}
|
||||
filterProperties={(s) => {
|
||||
return s['x-component'] === 'AssociationField.AddNewer';
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentOptions>
|
||||
</CollectionProvider>
|
||||
</ActionContextProvider>
|
||||
) : null}
|
||||
</div>,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<any>,
|
||||
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 <CalendarBlockView model={this} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Droppable model={action} key={action.uid}>
|
||||
<FlowModelRenderer
|
||||
model={action}
|
||||
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
|
||||
extraToolbarItems={DRAG_HANDLER_TOOLBAR_ITEMS}
|
||||
/>
|
||||
</Droppable>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Droppable model={action} key={action.uid}>
|
||||
<FlowModelRenderer
|
||||
model={action}
|
||||
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
|
||||
extraToolbarItems={DRAG_HANDLER_TOOLBAR_ITEMS}
|
||||
/>
|
||||
</Droppable>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}) || []) as React.ReactNode[];
|
||||
|
||||
const hasActions = [...leftActions, ...rightActions].some(Boolean);
|
||||
const actionBar =
|
||||
hasActions || isConfigMode ? (
|
||||
<DndProvider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: model.context.themeToken.margin,
|
||||
gap: model.context.themeToken.marginXXS,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
{leftActions}
|
||||
<span />
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{rightActions}
|
||||
<AddSubModelButton
|
||||
key="calendar-block-add-actions"
|
||||
model={model}
|
||||
subModelBaseClass={model.getModelClassName('CollectionActionGroupModel')}
|
||||
subModelKey="actions"
|
||||
>
|
||||
<FlowSettingsButton icon={<SettingOutlined />}>{model.translate('Actions')}</FlowSettingsButton>
|
||||
</AddSubModelButton>
|
||||
</Space>
|
||||
</div>
|
||||
</DndProvider>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<CalendarBlock
|
||||
actionBar={actionBar}
|
||||
{...model.props}
|
||||
collection={model.collection}
|
||||
dataSource={model.resource.getData()}
|
||||
fieldNames={fieldNames}
|
||||
defaultView={model.getDefaultView()}
|
||||
weekStart={model.getWeekStart()}
|
||||
resource={model.resource}
|
||||
rangeLoadEnabled
|
||||
onSelectSlot={(slotInfo) => {
|
||||
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 (
|
||||
<CalendarBlockRenderer
|
||||
key={colorFieldName || '__calendar-default-color__'}
|
||||
model={model}
|
||||
fieldNames={fieldNames}
|
||||
colorCollectionField={colorCollectionField}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
+241
@@ -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<CalendarTodayActionModel>();
|
||||
const calendarView = useCalendarViewContext();
|
||||
|
||||
if (!calendarView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = model.getIcon() ? <Icon type={model.getIcon() as any} /> : undefined;
|
||||
|
||||
return (
|
||||
<Button {...model.props} icon={icon} onClick={() => calendarView.navigate('TODAY')}>
|
||||
{model.props.children || model.getTitle()}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
const CalendarNavAction = observer(() => {
|
||||
const calendarView = useCalendarViewContext();
|
||||
|
||||
if (!calendarView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button.Group>
|
||||
<Button icon={<LeftOutlined />} onClick={() => calendarView.navigate('PREV')} />
|
||||
<Button icon={<RightOutlined />} onClick={() => calendarView.navigate('NEXT')} />
|
||||
</Button.Group>
|
||||
);
|
||||
});
|
||||
|
||||
const CalendarTitleAction = observer(() => {
|
||||
const calendarView = useCalendarViewContext();
|
||||
|
||||
const lunarLabel = useMemo(() => {
|
||||
if (!calendarView?.showLunar || calendarView.view !== 'day') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getLunarDay(dayjs(calendarView.date));
|
||||
}, [calendarView]);
|
||||
|
||||
if (!calendarView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text strong style={{ fontSize: 16, lineHeight: 2 }}>
|
||||
{calendarView.label}
|
||||
{lunarLabel ? <span style={{ marginLeft: 8, fontWeight: 400 }}>{lunarLabel}</span> : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
});
|
||||
|
||||
const CalendarViewSelectAction = observer(() => {
|
||||
const calendarView = useCalendarViewContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!calendarView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Segmented
|
||||
value={calendarView.view}
|
||||
onChange={(value) => calendarView.setView(value as any)}
|
||||
options={calendarView.views.map((name) => ({
|
||||
label: t(name === 'month' ? 'Month' : name === 'week' ? 'Week' : 'Day'),
|
||||
value: name,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export class CalendarCollectionActionGroupModel extends CollectionActionGroupModel {}
|
||||
|
||||
CalendarCollectionActionGroupModel.defineChildren = async function defineChildren(ctx: FlowModelContext) {
|
||||
const items = await CollectionActionGroupModel.defineChildren.call(this, ctx);
|
||||
return items
|
||||
.filter((item) => !shouldFilterActionItem(item))
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = CALENDAR_ACTION_ORDER_MAP.get(left.item.useModel);
|
||||
const rightOrder = CALENDAR_ACTION_ORDER_MAP.get(right.item.useModel);
|
||||
const leftIsCalendar = leftOrder !== undefined;
|
||||
const rightIsCalendar = rightOrder !== undefined;
|
||||
|
||||
if (leftIsCalendar && rightIsCalendar) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
|
||||
if (leftIsCalendar) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rightIsCalendar) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
};
|
||||
|
||||
export class CalendarTodayActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
|
||||
defaultProps = {
|
||||
type: 'default' as const,
|
||||
title: tExpr('Today', { ns: 'calendar' }),
|
||||
};
|
||||
|
||||
render() {
|
||||
return <CalendarTodayAction />;
|
||||
}
|
||||
}
|
||||
|
||||
CalendarTodayActionModel.define({
|
||||
label: tExpr('Today', { ns: 'calendar' }),
|
||||
toggleable: true,
|
||||
sort: 10,
|
||||
});
|
||||
|
||||
export class CalendarNavActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
enableEditIcon = false;
|
||||
enableEditType = false;
|
||||
enableEditDanger = false;
|
||||
|
||||
render() {
|
||||
return <CalendarNavAction />;
|
||||
}
|
||||
}
|
||||
|
||||
CalendarNavActionModel.define({
|
||||
label: tExpr('Turn pages', { ns: 'calendar' }),
|
||||
toggleable: true,
|
||||
sort: 20,
|
||||
});
|
||||
|
||||
export class CalendarTitleActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
enableEditIcon = false;
|
||||
enableEditType = false;
|
||||
enableEditDanger = false;
|
||||
|
||||
render() {
|
||||
return <CalendarTitleAction />;
|
||||
}
|
||||
}
|
||||
|
||||
CalendarTitleActionModel.define({
|
||||
label: tExpr('Title', { ns: 'calendar' }),
|
||||
toggleable: true,
|
||||
sort: 30,
|
||||
});
|
||||
|
||||
export class CalendarViewSelectActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
enableEditIcon = false;
|
||||
enableEditType = false;
|
||||
enableEditDanger = false;
|
||||
|
||||
render() {
|
||||
return <CalendarViewSelectAction />;
|
||||
}
|
||||
}
|
||||
|
||||
CalendarViewSelectActionModel.define({
|
||||
label: tExpr('Select view', { ns: 'calendar' }),
|
||||
toggleable: true,
|
||||
sort: 40,
|
||||
});
|
||||
|
||||
CalendarCollectionActionGroupModel.registerActionModels({
|
||||
CalendarTodayActionModel,
|
||||
CalendarNavActionModel,
|
||||
CalendarTitleActionModel,
|
||||
CalendarViewSelectActionModel,
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { AddNewActionModel, ViewActionModel } from '@nocobase/client';
|
||||
import { tExpr } from '@nocobase/flow-engine';
|
||||
|
||||
export const createCalendarQuickCreateActionOptions = (uid?: string) => {
|
||||
return {
|
||||
uid,
|
||||
use: 'CalendarQuickCreateActionModel',
|
||||
};
|
||||
};
|
||||
|
||||
export const createCalendarEventViewActionOptions = (uid?: string) => {
|
||||
return {
|
||||
uid,
|
||||
use: 'CalendarEventViewActionModel',
|
||||
};
|
||||
};
|
||||
|
||||
export class CalendarQuickCreateActionModel extends AddNewActionModel {
|
||||
defaultPopupTitle = tExpr('Add new', { ns: 'calendar' });
|
||||
}
|
||||
|
||||
CalendarQuickCreateActionModel.define({
|
||||
hide: true,
|
||||
createModelOptions: async () => {
|
||||
return createCalendarQuickCreateActionOptions();
|
||||
},
|
||||
});
|
||||
|
||||
export class CalendarEventViewActionModel extends ViewActionModel {
|
||||
defaultPopupTitle = tExpr('View record', { ns: 'calendar' });
|
||||
}
|
||||
|
||||
CalendarEventViewActionModel.define({
|
||||
hide: true,
|
||||
createModelOptions: async () => {
|
||||
return createCalendarEventViewActionOptions();
|
||||
},
|
||||
});
|
||||
+543
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* 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 { useAPIClient, useApp, useLazy } from '@nocobase/client';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { get, omit } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View } from 'react-big-calendar';
|
||||
import { dateFnsLocalizer } from 'react-big-calendar';
|
||||
import { format, getDay, parse, startOfWeek } from 'date-fns';
|
||||
import enUS from 'date-fns/locale/en-US';
|
||||
import ru from 'date-fns/locale/ru';
|
||||
import zhCN from 'date-fns/locale/zh-CN';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { getLabelFormatValue } from '@nocobase/client';
|
||||
import { i18nt, useTranslation } from '../../../locale';
|
||||
import GlobalStyle from '../../calendar/global.style';
|
||||
import { useCalenderHeight } from '../../calendar/hook';
|
||||
import useBaseStyle from '../../calendar/style';
|
||||
import { theme } from 'antd';
|
||||
import {
|
||||
CALENDAR_RANGE_FILTER_GROUP,
|
||||
createCalendarRangeFilter,
|
||||
formatDate,
|
||||
getCalendarVisibleRange,
|
||||
normalizeCalendarFieldPath,
|
||||
} from '../utils';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { CalendarViewProvider, type CalendarNavigationAction } from './CalendarViewContext';
|
||||
import useStyle from './style';
|
||||
|
||||
type CalendarCulture = keyof typeof LOCALES;
|
||||
|
||||
type LocalizerFormatContext = {
|
||||
format: (value: Date, formatStr: string, culture?: string) => string;
|
||||
};
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
colorFieldValue: string;
|
||||
title: string;
|
||||
rawTitle?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
const WEEKS = ['month', 'week', 'day'] as View[];
|
||||
const LOCALES = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
'ru-RU': ru,
|
||||
};
|
||||
|
||||
const useCalendarEvents = (
|
||||
dataSource: any,
|
||||
fieldNames: {
|
||||
colorFieldName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
id: string;
|
||||
title: string;
|
||||
},
|
||||
date: Date,
|
||||
view: (typeof WEEKS)[number] | string,
|
||||
collection: any,
|
||||
weekStart?: number,
|
||||
) => {
|
||||
const parseExpression = useLazy<typeof import('cron-parser').parseExpression>(
|
||||
() => import('cron-parser'),
|
||||
'parseExpression',
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const app = useApp();
|
||||
const plugin = app.pm.get('calendar') as any;
|
||||
const activeCollection = collection;
|
||||
const getCollectionField = useCallback(
|
||||
(fieldName?: string) => {
|
||||
const normalizedFieldName = normalizeCalendarFieldPath(fieldName);
|
||||
if (!normalizedFieldName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return activeCollection?.getField?.(normalizedFieldName);
|
||||
},
|
||||
[activeCollection],
|
||||
);
|
||||
const titleCollectionField = getCollectionField(fieldNames?.title);
|
||||
const labelUiSchema = titleCollectionField?.uiSchema;
|
||||
const visibleRange = useMemo(() => getCalendarVisibleRange(date, view, weekStart), [date, view, weekStart]);
|
||||
const rangeStart = visibleRange.start;
|
||||
const rangeEnd = visibleRange.end;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!Array.isArray(dataSource)) {
|
||||
return { events: [] as Event[] };
|
||||
}
|
||||
|
||||
const events: Event[] = [];
|
||||
const startDate = dayjs(rangeStart);
|
||||
const endDate = dayjs(rangeEnd);
|
||||
|
||||
dataSource.forEach((item) => {
|
||||
const { cron, exclude = [] } = item;
|
||||
const start = dayjs(get(item, fieldNames.start) || dayjs());
|
||||
const end = dayjs(get(item, fieldNames.end) || start);
|
||||
const intervalTime = end.diff(start, 'millisecond', true);
|
||||
|
||||
const push = (eventStart: Dayjs = start.clone()) => {
|
||||
if (
|
||||
eventStart.isBefore(start) ||
|
||||
(!eventStart.isBetween(startDate, endDate, null, '[]') && !end.isBetween(startDate, endDate, null, '[]'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let out = false;
|
||||
const res = exclude?.some((value: string) => {
|
||||
let dateValue = value;
|
||||
if (dateValue.endsWith('_after')) {
|
||||
dateValue = dateValue.replace(/_after$/, '');
|
||||
out = true;
|
||||
return eventStart.isSameOrAfter(dateValue);
|
||||
}
|
||||
return eventStart.isSame(dateValue);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const targetTitleCollectionField = getCollectionField(fieldNames.title);
|
||||
const targetTitle = targetTitleCollectionField?.interface
|
||||
? plugin.getTitleFieldInterface(targetTitleCollectionField.interface)
|
||||
: null;
|
||||
const title = getLabelFormatValue(
|
||||
labelUiSchema,
|
||||
get(item, fieldNames.title),
|
||||
true,
|
||||
targetTitleCollectionField,
|
||||
targetTitle?.TitleRenderer,
|
||||
);
|
||||
|
||||
events.push({
|
||||
id: get(item, fieldNames.id || 'id'),
|
||||
colorFieldValue: get(item, fieldNames.colorFieldName),
|
||||
title: title || t('Untitle'),
|
||||
start: eventStart.toDate(),
|
||||
end: eventStart.add(intervalTime, 'millisecond').toDate(),
|
||||
rawTitle: get(item, fieldNames.title),
|
||||
});
|
||||
};
|
||||
|
||||
if (cron === 'every_week') {
|
||||
let nextStart = start
|
||||
.clone()
|
||||
.year(startDate.year())
|
||||
.month(startDate.month())
|
||||
.date(startDate.date())
|
||||
.day(start.day());
|
||||
while (nextStart.isBefore(endDate)) {
|
||||
if (push(nextStart.clone())) {
|
||||
break;
|
||||
}
|
||||
nextStart = nextStart.add(1, 'week');
|
||||
}
|
||||
} else if (cron === 'every_month') {
|
||||
push(start.clone().year(startDate.year()).month(startDate.month()));
|
||||
} else if (cron === 'every_year') {
|
||||
push(start.clone().year(startDate.year()));
|
||||
} else {
|
||||
push();
|
||||
if (!cron) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const interval = parseExpression(cron, {
|
||||
startDate: startDate.toDate(),
|
||||
endDate: endDate.toDate(),
|
||||
iterator: true,
|
||||
currentDate: start.toDate(),
|
||||
utc: true,
|
||||
});
|
||||
while (interval.hasNext()) {
|
||||
const { value } = interval.next();
|
||||
if (push(dayjs(value.toDate()))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { events };
|
||||
}, [
|
||||
dataSource,
|
||||
fieldNames.colorFieldName,
|
||||
fieldNames.end,
|
||||
fieldNames.id,
|
||||
fieldNames.start,
|
||||
fieldNames.title,
|
||||
getCollectionField,
|
||||
labelUiSchema,
|
||||
parseExpression,
|
||||
plugin,
|
||||
rangeEnd,
|
||||
rangeStart,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
type CalendarBlockProps = {
|
||||
actionBar?: React.ReactNode;
|
||||
collection: any;
|
||||
dataSource: any[];
|
||||
defaultView?: View;
|
||||
fieldNames: {
|
||||
colorFieldName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
getBackgroundColor?: (value: string) => string | null;
|
||||
getFontColor?: (value: string) => string | null;
|
||||
onSelectEvent?: (payload: { event: Event; record: any }) => void;
|
||||
onSelectSlot?: (payload: any) => void;
|
||||
rangeLoadEnabled?: boolean;
|
||||
resource?: any;
|
||||
showLunar?: boolean;
|
||||
weekStart?: number;
|
||||
};
|
||||
|
||||
export const CalendarBlock = observer((props: CalendarBlockProps) => {
|
||||
const reactBigCalendar = useLazy(
|
||||
() => import('react-big-calendar'),
|
||||
(module) => ({
|
||||
BigCalendar: module.Calendar,
|
||||
}),
|
||||
);
|
||||
const height = useCalenderHeight();
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
const [view, setView] = useState<View>(props.defaultView || 'month');
|
||||
const rangeRefreshKeyRef = useRef<string>();
|
||||
const { events } = useCalendarEvents(
|
||||
props.dataSource,
|
||||
props.fieldNames,
|
||||
date,
|
||||
view,
|
||||
props.collection,
|
||||
props.weekStart,
|
||||
);
|
||||
const baseStyle = useBaseStyle();
|
||||
const overrideStyle = useStyle();
|
||||
const apiClient = useAPIClient();
|
||||
const locale = (
|
||||
LOCALES[apiClient.auth.locale as CalendarCulture] ? (apiClient.auth.locale as CalendarCulture) : 'en-US'
|
||||
) as CalendarCulture;
|
||||
|
||||
const formats = useMemo(() => {
|
||||
return {
|
||||
monthHeaderFormat: (value: Date, culture: string, local: LocalizerFormatContext) =>
|
||||
local.format(
|
||||
value,
|
||||
culture === 'zh-CN' ? 'yyyy年M月' : culture === 'ru-RU' ? 'LLLL yyyy' : 'MMM yyyy',
|
||||
culture,
|
||||
),
|
||||
agendaDateFormat: (value: Date, culture: string, local: LocalizerFormatContext) => {
|
||||
return local.format(
|
||||
value,
|
||||
culture === 'zh-CN' ? 'yyyy年M月' : culture === 'ru-RU' ? 'LLLL yyyy' : 'MMM yyyy',
|
||||
culture,
|
||||
);
|
||||
},
|
||||
dayHeaderFormat: (value: Date, culture: string, local: LocalizerFormatContext) => {
|
||||
return local.format(
|
||||
value,
|
||||
culture === 'zh-CN' ? 'eee, M/d' : culture === 'ru-RU' ? 'EEE, d MMM' : 'EEE, MMM d',
|
||||
culture,
|
||||
);
|
||||
},
|
||||
dayRangeHeaderFormat: (
|
||||
{ start, end }: { start: Date; end: Date },
|
||||
culture: string,
|
||||
local: LocalizerFormatContext,
|
||||
) => {
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return local.format(
|
||||
start,
|
||||
culture === 'zh-CN' ? 'yyyy年M月' : culture === 'ru-RU' ? 'LLLL yyyy' : 'MMM yyyy',
|
||||
culture,
|
||||
);
|
||||
}
|
||||
return `${local.format(
|
||||
start,
|
||||
culture === 'zh-CN' ? 'yyyy年M月' : culture === 'ru-RU' ? 'LLLL yyyy' : 'MMM yyyy',
|
||||
culture,
|
||||
)} - ${local.format(
|
||||
end,
|
||||
culture === 'zh-CN' ? 'yyyy年M月' : culture === 'ru-RU' ? 'LLLL yyyy' : 'MMM yyyy',
|
||||
culture,
|
||||
)}`;
|
||||
},
|
||||
weekdayFormat: (value: Date, culture: string, local: LocalizerFormatContext) => {
|
||||
return local.format(value, 'EEE', culture);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const localizer = useMemo(() => {
|
||||
return dateFnsLocalizer({
|
||||
format,
|
||||
parse,
|
||||
startOfWeek: (value: Date) =>
|
||||
startOfWeek(value, {
|
||||
locale: LOCALES[locale],
|
||||
weekStartsOn: (props.weekStart ?? 1) as 0 | 1 | 2 | 3 | 4 | 5 | 6,
|
||||
}),
|
||||
getDay,
|
||||
locales: LOCALES,
|
||||
});
|
||||
}, [locale, props.weekStart]);
|
||||
|
||||
useEffect(() => {
|
||||
setView(props.defaultView || 'month');
|
||||
}, [props.defaultView]);
|
||||
|
||||
const visibleRange = useMemo(() => {
|
||||
return getCalendarVisibleRange(date, view, props.weekStart);
|
||||
}, [date, props.weekStart, view]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (view === 'day') {
|
||||
return formats.dayHeaderFormat(date, locale, localizer);
|
||||
}
|
||||
|
||||
if (view === 'week') {
|
||||
return formats.dayRangeHeaderFormat(
|
||||
{
|
||||
start: visibleRange.start,
|
||||
end: visibleRange.end,
|
||||
},
|
||||
locale,
|
||||
localizer,
|
||||
);
|
||||
}
|
||||
|
||||
return formats.monthHeaderFormat(date, locale, localizer);
|
||||
}, [date, formats, locale, localizer, view, visibleRange.end, visibleRange.start]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(action: CalendarNavigationAction) => {
|
||||
if (action === 'TODAY') {
|
||||
setDate(new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
const unit = view === 'month' ? 'month' : view === 'week' ? 'week' : 'day';
|
||||
const nextDate = dayjs(date)[action === 'PREV' ? 'subtract' : 'add'](1, unit).toDate();
|
||||
setDate(nextDate);
|
||||
},
|
||||
[date, view],
|
||||
);
|
||||
|
||||
const viewContextValue = useMemo(() => {
|
||||
return {
|
||||
date,
|
||||
label,
|
||||
navigate,
|
||||
showLunar: props.showLunar,
|
||||
view,
|
||||
views: WEEKS,
|
||||
setView,
|
||||
};
|
||||
}, [date, label, navigate, props.showLunar, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.rangeLoadEnabled || !props.resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeFilter = createCalendarRangeFilter(props.fieldNames, visibleRange);
|
||||
const nextKey = JSON.stringify({
|
||||
fieldNames: props.fieldNames,
|
||||
view,
|
||||
start: visibleRange.start.toISOString(),
|
||||
end: visibleRange.end.toISOString(),
|
||||
rangeFilter,
|
||||
});
|
||||
|
||||
if (rangeRefreshKeyRef.current === nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
rangeRefreshKeyRef.current = nextKey;
|
||||
|
||||
if (rangeFilter) {
|
||||
props.resource.addFilterGroup(CALENDAR_RANGE_FILTER_GROUP, rangeFilter);
|
||||
} else {
|
||||
props.resource.removeFilterGroup(CALENDAR_RANGE_FILTER_GROUP);
|
||||
}
|
||||
|
||||
props.resource.setPage?.(1);
|
||||
void props.resource.refresh();
|
||||
}, [props.fieldNames, props.rangeLoadEnabled, props.resource, view, visibleRange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
props.resource?.removeFilterGroup?.(CALENDAR_RANGE_FILTER_GROUP);
|
||||
};
|
||||
}, [props.resource]);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
week: {
|
||||
header: (headerProps: any) => (
|
||||
<CalendarHeader
|
||||
{...headerProps}
|
||||
type="week"
|
||||
showLunar={props.showLunar}
|
||||
localizer={localizer}
|
||||
locale={locale}
|
||||
/>
|
||||
),
|
||||
},
|
||||
month: {
|
||||
dateHeader: (headerProps: any) => <CalendarHeader {...headerProps} showLunar={props.showLunar} />,
|
||||
},
|
||||
};
|
||||
}, [locale, localizer, props.showLunar]);
|
||||
|
||||
const messages: any = {
|
||||
allDay: '',
|
||||
previous: i18nt('Previous'),
|
||||
next: i18nt('Next'),
|
||||
today: i18nt('Today'),
|
||||
month: i18nt('Month'),
|
||||
week: i18nt('Week'),
|
||||
work_week: i18nt('Work week'),
|
||||
day: i18nt('Day'),
|
||||
agenda: i18nt('Agenda'),
|
||||
date: i18nt('Date'),
|
||||
time: i18nt('Time'),
|
||||
event: i18nt('Event'),
|
||||
noEventsInRange: i18nt('None'),
|
||||
showMore: (count: number) => i18nt('{{count}} more items', { count }),
|
||||
};
|
||||
|
||||
const eventPropGetter = (event: Event) => {
|
||||
if (!event.colorFieldValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fontColor = props.getFontColor?.(event.colorFieldValue);
|
||||
const backgroundColor = props.getBackgroundColor?.(event.colorFieldValue);
|
||||
const style = {} as Record<string, string>;
|
||||
|
||||
if (fontColor) {
|
||||
style.color = fontColor;
|
||||
}
|
||||
if (backgroundColor) {
|
||||
style.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
return { style };
|
||||
};
|
||||
|
||||
const BigCalendar = reactBigCalendar?.BigCalendar;
|
||||
if (!BigCalendar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return baseStyle.wrapSSR(
|
||||
overrideStyle.wrapSSR(
|
||||
<CalendarViewProvider value={viewContextValue}>
|
||||
<div
|
||||
className={`${baseStyle.hashId} ${baseStyle.componentCls} ${overrideStyle.hashId} ${overrideStyle.componentCls}`}
|
||||
style={{
|
||||
height: height || 700,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
marginBottom: token.marginSM,
|
||||
}}
|
||||
>
|
||||
<GlobalStyle />
|
||||
{props.actionBar ? props.actionBar : null}
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, overflow: 'hidden' }}>
|
||||
<BigCalendar
|
||||
popup
|
||||
selectable
|
||||
toolbar={false}
|
||||
events={events}
|
||||
eventPropGetter={eventPropGetter}
|
||||
view={view}
|
||||
views={WEEKS}
|
||||
date={date}
|
||||
step={60}
|
||||
showMultiDayTimes
|
||||
messages={messages}
|
||||
onNavigate={setDate}
|
||||
onView={setView}
|
||||
onSelectSlot={(slotInfo: any) => {
|
||||
props.onSelectSlot?.(slotInfo);
|
||||
}}
|
||||
onSelectEvent={(event: Event & { resource?: unknown }) => {
|
||||
const record = props.dataSource?.find((item) => get(item, props.fieldNames.id || 'id') === event.id);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
props.onSelectEvent?.({
|
||||
event: {
|
||||
...omit(event, 'resource'),
|
||||
start: formatDate(dayjs(event.start)),
|
||||
end: formatDate(dayjs(event.end)),
|
||||
} as any,
|
||||
record,
|
||||
});
|
||||
}}
|
||||
formats={formats}
|
||||
components={components}
|
||||
culture={locale}
|
||||
localizer={localizer}
|
||||
tooltipAccessor={(value: Event) => {
|
||||
return value.rawTitle ? value.rawTitle : '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CalendarViewProvider>,
|
||||
),
|
||||
);
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 React, { useMemo } from 'react';
|
||||
import { getLunarDay } from '../../calendar/utils';
|
||||
|
||||
type CalendarHeaderProps = {
|
||||
date: Date;
|
||||
label: string;
|
||||
drilldownView?: string;
|
||||
onDrillDown?: () => void;
|
||||
showLunar?: boolean;
|
||||
type?: 'week' | 'month';
|
||||
localizer?: any;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export const CalendarHeader = ({
|
||||
date,
|
||||
label,
|
||||
drilldownView,
|
||||
onDrillDown,
|
||||
showLunar = false,
|
||||
type,
|
||||
localizer,
|
||||
locale,
|
||||
}: CalendarHeaderProps) => {
|
||||
const lunarElement = useMemo(() => {
|
||||
if (!showLunar) {
|
||||
return null;
|
||||
}
|
||||
return <span className="rbc-date-lunar">{getLunarDay(dayjs(date))}</span>;
|
||||
}, [date, showLunar]);
|
||||
|
||||
const child = useMemo(() => {
|
||||
if (type === 'week') {
|
||||
const dayLabel = localizer?.format?.(date, 'd eee', locale) || label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="rbc-date-solar" style={{ fontSize: 14 }}>
|
||||
{dayLabel}
|
||||
</span>
|
||||
{lunarElement}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="rbc-date-solar">{label}</span>
|
||||
{lunarElement}
|
||||
</>
|
||||
);
|
||||
}, [date, label, locale, localizer, lunarElement, type]);
|
||||
|
||||
const Wrapper = drilldownView ? 'a' : React.Fragment;
|
||||
|
||||
return (
|
||||
<Wrapper onClick={onDrillDown} role="cell">
|
||||
{child}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 dayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import { i18nt, useTranslation } from '../../../locale';
|
||||
import { getLunarDay } from '../../calendar/utils';
|
||||
|
||||
type CalendarToolbarProps = {
|
||||
date: Date;
|
||||
label: string;
|
||||
onNavigate: (action: string) => void;
|
||||
onView: (view: string) => void;
|
||||
view: string;
|
||||
views: string[];
|
||||
showLunar?: boolean;
|
||||
};
|
||||
|
||||
const VIEW_LABELS = {
|
||||
month: i18nt('Month'),
|
||||
week: i18nt('Week'),
|
||||
day: i18nt('Day'),
|
||||
};
|
||||
|
||||
export const CalendarToolbar = ({ date, label, onNavigate, onView, view, views, showLunar }: CalendarToolbarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const lunarLabel = useMemo(() => {
|
||||
if (!showLunar || view !== 'day') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getLunarDay(dayjs(date));
|
||||
}, [date, showLunar, view]);
|
||||
|
||||
return (
|
||||
<div className="rbc-toolbar">
|
||||
<span className="rbc-btn-group">
|
||||
<button type="button" className="rbc-btn" onClick={() => onNavigate('TODAY')}>
|
||||
{t('Today')}
|
||||
</button>
|
||||
</span>
|
||||
<span className="rbc-btn-group">
|
||||
<button type="button" className="rbc-btn" onClick={() => onNavigate('PREV')}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<button type="button" className="rbc-btn" onClick={() => onNavigate('NEXT')}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</span>
|
||||
<span className="rbc-toolbar-label">
|
||||
{label}
|
||||
{lunarLabel ? <span style={{ marginLeft: 8 }}>{lunarLabel}</span> : null}
|
||||
</span>
|
||||
<span className="rbc-btn-group">
|
||||
{views.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={`rbc-btn${name === view ? ' rbc-active' : ''}`}
|
||||
onClick={() => onView(name)}
|
||||
>
|
||||
{VIEW_LABELS[name] || name}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+32
@@ -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<CalendarViewContextValue | null>(null);
|
||||
CalendarViewContext.displayName = 'CalendarViewContext';
|
||||
|
||||
export const CalendarViewProvider = CalendarViewContext.Provider;
|
||||
|
||||
export const useCalendarViewContext = () => {
|
||||
return useContext(CalendarViewContext);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user