feat(calendar): support for add/remove repeats events (#988)

* feat(calendar): support for adding repeats

* feat: support delete events

* fix: has many same x-action

* feat: update better logic

* fix: exclude is not an array

* fix: handle parse cron error

* feat: support every_week, every_month, every_year
This commit is contained in:
Dunqing 2022-11-02 21:38:55 +08:00 committed by chenos
parent 30afeb0a99
commit 8f5a93bf63
13 changed files with 462 additions and 79 deletions

View File

@ -25,6 +25,7 @@
"antd": "~4.19.5",
"axios": "^0.26.1",
"classnames": "^2.3.1",
"cron-parser": "^4.6.0",
"cronstrue": "^2.11.0",
"file-saver": "^2.0.5",
"i18next": "^21.6.0",

View File

@ -53,6 +53,12 @@ export default {
"Form": "Form",
"Select data source": "Select data source",
"Calendar": "Calendar",
'Delete events': 'Delete events',
'This event': 'This event',
'This and following events': 'This and following events',
'All events': 'All events',
'Delete this event?': 'Delete this event?',
'Delete Event': 'Delete Event',
"Kanban": "Kanban",
"Select grouping field": "Select grouping field",
"Media": "Media",

View File

@ -54,6 +54,12 @@ export default {
"Form": "表单",
"Select data source": "选择数据源",
"Calendar": "日历",
'Delete events': '删除日程',
'This event': '此日程',
'This and following events': '此日程及后续日程',
'All events': '所有日程',
'Delete this event?': '是否删除这个日程?',
'Delete Event': '删除日程',
"Kanban": "看板",
"Select grouping field": "选择分组字段",
"Media": "多媒体",

View File

@ -7,6 +7,7 @@ import React, { useMemo, useState } from 'react';
import { Calendar as BigCalendar, momentLocalizer } from 'react-big-calendar';
import * as dates from 'react-big-calendar/lib/utils/dates';
import { useTranslation } from 'react-i18next';
import { parseExpression } from 'cron-parser';
import { RecordProvider } from '../../../';
import { i18n } from '../../../i18n';
import { useProps } from '../../hooks/useProps';
@ -16,6 +17,8 @@ import { CalendarToolbarContext } from './context';
import './style.less';
import type { ToolbarProps } from './types';
const Weeks = ['month', 'week', 'day'] as const;
const localizer = momentLocalizer(moment);
function Toolbar(props: ToolbarProps) {
@ -62,22 +65,104 @@ const messages: any = {
showMore: (count) => i18n.t('{{count}} more items', { count }),
};
const useEvents = (dataSource: any, fieldNames: any) => {
const useEvents = (dataSource: any, fieldNames: any, date: Date, view: typeof Weeks[number]) => {
const { t } = useTranslation();
return useMemo(
() =>
Array.isArray(dataSource)
? dataSource?.map((item) => {
return {
id: get(item, fieldNames.id || 'id'),
title: get(item, fieldNames.title) || t('Untitle'),
start: new Date(get(item, fieldNames.start)),
end: new Date(get(item, fieldNames.end || fieldNames.start)),
};
})
: [],
[dataSource, fieldNames],
);
return useMemo(() => {
if (!Array.isArray(dataSource)) return [];
const events = [];
dataSource.forEach((item) => {
const { cron, exclude = [] } = item;
const start = moment(get(item, fieldNames.start) || moment());
const end = moment(get(item, fieldNames.end) || start);
const intervalTime = end.diff(start, 'millisecond', true);
const dateM = moment(date);
const startDate = dateM.clone().startOf('month');
const endDate = startDate.clone().endOf('month');
if (view === 'month') {
startDate.startOf('week');
endDate.endOf('week');
}
const push = (fields?: Record<string, any>) => {
// 必须在这个月的开始时间和结束时间,切在日程的开始时间之后
const eventStart: moment.Moment = fields?.start || start;
if (eventStart.isBefore(start) || !eventStart.isBetween(startDate, endDate)) {
return;
}
const event = {
id: get(item, fieldNames.id || 'id'),
title: get(item, fieldNames.title) || t('Untitle'),
end: eventStart.add(intervalTime, 'millisecond'),
...fields,
start: eventStart,
};
let out = false;
const res = exclude?.some((d) => {
if (d.endsWith('_after')) {
d = d.replace(/_after$/, '');
out = true;
return event.start.isSameOrAfter(d);
} else {
return event.start.isSame(d);
}
});
if (res) return out;
events.push(event);
};
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({
start: nextStart.clone(),
})
) {
break;
}
nextStart.add(1, 'week');
}
} else if (cron === 'every_month') {
push({
start: start.clone().year(dateM.year()).month(dateM.month()),
});
} else if (cron === 'every_year') {
push({ start: start.clone().year(dateM.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({
start: moment(value.toDate()),
})
)
break;
}
} catch (err) {
console.error(err);
}
}
});
return events;
}, [dataSource, fieldNames, date, view]);
};
const CalendarRecordViewer = (props) => {
@ -107,21 +192,27 @@ const CalendarRecordViewer = (props) => {
export const Calendar: any = observer((props: any) => {
const { dataSource, fieldNames, showLunar } = useProps(props);
const events = useEvents(dataSource, fieldNames);
const [date, setDate] = useState<Date>(new Date());
const [view, setView] = useState<typeof Weeks[number]>('month');
const events = useEvents(dataSource, fieldNames, date, view);
const [visible, setVisible] = useState(false);
const [record, setRecord] = useState<any>({});
return (
<div {...props} style={{ height: 700 }}>
<div style={{ height: 700 }}>
<CalendarRecordViewer visible={visible} setVisible={setVisible} record={record} />
<BigCalendar
popup
selectable
events={events}
views={['month', 'week', 'day']}
view={view}
views={Weeks}
date={date}
step={60}
showMultiDayTimes
messages={messages}
onNavigate={setDate}
onView={setView}
onSelectSlot={(slotInfo) => {
console.log('onSelectSlot', slotInfo);
}}
@ -133,6 +224,7 @@ export const Calendar: any = observer((props: any) => {
if (!record) {
return;
}
record.__event = event;
setRecord(record);
setVisible(true);
}}
@ -147,7 +239,6 @@ export const Calendar: any = observer((props: any) => {
return `${local.format(start, 'Y-M', culture)} - ${local.format(end, 'Y-M', culture)}`;
},
}}
defaultDate={new Date()}
components={{
toolbar: (props) => <Toolbar {...props} showLunar={showLunar}></Toolbar>,
week: {

View File

@ -0,0 +1,66 @@
import { observer } from '@formily/react';
import { Modal, Radio, Space, Typography } from 'antd';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useBlockRequestContext, useFilterByTk } from '../../../block-provider';
import { useRecord } from '../../../record-provider';
import { useActionContext } from '../action';
const { Text } = Typography;
export const DeleteEvent = observer(() => {
const { visible, setVisible } = useActionContext();
const { exclude = [], cron, ...record } = useRecord();
const startDate = record.__parent.__event.start.format();
const filterByTk = useFilterByTk();
const { resource, service, __parent } = useBlockRequestContext();
const [value, onChange] = useState(startDate);
const [loading, setLoading] = useState(false);
const onOk = async () => {
setLoading(true);
if (value === 'all' || !cron) {
await resource.destroy({
filterByTk,
});
} else {
await resource.update({
filterByTk,
values: {
exclude: (exclude || []).concat(value),
},
});
}
setLoading(false);
__parent?.service?.refresh?.();
service?.refresh?.();
setVisible?.(false, true);
};
const { t } = useTranslation();
return createPortal(
<Modal
title={cron ? t('Delete events') : null}
visible={visible}
onCancel={() => setVisible(false)}
onOk={() => onOk()}
confirmLoading={loading}
>
{cron ? (
<Radio.Group value={value} onChange={(event) => onChange(event.target.value)}>
<Space direction="vertical">
<Radio value={startDate}>{t('This event')}</Radio>
<Radio value={`${startDate}_after`}>{t('This and following events')}</Radio>
<Radio value="all">{t('All events')}</Radio>
</Space>
</Radio.Group>
) : (
<Text strong style={{ fontSize: '18px' }}>
{t('Delete this event?')}
</Text>
)}
</Modal>,
document.body,
);
});
export default DeleteEvent;

View File

@ -1,3 +1,4 @@
import DeleteEvent from './DeleteEvent';
import { ActionBar } from '../action';
import { Calendar } from './Calendar';
import { CalendarDesigner } from './Calendar.Designer';
@ -10,6 +11,7 @@ import { ViewSelect } from './ViewSelect';
Calendar.ActionBar = ActionBar;
Calendar.Event = Event;
Calendar.DeleteEvent = DeleteEvent;
Calendar.Title = Title;
Calendar.Today = Today;
Calendar.Nav = Nav;

View File

@ -0,0 +1,160 @@
// 表单的操作配置
export const CalendarFormActionInitializers = {
title: '{{t("Configure actions")}}',
icon: 'SettingOutlined',
style: {
marginLeft: 8,
},
items: [
{
type: 'itemGroup',
title: '{{t("Enable actions")}}',
children: [
{
type: 'item',
title: '{{t("Edit")}}',
component: 'UpdateActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
type: 'primary',
},
},
},
{
type: 'item',
title: '{{t("Delete")}}',
component: 'DestroyActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
},
},
{
type: 'item',
title: '{{t("Delete Event")}}',
component: 'DeleteEventActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
},
},
{
type: 'item',
title: '{{t("Print")}}',
component: 'PrintActionInitializer',
schema: {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
},
},
],
},
{
type: 'divider',
},
{
type: 'subMenu',
title: '{{t("Customize")}}',
children: [
{
type: 'item',
title: '{{t("Popup")}}',
component: 'CustomizeActionInitializer',
schema: {
type: 'void',
title: '{{ t("Popup") }}',
'x-action': 'customize:popup',
'x-designer': 'Action.Designer',
'x-component': 'Action',
'x-component-props': {
openMode: 'drawer',
},
properties: {
drawer: {
type: 'void',
title: '{{ t("Popup") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'TabPaneInitializers',
properties: {
tab1: {
type: 'void',
title: '{{t("Details")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'RecordBlockInitializers',
properties: {},
},
},
},
},
},
},
},
},
},
},
{
type: 'item',
title: '{{t("Update record")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Update record") }}',
'x-component': 'Action',
'x-designer': 'Action.Designer',
'x-action': 'customize:update',
'x-action-settings': {
assignedValues: {},
onSuccess: {
manualClose: true,
redirecting: false,
successMessage: '{{t("Updated successfully")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeUpdateActionProps }}',
},
},
},
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
},
],
},
],
};

