Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-04-17 08:00:49 +00:00
18 changed files with 2310 additions and 28 deletions
@@ -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"
---
# 日历区块
## 介绍
日历区块以直观的日历视图展示事件及日期相关数据,适用于会议安排、活动计划等典型场景。
![](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)
### 今天
点击 "今天" 按钮,可快速跳转回当前日期所在的日历视图。
### 切换页面
根据当前视图模式进行时间切换:
- 月视图:上一月 / 下一月
- 周视图:上一周 / 下一周
- 日视图:昨天 / 明天
### 选择视图
支持在以下视图间切换:
- 月视图(默认)
- 周视图
- 日视图
### 标题
用于显示当前视图对应的日期。
@@ -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.
![](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.
@@ -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,
});
@@ -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,
});
@@ -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();
},
});
@@ -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>,
),
);
});
@@ -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>
);
};
@@ -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>
);
};
@@ -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,
},
},
],
},
],
};
};