View File

@ -12,13 +12,13 @@ const recursiveParent = (schema: Schema) => {
} else {
return recursiveParent(schema.parent);
}
}
};
const useRelationFields = () => {
const fieldSchema = useFieldSchema();
const { getCollectionFields } = useCollectionManager();
let fields = [];
if (fieldSchema['x-initializer']) {
fields = useCollection().fields;
} else {
@ -52,7 +52,7 @@ const useRelationFields = () => {
// component: 'RecordAssociationFormBlockInitializer',
// },
],
}
};
}
if (['hasMany', 'belongsToMany'].includes(field.type)) {
@ -90,7 +90,7 @@ const useRelationFields = () => {
component: 'RecordAssociationCalendarBlockInitializer',
},
],
}
};
}
return {
@ -101,7 +101,7 @@ const useRelationFields = () => {
component: 'RecordAssociationBlockInitializer',
};
}) as any;
return relationFields;
return relationFields;
};
export const RecordBlockInitializers = (props: any) => {
@ -124,6 +124,7 @@ export const RecordBlockInitializers = (props: any) => {
type: 'item',
title: '{{t("Details")}}',
component: 'RecordReadPrettyFormBlockInitializer',
actionInitializers: 'CalendarFormActionInitializers',
},
{
key: 'form',

View File

@ -9,6 +9,7 @@ export * from './FormActionInitializers';
export * from './FormItemInitializers';
export * from './KanbanActionInitializers';
export * from './ReadPrettyFormActionInitializers';
export * from './CalendarFormActionInitializers';
export * from './ReadPrettyFormItemInitializers';
export * from './RecordBlockInitializers';
export * from './RecordFormBlockInitializers';
@ -18,4 +19,3 @@ export * from './TableActionInitializers';
export * from './TableColumnInitializers';
export * from './TableSelectorInitializers';
export * from './TabPaneInitializers';

View File

@ -0,0 +1,21 @@
import React from 'react';
import { ActionInitializer } from './ActionInitializer';
export const DeleteEventActionInitializer = (props) => {
const schema = {
title: '{{ t("Delete Event") }}',
'x-action': 'deleteEvent',
'x-component': 'Action',
'x-designer': 'Action.Designer',
'x-component-props': {
icon: 'DeleteOutlined',
},
properties: {
modal: {
'x-component': 'CalendarV2.DeleteEvent',
},
},
};
return <ActionInitializer {...props} schema={schema} />;
};

View File

@ -1,59 +1,60 @@
import React from "react";
import React from 'react';
import { FormOutlined } from '@ant-design/icons';
import { useBlockAssociationContext, useBlockRequestContext } from "../../block-provider";
import { useCollection } from "../../collection-manager";
import { useSchemaTemplateManager } from "../../schema-templates";
import { SchemaInitializer } from "../SchemaInitializer";
import { createReadPrettyFormBlockSchema, useRecordCollectionDataSourceItems } from "../utils";
import { useBlockAssociationContext, useBlockRequestContext } from '../../block-provider';
import { useCollection } from '../../collection-manager';
import { useSchemaTemplateManager } from '../../schema-templates';
import { SchemaInitializer } from '../SchemaInitializer';
import { createReadPrettyFormBlockSchema, useRecordCollectionDataSourceItems } from '../utils';
export const RecordReadPrettyFormBlockInitializer = (props) => {
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
const { onCreateBlockSchema, componentType, createBlockSchema, insert, ...others } = props;
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const collection = useCollection();
const association = useBlockAssociationContext();
const { block } = useBlockRequestContext();
const actionInitializers = block !== 'TableField' ? 'ReadPrettyFormActionInitializers' : null;
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const collection = useCollection();
const association = useBlockAssociationContext();
const { block } = useBlockRequestContext();
const actionInitializers =
block !== 'TableField' ? props.actionInitializers || 'ReadPrettyFormActionInitializers' : null;
return (
<SchemaInitializer.Item
icon={<FormOutlined />}
{...others}
key={'123'}
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
if (item.template.componentName === 'ReadPrettyFormItem') {
const blockSchema = createReadPrettyFormBlockSchema({
actionInitializers,
association,
collection: collection.name,
action: 'get',
useSourceId: '{{ useSourceIdFromParentRecord }}',
useParams: '{{ useParamsFromRecord }}',
template: s,
});
if (item.mode === 'reference') {
blockSchema['x-template-key'] = item.template.key;
}
insert(blockSchema);
} else {
insert(s);
return (
<SchemaInitializer.Item
icon={<FormOutlined />}
{...others}
key={'123'}
onClick={async ({ item }) => {
if (item.template) {
const s = await getTemplateSchemaByMode(item);
if (item.template.componentName === 'ReadPrettyFormItem') {
const blockSchema = createReadPrettyFormBlockSchema({
actionInitializers,
association,
collection: collection.name,
action: 'get',
useSourceId: '{{ useSourceIdFromParentRecord }}',
useParams: '{{ useParamsFromRecord }}',
template: s,
});
if (item.mode === 'reference') {
blockSchema['x-template-key'] = item.template.key;
}
insert(blockSchema);
} else {
insert(
createReadPrettyFormBlockSchema({
actionInitializers,
association,
collection: collection.name,
action: 'get',
useSourceId: '{{ useSourceIdFromParentRecord }}',
useParams: '{{ useParamsFromRecord }}',
}),
);
insert(s);
}
}}
items={useRecordCollectionDataSourceItems('ReadPrettyFormItem')}
/>
);
};
} else {
insert(
createReadPrettyFormBlockSchema({
actionInitializers,
association,
collection: collection.name,
action: 'get',
useSourceId: '{{ useSourceIdFromParentRecord }}',
useParams: '{{ useParamsFromRecord }}',
}),
);
}
}}
items={useRecordCollectionDataSourceItems('ReadPrettyFormItem')}
/>
);
};

View File

@ -12,6 +12,7 @@ export * from './CustomizeActionInitializer';
export * from './CustomizeBulkEditActionInitializer';
export * from './DataBlockInitializer';
export * from './DestroyActionInitializer';
export * from './DeleteEventActionInitializer';
export * from './DetailsBlockInitializer';
export * from './FilterActionInitializer';
export * from './FormBlockInitializer';
@ -36,4 +37,3 @@ export * from './TableSelectorInitializer';
export * from './UpdateActionInitializer';
export * from './UpdateSubmitActionInitializer';
export * from './ViewActionInitializer';

View File

@ -5352,7 +5352,14 @@
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@^16.9.8", "@types/react-dom@^17.0.0":
"@types/react-dom@^16.9.8":
version "16.9.17"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.17.tgz#29100cbcc422d7b7dba7de24bb906de56680dd34"
integrity sha512-qSRyxEsrm5btPXnowDOs5jSkgT8ldAA0j6Qp+otHUh+xHzy3sXmgNfyhucZjAjkgpdAUw9rJe0QRtX/l+yaS4g==
dependencies:
"@types/react" "^16"
"@types/react-dom@^17.0.0":
version "17.0.11"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466"
integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==
@ -5412,7 +5419,7 @@
"@types/history" "*"
"@types/react" "*"
"@types/react@*", "@types/react@>=16.9.11", "@types/react@^16.9.43", "@types/react@^17.0.0":
"@types/react@*", "@types/react@>=16.9.11", "@types/react@^17.0.0":
version "17.0.34"
resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==
@ -5421,6 +5428,15 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@^16", "@types/react@^16.9.43":
version "16.14.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.34.tgz#d129324ffda312044e1c47aab18696e4ed493282"
integrity sha512-b99nWeGGReLh6aKBppghVqp93dFJtgtDOzc8NXM6hewD8PQ2zZG5kBLgbx+VJr7Q7WBMjHxaIl3dwpwwPIUgyA==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@ -8718,6 +8734,13 @@ cron-parser@4.4.0:
dependencies:
luxon "^1.28.0"
cron-parser@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.6.0.tgz#404c3fdbff10ae80eef6b709555d577ef2fd2e0d"
integrity sha512-guZNLMGUgg6z4+eGhmHGw7ft+v6OQeuHzd1gcLxCo9Yg/qoxmG3nindp2/uwGCLizEisf2H0ptqeVXeoCpP6FA==
dependencies:
luxon "^3.0.1"
croner@~4.1.92:
version "4.1.97"
resolved "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766"
@ -14872,6 +14895,11 @@ luxon@^1.28.0:
resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf"
integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==
luxon@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.0.4.tgz#d179e4e9f05e092241e7044f64aaa54796b03929"
integrity sha512-aV48rGUwP/Vydn8HT+5cdr26YYQiUZ42NM6ToMoaGKwYfWbfLeRkEu1wXWMHBZT6+KyLfcbbtVcoQFCbbPjKlw==
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"