diff --git a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx index 4b7b7bc70..7eab313aa 100644 --- a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx +++ b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx @@ -5,17 +5,19 @@ import { CalendarBlockProvider, useCalendarBlockProps } from './CalendarBlockPro import { DetailsBlockProvider, useDetailsBlockProps } from './DetailsBlockProvider'; import { FilterFormBlockProvider } from './FilterFormBlockProvider'; import { FormBlockProvider, useFormBlockProps } from './FormBlockProvider'; -import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider'; import * as bp from './hooks'; import { KanbanBlockProvider, useKanbanBlockProps } from './KanbanBlockProvider'; import { TableBlockProvider, useTableBlockProps } from './TableBlockProvider'; import { TableFieldProvider, useTableFieldProps } from './TableFieldProvider'; import { TableSelectorProvider, useTableSelectorProps } from './TableSelectorProvider'; +import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider'; +import { GanttBlockProvider, useGanttBlockProps } from './GanttBlockProvider'; export const BlockSchemaComponentProvider: React.FC = (props) => { return ( { useTableBlockProps, useTableSelectorProps, useKanbanBlockProps, + useGanttBlockProps, }} > {props.children} diff --git a/packages/core/client/src/block-provider/GanttBlockProvider.tsx b/packages/core/client/src/block-provider/GanttBlockProvider.tsx new file mode 100644 index 000000000..7e886fe0d --- /dev/null +++ b/packages/core/client/src/block-provider/GanttBlockProvider.tsx @@ -0,0 +1,108 @@ +import { useField } from '@formily/react'; +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { BlockProvider, useBlockRequestContext } from './BlockProvider'; +import { TableBlockProvider } from './TableBlockProvider'; + +export const GanttBlockContext = createContext({}); + +const formatData = ( + data = [], + fieldNames, + tasks: any[] = [], + projectId: any = undefined, + hideChildren: boolean = false, +) => { + data.forEach((item: any) => { + if (item.children && item.children.length) { + tasks.push({ + start: new Date(item[fieldNames.start]), + end: new Date(item[fieldNames.end]), + name: item[fieldNames.title], + id: item.id + '', + type: 'project', + progress: item[fieldNames.progress] * 100 || 0, + hideChildren: hideChildren, + project: projectId, + color: item.color, + }); + formatData(item.children, fieldNames, tasks, item.id + '', hideChildren); + } else { + tasks.push({ + start: item[fieldNames.start] ? new Date(item[fieldNames.start]) : undefined, + end: new Date(item[fieldNames.end] || item[fieldNames.start]), + name: item[fieldNames.title], + id: item.id + '', + type: fieldNames.end ? 'task' : 'milestone', + progress: item[fieldNames.progress] * 100 || 0, + project: projectId, + color: item.color, + }); + } + }); + return tasks; +}; +const InternalGanttBlockProvider = (props) => { + const { fieldNames, timeRange, resource } = props; + const field = useField(); + const { service } = useBlockRequestContext(); + // if (service.loading) { + // return ; + // } + return ( + + {props.children} + + ); +}; + +export const GanttBlockProvider = (props) => { + return ( + + + + + + ); +}; + +export const useGanttBlockContext = () => { + return useContext(GanttBlockContext); +}; + +export const useGanttBlockProps = () => { + const ctx = useGanttBlockContext(); + const [tasks, setTasks] = useState([]); + const onExpanderClick = (task: any) => { + const data = ctx.field.data; + const tasksData = data.map((t: any) => (t.id === task.id ? task : t)); + setTasks(tasksData); + ctx.field.data = tasksData; + }; + const expandAndCollapseAll = (flag) => { + const data = formatData(ctx.service.data?.data, ctx.fieldNames, [], undefined, flag); + setTasks(data); + ctx.field.data = data; + }; + useEffect(() => { + if (!ctx?.service?.loading) { + const data = formatData(ctx.service.data?.data, ctx.fieldNames); + setTasks(data); + ctx.field.data = data; + } + }, [ctx?.service?.loading]); + return { + fieldNames: ctx.fieldNames, + timeRange: ctx.timeRange, + onExpanderClick, + expandAndCollapseAll, + tasks, + }; +}; diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index 1bdd0562a..b61c24717 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -21,7 +21,7 @@ interface Props { const InternalTableBlockProvider = (props: Props) => { const { params, showIndex, dragSort, rowKey, childrenColumnName } = props; - const field = useField(); + const field: any = useField(); const { resource, service } = useBlockRequestContext(); const [expandFlag, setExpandFlag] = useState(false); return ( @@ -66,6 +66,9 @@ export const useAssociationNames = (collection) => { if (schema['x-component'] === 'TableV2') { return schema; } + if (schema['x-component'] === 'Gantt') { + return schema.properties?.table; + } return buf; }, new Schema({})); return uniq( @@ -174,6 +177,7 @@ export const useTableBlockProps = () => { console.log(selectedRowKeys); ctx.field.data = ctx?.field?.data || {}; ctx.field.data.selectedRowKeys = selectedRowKeys; + ctx?.field?.onRowSelect?.(selectedRowKeys); }, async onRowDragEnd({ from, to }) { await ctx.resource.move({ @@ -244,5 +248,8 @@ export const useTableBlockProps = () => { // 更新表格的选中状态 setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value])); }, + onExpand(expanded, record) { + ctx?.field.onExpandClick?.(expanded, record); + }, }; }; diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index fe5b564a6..df3c74e4a 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -775,6 +775,8 @@ export const useRefreshActionProps = () => { }; }; + + export const useDetailsPaginationProps = () => { const ctx = useDetailsBlockContext(); const count = ctx.service?.data?.meta?.count || 0; diff --git a/packages/core/client/src/block-provider/index.tsx b/packages/core/client/src/block-provider/index.tsx index 98216bf30..3f401d863 100644 --- a/packages/core/client/src/block-provider/index.tsx +++ b/packages/core/client/src/block-provider/index.tsx @@ -3,10 +3,11 @@ export * from './BlockSchemaComponentProvider'; export * from './CalendarBlockProvider'; export * from './FilterFormBlockProvider'; export * from './FormBlockProvider'; -export * from './FormFieldProvider'; export * from './KanbanBlockProvider'; export * from './SharedFilterProvider'; export * from './TableBlockProvider'; export * from './TableFieldProvider'; export * from './TableSelectorProvider'; - +export * from './FormFieldProvider'; +export * from './GanttBlockProvider' +export * from './SharedFilterProvider'; diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 3fb6d7889..9b8b2fca7 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -112,6 +112,15 @@ export default { "Delete this event?": "Delete this event?", "Delete Event": "Delete Event", "Kanban": "Kanban", + "Gantt":"Gantt", + "Create gantt block":"Create gantt block", + "Progress field":"Progress field", + "Time scale":"Time scale", + "Hour":"Hour", + "Quarter of day":"Quarter of day", + "Half of day":"Half of day", + "Year":"Year", + "QuarterYear":"QuarterYear", "Select grouping field": "Select grouping field", "Media": "Media", "Markdown": "Markdown", diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index c1254ec21..986b371d7 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -109,6 +109,15 @@ export default { "Select data source": "データソースを選択", "Calendar": "カレンダー", "Kanban": "かんばん", + "Gantt":"ガント図", + "Create gantt block":"ガントチャートブロックの作成", + "Progress field":"進捗フィールド", + "Time scale":"時間スケールレベル", + "Hour":"時間", + "Quarter of day":"四分の一日", + "Half of day":"半日", + "Year":"年", + "QuarterYear":"四半期", "Select grouping field": "グループフィールドを選択してください", "Media": "メディア", "Markdown": "マークダウン", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 3df62a086..efc56d81c 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -125,6 +125,15 @@ export default { 'Delete this event?': '是否删除这个日程?', 'Delete Event': '删除日程', "Kanban": "看板", + "Gantt":"甘特图", + "Create gantt block":"创建甘特图区块", + "Progress field":"进度字段", + "Time scale":"时间缩放等级", + "Hour":"小时", + "Quarter of day":"四分之一天", + "Half of day":"半天", + "Year":"年", + "QuarterYear":"季度", "Select grouping field": "选择分组字段", "Media": "多媒体", "Markdown": "Markdown", diff --git a/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx new file mode 100644 index 000000000..98707f68b --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx @@ -0,0 +1,196 @@ +import { ISchema, useField, useFieldSchema } from '@formily/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCompile, useDesignable } from '../..'; +import { useGanttBlockContext } from '../../../block-provider'; +import { useCollection } from '../../../collection-manager'; +import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks'; +import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; +import { useSchemaTemplate } from '../../../schema-templates'; + +const useOptions = (type = 'string') => { + const compile = useCompile(); + const { fields } = useCollection(); + const options = fields + ?.filter((field) => field.type === type) + ?.map((field) => { + return { + value: field.name, + label: compile(field?.uiSchema?.title), + }; + }); + return options; +}; + +export const GanttDesigner = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { name, title, fields } = useCollection(); + const dataSource = useCollectionFilterOptions(name); + const { service } = useGanttBlockContext(); + const { dn } = useDesignable(); + const compile = useCompile(); + const { t } = useTranslation(); + const template = useSchemaTemplate(); + const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; + const fieldNames = fieldSchema?.['x-decorator-props']?.['fieldNames'] || {}; + const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + return ( + + + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['title'] = title; + field.decoratorProps.params = fieldNames; + fieldSchema['x-decorator-props']['params'] = fieldNames; + // Select切换option后value未按照预期切换,固增加以下代码 + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['range'] = range; + field.decoratorProps.params = fieldNames; + fieldSchema['x-decorator-props']['params'] = fieldNames; + // Select切换option后value未按照预期切换,固增加以下代码 + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['start'] = start; + field.decoratorProps.fieldNames = fieldNames; + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['end'] = end; + field.decoratorProps.fieldNames = fieldNames; + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['progress'] = progress; + field.decoratorProps.fieldNames = fieldNames; + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const params = field.decoratorProps.params || {}; + params.filter = filter; + field.decoratorProps.params = params; + fieldSchema['x-decorator-props']['params'] = params; + service.run({ ...service?.params?.[0], filter }); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + }} + /> + + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx new file mode 100644 index 000000000..e334c20d5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx @@ -0,0 +1,380 @@ +import React, { ReactChild } from 'react'; +import { cx } from '@emotion/css'; +import { DateSetup } from '../../types/date-setup'; +import { ViewMode } from '../../types/public-types'; +import { TopPartOfCalendar } from './top-part-of-calendar'; +import { + getCachedDateTimeFormat, + getDaysInMonth, + getLocalDayOfWeek, + getLocaleMonth, + getWeekNumberISO8601, +} from '../../helpers/date-helper'; +import { calendarBottomText, calendarHeader } from './style'; + +export type CalendarProps = { + dateSetup: DateSetup; + locale: string; + viewMode: ViewMode; + rtl: boolean; + headerHeight: number; + columnWidth: number; + fontFamily: string; + fontSize: string; +}; + +export const Calendar: React.FC = ({ + dateSetup, + locale, + viewMode, + rtl, + headerHeight, + columnWidth, + fontFamily, + fontSize, +}) => { + const getCalendarValuesForYear = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + const bottomValue = date.getFullYear(); + bottomValues.push( + + {bottomValue} + , + ); + if (i === 0 || date.getFullYear() !== dateSetup.dates[i - 1].getFullYear()) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getFullYear() + 1) * columnWidth; + } else { + xText = (6 + i - date.getFullYear()) * columnWidth; + } + topValues.push( + , + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForQuarterYear = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + // const bottomValue = getLocaleMonth(date, locale); + const quarter = 'Q' + Math.floor((date.getMonth() + 3) / 3); + bottomValues.push( + + {quarter} + , + ); + if (i === 0 || date.getFullYear() !== dateSetup.dates[i - 1].getFullYear()) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getMonth() + 1) * columnWidth; + } else { + xText = (6 + i - date.getMonth()) * columnWidth; + } + topValues.push( + , + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForMonth = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + const bottomValue = getLocaleMonth(date, locale); + bottomValues.push( + + {bottomValue} + , + ); + if (i === 0 || date.getFullYear() !== dateSetup.dates[i - 1].getFullYear()) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getMonth() + 1) * columnWidth; + } else { + xText = (6 + i - date.getMonth()) * columnWidth; + } + topValues.push( + , + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForWeek = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + let weeksCount: number = 1; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = dates.length - 1; i >= 0; i--) { + const date = dates[i]; + let topValue = ''; + if (i === 0 || date.getMonth() !== dates[i - 1].getMonth()) { + // top + topValue = `${getLocaleMonth(date, locale)}, ${date.getFullYear()}`; + } + // bottom + const bottomValue = `W${getWeekNumberISO8601(date)}`; + + bottomValues.push( + + {bottomValue} + , + ); + + if (topValue) { + // if last day is new month + if (i !== dates.length - 1) { + topValues.push( + , + ); + } + weeksCount = 0; + } + weeksCount++; + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForDay = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + // const bottomValue = `${getLocalDayOfWeek(date, locale, 'short')}, ${date.getDate().toString()}`; + const bottomValue = `${date.getDate().toString()}`; + + bottomValues.push( + + {bottomValue} + , + ); + if (i + 1 !== dates.length && date.getMonth() !== dates[i + 1].getMonth()) { + const topValue = getLocaleMonth(date, locale); + + topValues.push( + , + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForPartOfDay = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const ticks = viewMode === ViewMode.HalfDay ? 2 : 4; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const bottomValue = getCachedDateTimeFormat(locale, { + hour: 'numeric', + }) + //@ts-ignore + .format(date) + .replace('时', ''); + bottomValues.push( + + {bottomValue} + , + ); + if (i === 0 || date.getDate() !== dates[i - 1].getDate()) { + const topValue = `${getLocalDayOfWeek(date, locale, 'short')}, ${date.getDate()} ${getLocaleMonth( + date, + locale, + )}`; + topValues.push( + , + ); + } + } + + return [topValues, bottomValues]; + }; + + const getCalendarValuesForHour = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const bottomValue = getCachedDateTimeFormat(locale, { + hour: 'numeric', + }) + //@ts-ignore + .format(date) + ?.replace('时', ''); + bottomValues.push( + + {bottomValue} + , + ); + if (i !== 0 && date.getDate() !== dates[i - 1].getDate()) { + const displayDate = dates[i - 1]; + const topValue = `${getLocalDayOfWeek(displayDate, locale, 'long')}, ${displayDate.getDate()} ${getLocaleMonth( + displayDate, + locale, + )}`; + const topPosition = (date.getHours() - 24) / 2; + topValues.push( + , + ); + } + } + + return [topValues, bottomValues]; + }; + + let topValues: ReactChild[] = []; + let bottomValues: ReactChild[] = []; + switch (dateSetup.viewMode) { + case ViewMode.Year: + [topValues, bottomValues] = getCalendarValuesForYear(); + break; + case ViewMode.QuarterYear: + [topValues, bottomValues] = getCalendarValuesForQuarterYear(); + break; + case ViewMode.Month: + [topValues, bottomValues] = getCalendarValuesForMonth(); + break; + case ViewMode.Week: + [topValues, bottomValues] = getCalendarValuesForWeek(); + break; + case ViewMode.Day: + [topValues, bottomValues] = getCalendarValuesForDay(); + break; + case ViewMode.QuarterDay: + case ViewMode.HalfDay: + [topValues, bottomValues] = getCalendarValuesForPartOfDay(); + break; + case ViewMode.Hour: + [topValues, bottomValues] = getCalendarValuesForHour(); + } + return ( + + + {bottomValues} {topValues} + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/calendar/style.tsx new file mode 100644 index 000000000..6b9930d6a --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/style.tsx @@ -0,0 +1,38 @@ +import { css } from '@emotion/css'; + +export const calendarBottomText = css` + text-anchor: middle; + fill: rgba(0, 0, 0, 0.85); + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + font-weight: 500; +`; + +export const calendarTopTick = css` + stroke: #f0f0f0; + stroke-width: 0; +`; + +export const calendarTopText = css` + text-anchor: middle; + /* fill: #555; */ + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + font-weight: 500; +`; + +export const calendarHeader = css` + fill: #fafafa; + // stroke: #e0e0e0; + stroke-width: 1.4; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; +`; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx new file mode 100644 index 000000000..56237afe1 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { cx } from '@emotion/css'; +import { calendarTopTick, calendarTopText } from './style'; + + +type TopPartOfCalendarProps = { + value: string; + x1Line: number; + y1Line: number; + y2Line: number; + xText: number; + yText: number; +}; + +export const TopPartOfCalendar: React.FC = ({ + value, + x1Line, + y1Line, + y2Line, + xText, + yText, +}) => { + return ( + + + + {value} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/Event.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/Event.tsx new file mode 100644 index 000000000..73a9f26f6 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/Event.tsx @@ -0,0 +1,6 @@ +import { observer } from '@formily/react'; +import React from 'react'; + +export const Event = observer((props) => { + return <>{props.children}; +}); diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx new file mode 100644 index 000000000..b09256c09 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx @@ -0,0 +1,546 @@ +import { css, cx } from '@emotion/css'; +import { createForm } from '@formily/core'; +import { RecursionField, Schema, useFieldSchema } from '@formily/react'; +import { message } from 'antd'; +import React, { SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCurrentAppInfo } from '../../../../../appInfo'; +import { useBlockRequestContext, useGanttBlockContext, useTableBlockContext } from '../../../../../block-provider'; +import { RecordProvider } from '../../../../../record-provider'; +import { useDesignable } from '../../../../../schema-component'; +import { ActionContext } from '../../../action'; +import { convertToBarTasks } from '../../helpers/bar-helper'; +import { ganttDateRange, seedDates } from '../../helpers/date-helper'; +import { removeHiddenTasks, sortTasks } from '../../helpers/other-helper'; +import { BarTask } from '../../types/bar-task'; +import { DateSetup } from '../../types/date-setup'; +import { GanttEvent } from '../../types/gantt-task-actions'; +import { Task } from '../../types/public-types'; +import { CalendarProps } from '../calendar/calendar'; +import { GridProps } from '../grid/grid'; +import { HorizontalScroll } from '../other/horizontal-scroll'; +import { StandardTooltipContent, Tooltip } from '../other/tooltip'; +import { VerticalScroll } from '../other/vertical-scroll'; +import { wrapper } from './style'; +import { TaskGantt } from './task-gantt'; +import { TaskGanttContentProps } from './task-gantt-content'; + +const getColumnWidth = (dataSetLength: any, clientWidth: any) => { + const columnWidth = clientWidth / dataSetLength > 50 ? Math.floor(clientWidth / dataSetLength) + 20 : 50; + return columnWidth; +}; +export const DeleteEventContext = React.createContext({ + close: () => {}, +}); +const GanttRecordViewer = (props) => { + const { visible, setVisible, record } = props; + const form = useMemo(() => createForm(), [record]); + const fieldSchema = useFieldSchema(); + const eventSchema: Schema = fieldSchema.properties.detail; + const close = useCallback(() => { + setVisible(false); + }, []); + + return ( + eventSchema && ( + + + + + + + + ) + ); +}; +export const Gantt: any = (props: any) => { + const { designable } = useDesignable(); + const currentTheme = localStorage.getItem('NOCOBASE_THEME'); + const { + headerHeight = currentTheme === 'compact' ? (designable ? 53 : 45) : designable ? 65 : 55, + listCellWidth = '155px', + rowHeight = currentTheme === 'compact' ? 45 : 55.56, + ganttHeight = 0, + preStepsCount = 1, + barFill = 60, + barCornerRadius = 2, + barProgressColor = '#1890ff', + barProgressSelectedColor = '#1890ff', + barBackgroundColor = '#1890ff', + barBackgroundSelectedColor = '#1890ff', + projectProgressColor = '#1890ff', + projectProgressSelectedColor = '#1890ff', + projectBackgroundColor = '#1890ff', + projectBackgroundSelectedColor = '#1890ff', + milestoneBackgroundColor = '#f1c453', + milestoneBackgroundSelectedColor = '#f29e4c', + rtl = false, + handleWidth = 8, + timeStep = 300000, + arrowColor = 'grey', + fontFamily = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`, + fontSize = currentTheme === 'compact' ? '12px' : '14px', + arrowIndent = 20, + todayColor = 'rgba(252, 248, 227, 0.5)', + viewDate, + TooltipContent = StandardTooltipContent, + onDoubleClick, + onClick, + onDelete, + onSelect, + useProps, + } = props; + const { onExpanderClick, tasks, expandAndCollapseAll } = useProps(); + const ctx = useGanttBlockContext(); + const appInfo = useCurrentAppInfo(); + const { t } = useTranslation(); + const locale = appInfo.data?.lang; + const tableCtx = useTableBlockContext(); + const { resource, service } = useBlockRequestContext(); + const fieldSchema = useFieldSchema(); + const { fieldNames } = useProps(props); + const viewMode = fieldNames.range || 'day'; + const wrapperRef = useRef(null); + const taskListRef = useRef(null); + const verticalGanttContainerRef = useRef(null); + const [dateSetup, setDateSetup] = useState(() => { + const [startDate, endDate] = ganttDateRange(tasks, viewMode, preStepsCount); + return { viewMode, dates: seedDates(startDate, endDate, viewMode) }; + }); + const [visible, setVisible] = useState(false); + const [record, setRecord] = useState({}); + const [currentViewDate, setCurrentViewDate] = useState(undefined); + const [taskListWidth, setTaskListWidth] = useState(0); + const [svgContainerWidth, setSvgContainerWidth] = useState(0); + const [svgContainerHeight, setSvgContainerHeight] = useState(ganttHeight); + const [barTasks, setBarTasks] = useState([]); + const [ganttEvent, setGanttEvent] = useState({ + action: '', + }); + const taskHeight = useMemo(() => (rowHeight * barFill) / 100, [rowHeight, barFill]); + const [selectedTask, setSelectedTask] = useState(); + const [failedTask, setFailedTask] = useState(null); + const [scrollY, setScrollY] = useState(0); + const [scrollX, setScrollX] = useState(-1); + const [ignoreScrollEvent, setIgnoreScrollEvent] = useState(false); + const columnWidth: number = getColumnWidth(dateSetup.dates.length, verticalGanttContainerRef.current?.clientWidth); + const svgWidth = dateSetup.dates.length * columnWidth; + const ganttFullHeight = barTasks.length * rowHeight; + const { expandFlag } = tableCtx; + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + useEffect(() => { + tableCtx.field.onExpandClick = handleTableExpanderClick; + tableCtx.field.onRowSelect = handleRowSelect; + tableCtx.setExpandFlag(true); + }, []); + useEffect(() => { + expandAndCollapseAll?.(!expandFlag); + }, [expandFlag]); + // task change events + useEffect(() => { + let filteredTasks: Task[]; + if (onExpanderClick) { + filteredTasks = removeHiddenTasks(tasks); + } else { + filteredTasks = tasks; + } + filteredTasks = filteredTasks.sort(sortTasks); + const [startDate, endDate] = ganttDateRange(filteredTasks, viewMode, preStepsCount); + let newDates = seedDates(startDate, endDate, viewMode); + if (rtl) { + newDates = newDates.reverse(); + if (scrollX === -1) { + setScrollX(newDates.length * columnWidth); + } + } + setDateSetup({ dates: newDates, viewMode }); + setBarTasks( + convertToBarTasks( + filteredTasks, + newDates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor, + ), + ); + }, [ + tasks, + viewMode, + preStepsCount, + rowHeight, + barCornerRadius, + columnWidth, + taskHeight, + handleWidth, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor, + rtl, + scrollX, + ]); + + useEffect(() => { + if ( + viewMode === dateSetup.viewMode && + ((viewDate && !currentViewDate) || (viewDate && currentViewDate?.valueOf() !== viewDate.valueOf())) + ) { + const dates = dateSetup.dates; + const index = dates.findIndex( + (d, i) => + viewDate.valueOf() >= d.valueOf() && i + 1 !== dates.length && viewDate.valueOf() < dates[i + 1].valueOf(), + ); + if (index === -1) { + return; + } + setCurrentViewDate(viewDate); + setScrollX(columnWidth * index); + } + }, [viewDate, columnWidth, dateSetup.dates, dateSetup.viewMode, viewMode, currentViewDate, setCurrentViewDate]); + + useEffect(() => { + const { changedTask, action } = ganttEvent; + if (changedTask) { + if (action === 'delete') { + setGanttEvent({ action: '' }); + setBarTasks(barTasks.filter((t) => t.id !== changedTask.id)); + } else if (action === 'move' || action === 'end' || action === 'start' || action === 'progress') { + const prevStateTask = barTasks.find((t) => t.id === changedTask.id); + if ( + prevStateTask && + (prevStateTask.start.getTime() !== changedTask.start.getTime() || + prevStateTask.end.getTime() !== changedTask.end.getTime() || + prevStateTask.progress !== changedTask.progress) + ) { + // actions for change + const newTaskList = barTasks.map((t) => (t.id === changedTask.id ? changedTask : t)); + setBarTasks(newTaskList); + } + } + } + }, [ganttEvent, barTasks]); + + useEffect(() => { + if (failedTask) { + setBarTasks(barTasks.map((t) => (t.id !== failedTask.id ? t : failedTask))); + setFailedTask(null); + } + }, [failedTask, barTasks]); + + useEffect(() => { + if (!listCellWidth) { + setTaskListWidth(0); + } + if (taskListRef.current) { + setTaskListWidth(taskListRef.current.offsetWidth); + } + }, [taskListRef, listCellWidth]); + + useEffect(() => { + if (wrapperRef.current) { + setSvgContainerWidth(wrapperRef.current.offsetWidth - taskListWidth); + } + }, [wrapperRef, taskListWidth]); + + useEffect(() => { + if (ganttHeight) { + setSvgContainerHeight(ganttHeight + headerHeight); + } else { + setSvgContainerHeight(tasks.length * rowHeight + headerHeight); + } + }, [ganttHeight, tasks, headerHeight, rowHeight]); + + // scroll events + useEffect(() => { + const handleWheel = (event: WheelEvent) => { + if (event.shiftKey || event.deltaX) { + const scrollMove = event.deltaX ? event.deltaX : event.deltaY; + let newScrollX = scrollX + scrollMove; + if (newScrollX < 0) { + newScrollX = 0; + } else if (newScrollX > svgWidth) { + newScrollX = svgWidth; + } + setScrollX(newScrollX); + event.preventDefault(); + } else if (ganttHeight) { + let newScrollY = scrollY + event.deltaY; + if (newScrollY < 0) { + newScrollY = 0; + } else if (newScrollY > ganttFullHeight - ganttHeight) { + newScrollY = ganttFullHeight - ganttHeight; + } + if (newScrollY !== scrollY) { + setScrollY(newScrollY); + event.preventDefault(); + } + } + + setIgnoreScrollEvent(true); + }; + + // subscribe if scroll is necessary + wrapperRef.current?.addEventListener('wheel', handleWheel, { + passive: false, + }); + return () => { + wrapperRef.current?.removeEventListener('wheel', handleWheel); + }; + }, [wrapperRef, scrollY, scrollX, ganttHeight, svgWidth, rtl, ganttFullHeight]); + + const handleScrollY = (event: SyntheticEvent) => { + if (scrollY !== event.currentTarget.scrollTop && !ignoreScrollEvent) { + setScrollY(event.currentTarget.scrollTop); + setIgnoreScrollEvent(true); + } else { + setIgnoreScrollEvent(false); + } + }; + + const handleScrollX = (event: SyntheticEvent) => { + if (scrollX !== event.currentTarget.scrollLeft && !ignoreScrollEvent) { + setScrollX(event.currentTarget.scrollLeft); + setIgnoreScrollEvent(true); + } else { + setIgnoreScrollEvent(false); + } + }; + + /** + * Handles arrow keys events and transform it to new scroll + */ + const handleKeyDown = (event: React.KeyboardEvent) => { + event.preventDefault(); + let newScrollY = scrollY; + let newScrollX = scrollX; + let isX = true; + switch (event.key) { + case 'Down': // IE/Edge specific value + case 'ArrowDown': + newScrollY += rowHeight; + isX = false; + break; + case 'Up': // IE/Edge specific value + case 'ArrowUp': + newScrollY -= rowHeight; + isX = false; + break; + case 'Left': + case 'ArrowLeft': + newScrollX -= columnWidth; + break; + case 'Right': // IE/Edge specific value + case 'ArrowRight': + newScrollX += columnWidth; + break; + } + if (isX) { + if (newScrollX < 0) { + newScrollX = 0; + } else if (newScrollX > svgWidth) { + newScrollX = svgWidth; + } + setScrollX(newScrollX); + } else { + if (newScrollY < 0) { + newScrollY = 0; + } else if (newScrollY > ganttFullHeight - ganttHeight) { + newScrollY = ganttFullHeight - ganttHeight; + } + setScrollY(newScrollY); + } + setIgnoreScrollEvent(true); + }; + + /** + * Task select event + */ + const handleSelectedTask = (taskId: string) => { + const newSelectedTask = barTasks.find((t) => t.id === taskId); + const oldSelectedTask = barTasks.find((t) => !!selectedTask && t.id === selectedTask.id); + if (onSelect) { + if (oldSelectedTask) { + onSelect(oldSelectedTask, false); + } + if (newSelectedTask) { + onSelect(newSelectedTask, true); + } + } + setSelectedTask(newSelectedTask); + }; + const handleTableExpanderClick = (expanded: boolean, record: any) => { + const task = ctx?.field?.data.find((v: any) => v.id === record.id + ''); + if (onExpanderClick && record.children.length) { + onExpanderClick({ ...task, hideChildren: !expanded }); + } + }; + + const handleRowSelect = (keys) => { + setSelectedRowKeys(keys); + }; + const handleProgressChange = async (task: Task) => { + await resource.update({ + filterByTk: task.id, + values: { + ...task, + [fieldNames.progress]: task.progress / 100, + }, + }); + message.success(t('Saved successfully')); + await service?.refresh(); + }; + const handleTaskChange = async (task: Task) => { + await resource.update({ + filterByTk: task.id, + values: { + ...task, + [fieldNames.start]: task.start, + [fieldNames.end]: task.end, + }, + }); + message.success(t('Saved successfully')); + await service?.refresh(); + }; + const handleBarClick = (data) => { + const flattenTree = (treeData) => { + return treeData.reduce((acc, node) => { + if (node.children) { + return acc.concat([node, ...flattenTree(node.children)]); + } else { + return acc.concat(node); + } + }, []); + }; + const flattenedData = flattenTree(service?.data?.data); + const recordData = flattenedData?.find((item) => item.id === +data.id); + if (!recordData) { + return; + } + setRecord(recordData); + setVisible(true); + }; + const gridProps: GridProps = { + columnWidth, + svgWidth, + tasks: tasks, + rowHeight, + dates: dateSetup.dates, + todayColor, + rtl, + selectedRowKeys, + }; + const calendarProps: CalendarProps = { + dateSetup, + locale, + viewMode, + headerHeight, + columnWidth, + fontFamily, + fontSize, + rtl, + }; + const barProps: TaskGanttContentProps = { + tasks: barTasks, + dates: dateSetup.dates, + ganttEvent, + selectedTask, + rowHeight, + taskHeight, + columnWidth, + arrowColor, + timeStep, + fontFamily, + fontSize, + arrowIndent, + svgWidth, + rtl, + setGanttEvent, + setFailedTask, + setSelectedTask: handleSelectedTask, + onDateChange: handleTaskChange, + onProgressChange: fieldNames.progress && handleProgressChange, + onDoubleClick, + onClick: handleBarClick, + onDelete, + }; + return ( +
+
+ + + +
+ + + {ganttEvent.changedTask && ( + + )} + +
+
+ +
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/style.tsx new file mode 100644 index 000000000..c5a068e09 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/style.tsx @@ -0,0 +1,26 @@ + +import { css } from '@emotion/css'; + +export const ganttVerticalContainer=css ` + overflow: hidden; + font-size: 0; + margin: 0; + padding: 0; + width:100%; + border-left:2px solid #f4f2f2 +` + +export const horizontalContainer=css ` + margin: 0; + padding: 0; + overflow: hidden; +` + +export const wrapper =css` + display: flex; + padding: 0; + margin: 0; + list-style: none; + outline: none; + position: relative; +` diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx new file mode 100644 index 000000000..4e5b56e59 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState } from 'react'; +import { EventOption } from '../../types/public-types'; +import { BarTask } from '../../types/bar-task'; +import { Arrow } from '../other/arrow'; +import { handleTaskBySVGMouseEvent } from '../../helpers/bar-helper'; +import { isKeyboardEvent } from '../../helpers/other-helper'; +import { TaskItem } from '../task-item/task-item'; +import { BarMoveAction, GanttContentMoveAction, GanttEvent } from '../../types/gantt-task-actions'; + +let lastAction = null; +let lastStart = null; +export type TaskGanttContentProps = { + tasks: BarTask[]; + dates: Date[]; + ganttEvent: GanttEvent; + selectedTask: BarTask | undefined; + rowHeight: number; + columnWidth: number; + timeStep: number; + svg?: React.RefObject; + svgWidth: number; + taskHeight: number; + arrowColor: string; + arrowIndent: number; + fontSize: string; + fontFamily: string; + rtl: boolean; + setGanttEvent: (value: GanttEvent) => void; + setFailedTask: (value: BarTask | null) => void; + setSelectedTask: (taskId: string) => void; +} & EventOption; + +export const TaskGanttContent: React.FC = ({ + tasks, + dates, + ganttEvent, + selectedTask, + rowHeight, + columnWidth, + timeStep, + svg, + taskHeight, + arrowColor, + arrowIndent, + fontFamily, + fontSize, + rtl, + setGanttEvent, + setFailedTask, + setSelectedTask, + onDateChange, + onProgressChange, + onDoubleClick, + onClick, + onDelete, +}) => { + const point = svg?.current?.createSVGPoint(); + const [xStep, setXStep] = useState(0); + const [initEventX1Delta, setInitEventX1Delta] = useState(0); + const [isMoving, setIsMoving] = useState(false); + + // create xStep + useEffect(() => { + const dateDelta = + dates[1]?.getTime() - + dates[0]?.getTime() - + dates[1]?.getTimezoneOffset() * 60 * 1000 + + dates[0]?.getTimezoneOffset() * 60 * 1000; + const newXStep = (timeStep * columnWidth) / dateDelta; + setXStep(newXStep); + }, [columnWidth, dates, timeStep]); + + useEffect(() => { + const handleMouseMove = async (event: MouseEvent) => { + if (!ganttEvent.changedTask || !point || !svg?.current) return; + event.preventDefault(); + + point.x = event.clientX; + const cursor = point.matrixTransform(svg?.current.getScreenCTM()?.inverse()); + + const { isChanged, changedTask } = handleTaskBySVGMouseEvent( + cursor.x, + ganttEvent.action as BarMoveAction, + ganttEvent.changedTask, + xStep, + timeStep, + initEventX1Delta, + rtl, + ); + if (isChanged) { + setGanttEvent({ action: ganttEvent.action, changedTask }); + } + }; + + const handleMouseUp = async (event: MouseEvent) => { + const { action, originalSelectedTask, changedTask } = ganttEvent; + if (!changedTask || !point || !svg?.current || !originalSelectedTask) return; + event.preventDefault(); + + point.x = event.clientX; + const cursor = point.matrixTransform(svg?.current.getScreenCTM()?.inverse()); + const { changedTask: newChangedTask } = handleTaskBySVGMouseEvent( + cursor.x, + action as BarMoveAction, + changedTask, + xStep, + timeStep, + initEventX1Delta, + rtl, + ); + + const isNotLikeOriginal = + originalSelectedTask.start !== newChangedTask.start || + originalSelectedTask.end !== newChangedTask.end || + originalSelectedTask.progress !== newChangedTask.progress; + + // remove listeners + svg.current.removeEventListener('mousemove', handleMouseMove); + svg.current.removeEventListener('mouseup', handleMouseUp); + setGanttEvent({ action: '' }); + setIsMoving(false); + + // custom operation start + let operationSuccess: any = true; + if ((action === 'move' || action === 'end' || action === 'start') && onDateChange && isNotLikeOriginal) { + try { + const result = await onDateChange(newChangedTask, newChangedTask.barChildren); + if (result !== undefined) { + operationSuccess = result; + } + } catch (error) { + operationSuccess = false; + } + } else if (onProgressChange && isNotLikeOriginal) { + try { + const result = await onProgressChange(newChangedTask, newChangedTask.barChildren); + if (result !== undefined) { + operationSuccess = result; + } + } catch (error) { + operationSuccess = false; + } + } + + // If operation is failed - return old state + if (!operationSuccess) { + setFailedTask(originalSelectedTask); + } + }; + + if ( + !isMoving && + (ganttEvent.action === 'move' || + ganttEvent.action === 'end' || + ganttEvent.action === 'start' || + ganttEvent.action === 'progress') && + svg?.current + ) { + svg.current.addEventListener('mousemove', handleMouseMove); + svg.current.addEventListener('mouseup', handleMouseUp); + setIsMoving(true); + } + }, [ + ganttEvent, + xStep, + initEventX1Delta, + onProgressChange, + timeStep, + onDateChange, + svg, + isMoving, + point, + rtl, + setFailedTask, + setGanttEvent, + ]); + + /** + * Method is Start point of task change + */ + const handleBarEventStart = async ( + action: GanttContentMoveAction, + task: BarTask, + event?: React.MouseEvent | React.KeyboardEvent, + ) => { + if (!event) { + if (action === 'select') { + setSelectedTask(task.id); + } + } + // Keyboard events + else if (isKeyboardEvent(event)) { + if (action === 'delete') { + if (onDelete) { + try { + const result = await onDelete(task); + if (result !== undefined && result) { + setGanttEvent({ action, changedTask: task }); + } + } catch (error) { + console.error('Error on Delete. ' + error); + } + } + } + } + // Mouse Events + else if (action === 'mouseenter') { + if (!ganttEvent.action) { + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } + } else if (action === 'mouseleave') { + if (ganttEvent.action === 'mouseenter') { + setGanttEvent({ action: '' }); + } + } else if (action === 'dblclick') { + !!onDoubleClick && onDoubleClick(task); + } else if (action === 'click') { + !!onClick && onClick(task); + } + // Change task event start + else if (action === 'move') { + if (!svg?.current || !point) return; + point.x = event.clientX; + const cursor = point.matrixTransform(svg.current.getScreenCTM()?.inverse()); + setInitEventX1Delta(cursor.x - task.x1); + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } else { + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } + }; + + const handleBarEvent = (action, task, event) => { + if (['click'].includes(action)) { + if (!['start', 'end', 'progress'].includes(lastAction) && (!lastStart || lastStart === task.start)) { + handleBarEventStart(action, task, event); + } + lastAction = null; + lastStart = null; + } else if (['move', 'select'].includes(action)) { + lastStart = task.start; + handleBarEventStart(action, task, event); + } else { + lastStart = task.start; + lastAction = action; + handleBarEventStart(action, task, event); + } + }; + return ( + + + {tasks.map((task) => { + return task.barChildren.map((child) => { + return ( + + ); + }); + })} + + + {tasks.map((task) => { + return ( + + ); + })} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx new file mode 100644 index 000000000..1128e5327 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx @@ -0,0 +1,66 @@ +import React, { useRef, useEffect, forwardRef } from 'react'; +import { cx } from '@emotion/css'; +import { GridProps, Grid } from '../grid/grid'; +import { CalendarProps, Calendar } from '../calendar/calendar'; +import { TaskGanttContentProps, TaskGanttContent } from './task-gantt-content'; +import { ganttVerticalContainer, horizontalContainer } from './style'; + +export type TaskGanttProps = { + gridProps: GridProps; + calendarProps: CalendarProps; + barProps: TaskGanttContentProps; + ganttHeight: number; + scrollY: number; + scrollX: number; + ref: any; +}; +export const TaskGantt: React.FC = forwardRef( + ({ gridProps, calendarProps, barProps, ganttHeight, scrollY, scrollX }, ref: any) => { + const ganttSVGRef = useRef(null); + const horizontalContainerRef = useRef(null); + const newBarProps = { ...barProps, svg: ganttSVGRef }; + + useEffect(() => { + if (horizontalContainerRef.current) { + horizontalContainerRef.current.scrollTop = scrollY; + } + }, [scrollY]); + + useEffect(() => { + if (ref.current) { + ref.current.scrollLeft = scrollX; + } + }, [scrollX]); + + return ( +
+ + + +
+ + + + +
+
+ ); + }, +); diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx new file mode 100644 index 000000000..60d149474 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx @@ -0,0 +1,90 @@ +import React, { ReactChild } from 'react'; +import { cx } from '@emotion/css'; +import { Task } from '../../types/public-types'; +import { addToDate } from '../../helpers/date-helper'; +import { gridRowLine, gridRow, gridTick, gridHeightRow } from './style'; +import { uid } from '@nocobase/utils/client'; + +export type GridBodyProps = { + tasks: Task[]; + dates: Date[]; + svgWidth: number; + rowHeight: number; + columnWidth: number; + todayColor: string; + rtl: boolean; + selectedRowKeys: any[]; +}; +const empty = [{ id: uid() }, { id: uid() }, { id: uid() }]; +export const GridBody: React.FC = ({ + tasks, + dates, + rowHeight, + svgWidth, + columnWidth, + todayColor, + rtl, + selectedRowKeys, +}) => { + const data = tasks.length ? tasks : empty; + let y = 0; + const gridRows: ReactChild[] = []; + const rowLines: ReactChild[] = [ + , + ]; + for (const task of data) { + gridRows.push( + , + ); + rowLines.push( + , + ); + y += rowHeight; + } + + const now = new Date(); + let tickX = 0; + const ticks: ReactChild[] = []; + let today: ReactChild = ; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + ticks.push(); + if ( + (i + 1 !== dates.length && date.getTime() < now.getTime() && dates[i + 1].getTime() >= now.getTime()) || + // if current date is last + (i !== 0 && + i + 1 === dates.length && + date.getTime() < now.getTime() && + addToDate(date, date.getTime() - dates[i - 1].getTime(), 'millisecond').getTime() >= now.getTime()) + ) { + today = ; + } + // rtl for today + if (rtl && i + 1 !== dates.length && date.getTime() >= now.getTime() && dates[i + 1].getTime() < now.getTime()) { + today = ; + } + tickX += columnWidth; + } + return ( + + {gridRows} + {rowLines} + {ticks} + {today} + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx new file mode 100644 index 000000000..488cfa3fe --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { GridBody, GridBodyProps } from "./grid-body"; + +export type GridProps = GridBodyProps; +export const Grid: React.FC = props => { + return ( + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/grid/style.tsx new file mode 100644 index 000000000..908cdf291 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/style.tsx @@ -0,0 +1,19 @@ +import { css } from '@emotion/css'; +export const gridRow = css` + fill: #fff; +`; + +export const gridHeightRow = css` + fill: #e6f7ff; + border-color: rgba(0, 0, 0, 0.03); +`; + +export const gridRowLine = css` + stroke: #f0f0f0; + stroke-width:0; + border-bottom: 1px solid #f0f0f0; +`; + +export const gridTick = css` + stroke: #f0f0f0; +`; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx new file mode 100644 index 000000000..52e8f28b4 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { BarTask } from "../../types/bar-task"; + +type ArrowProps = { + taskFrom: BarTask; + taskTo: BarTask; + rowHeight: number; + taskHeight: number; + arrowIndent: number; + rtl: boolean; +}; +export const Arrow: React.FC = ({ + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent, + rtl, +}) => { + let path: string; + let trianglePoints: string; + if (rtl) { + [path, trianglePoints] = drownPathAndTriangleRTL( + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent + ); + } else { + [path, trianglePoints] = drownPathAndTriangle( + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent + ); + } + + return ( + + + + + ); +}; + +const drownPathAndTriangle = ( + taskFrom: BarTask, + taskTo: BarTask, + rowHeight: number, + taskHeight: number, + arrowIndent: number +) => { + const indexCompare = taskFrom.index > taskTo.index ? -1 : 1; + const taskToEndPosition = taskTo.y + taskHeight / 2; + const taskFromEndPosition = taskFrom.x2 + arrowIndent * 2; + const taskFromHorizontalOffsetValue = + taskFromEndPosition < taskTo.x1 ? "" : `H ${taskTo.x1 - arrowIndent}`; + const taskToHorizontalOffsetValue = + taskFromEndPosition > taskTo.x1 + ? arrowIndent + : taskTo.x1 - taskFrom.x2 - arrowIndent; + + const path = `M ${taskFrom.x2} ${taskFrom.y + taskHeight / 2} + h ${arrowIndent} + v ${(indexCompare * rowHeight) / 2} + ${taskFromHorizontalOffsetValue} + V ${taskToEndPosition} + h ${taskToHorizontalOffsetValue}`; + + const trianglePoints = `${taskTo.x1},${taskToEndPosition} + ${taskTo.x1 - 5},${taskToEndPosition - 5} + ${taskTo.x1 - 5},${taskToEndPosition + 5}`; + return [path, trianglePoints]; +}; + +const drownPathAndTriangleRTL = ( + taskFrom: BarTask, + taskTo: BarTask, + rowHeight: number, + taskHeight: number, + arrowIndent: number +) => { + const indexCompare = taskFrom.index > taskTo.index ? -1 : 1; + const taskToEndPosition = taskTo.y + taskHeight / 2; + const taskFromEndPosition = taskFrom.x1 - arrowIndent * 2; + const taskFromHorizontalOffsetValue = + taskFromEndPosition > taskTo.x2 ? "" : `H ${taskTo.x2 + arrowIndent}`; + const taskToHorizontalOffsetValue = + taskFromEndPosition < taskTo.x2 + ? -arrowIndent + : taskTo.x2 - taskFrom.x1 + arrowIndent; + + const path = `M ${taskFrom.x1} ${taskFrom.y + taskHeight / 2} + h ${-arrowIndent} + v ${(indexCompare * rowHeight) / 2} + ${taskFromHorizontalOffsetValue} + V ${taskToEndPosition} + h ${taskToHorizontalOffsetValue}`; + + const trianglePoints = `${taskTo.x2},${taskToEndPosition} + ${taskTo.x2 + 5},${taskToEndPosition + 5} + ${taskTo.x2 + 5},${taskToEndPosition - 5}`; + return [path, trianglePoints]; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx new file mode 100644 index 000000000..286ae8ea1 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx @@ -0,0 +1,35 @@ +import React, { SyntheticEvent, useRef, useEffect } from "react"; +import { cx } from '@emotion/css'; +import {scrollWrapper,horizontalScroll} from './style' + +export const HorizontalScroll: React.FC<{ + scroll: number; + svgWidth: number; + taskListWidth: number; + rtl: boolean; + onScroll: (event: SyntheticEvent) => void; +}> = ({ scroll, svgWidth, taskListWidth, rtl, onScroll }) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollLeft = scroll; + } + }, [scroll]); + + return ( +
+
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/style.tsx new file mode 100644 index 000000000..7700861bd --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/style.tsx @@ -0,0 +1,101 @@ +import { css } from '@emotion/css'; + +export const scrollWrapper = css` + overflow: auto; + position: relative; + top: -14px; + max-width: 100%; + /*firefox*/ + scrollbar-width: thin; + /*iPad*/ + height: 1.2rem; + &::-webkit-scrollbar { + width: 1.1rem; + height: 1.1rem; + } + &::-webkit-scrollbar-corner { + background: transparent; + } + &::-webkit-scrollbar-thumb { + border: 6px solid transparent; + background: rgba(0, 0, 0, 0.2); + background: var(--palette-black-alpha-20, rgba(0, 0, 0, 0.2)); + border-radius: 10px; + background-clip: padding-box; + } + &::-webkit-scrollbar-thumb:hover { + border: 4px solid transparent; + background: rgba(0, 0, 0, 0.3); + background: var(--palette-black-alpha-30, rgba(0, 0, 0, 0.3)); + background-clip: padding-box; + } +`; + +export const horizontalScroll = css` + height: 1px; +`; + +export const tooltipDefaultContainer = css` + padding: 12px; + background-color: #fff; + background-clip: padding-box; + border-radius: 2px; + box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); + b { + display: block; + margin-bottom: 8px; + } +`; + +export const tooltipDefaultContainerParagraph = css` + font-size: 12px; + margin-bottom: 6px; + color: #666; +`; + +export const tooltipDetailsContainer = css` + position: absolute; + display: flex; + flex-shrink: 0; + pointer-events: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +`; + +export const tooltipDetailsContainerHidden = css` + visibility: hidden; + position: absolute; + display: flex; + pointer-events: none; +`; + +export const verticalScroll = css` + overflow: hidden auto; + width: 1rem; + flex-shrink: 0; + /*firefox*/ + scrollbar-width: thin; + &::-webkit-scrollbar { + width: 1.1rem; + height: 1.1rem; + } + &::-webkit-scrollbar-corner { + background: transparent; + } + &::-webkit-scrollbar-thumb { + border: 6px solid transparent; + background: rgba(0, 0, 0, 0.2); + background: var(--palette-black-alpha-20, rgba(0, 0, 0, 0.2)); + border-radius: 10px; + background-clip: padding-box; + } + &::-webkit-scrollbar-thumb:hover { + border: 4px solid transparent; + background: rgba(0, 0, 0, 0.3); + background: var(--palette-black-alpha-30, rgba(0, 0, 0, 0.3)); + background-clip: padding-box; + } +`; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx new file mode 100644 index 000000000..c13f2a6c9 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx @@ -0,0 +1,137 @@ +import { cx } from '@emotion/css'; +import React, { useEffect, useRef, useState } from 'react'; +import { getYmd } from '../../helpers/other-helper'; +import { BarTask } from '../../types/bar-task'; +import { Task } from '../../types/public-types'; +import { + tooltipDefaultContainer, + tooltipDefaultContainerParagraph, + tooltipDetailsContainer, + tooltipDetailsContainerHidden, +} from './style'; + +export type TooltipProps = { + task: BarTask; + arrowIndent: number; + rtl: boolean; + svgContainerHeight: number; + svgContainerWidth: number; + svgWidth: number; + headerHeight: number; + taskListWidth: number; + scrollX: number; + scrollY: number; + rowHeight: number; + fontSize: string; + fontFamily: string; + TooltipContent: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; + }>; +}; +export const Tooltip: React.FC = ({ + task, + rowHeight, + rtl, + svgContainerHeight, + svgContainerWidth, + scrollX, + scrollY, + arrowIndent, + fontSize, + fontFamily, + headerHeight, + taskListWidth, + TooltipContent, +}) => { + const tooltipRef = useRef(null); + const [relatedY, setRelatedY] = useState(0); + const [relatedX, setRelatedX] = useState(0); + useEffect(() => { + if (tooltipRef.current) { + const tooltipHeight = tooltipRef.current.offsetHeight * 1.1; + const tooltipWidth = tooltipRef.current.offsetWidth * 1.1; + + let newRelatedY = task.index * rowHeight - scrollY + headerHeight; + let newRelatedX: number; + if (rtl) { + newRelatedX = task.x1 - arrowIndent * 1.5 - tooltipWidth - scrollX; + if (newRelatedX < 0) { + newRelatedX = task.x2 + arrowIndent * 1.5 - scrollX; + } + const tooltipLeftmostPoint = tooltipWidth + newRelatedX; + if (tooltipLeftmostPoint > svgContainerWidth) { + newRelatedX = svgContainerWidth - tooltipWidth; + newRelatedY += rowHeight; + } + } else { + newRelatedX = task.x2 + arrowIndent * 1.5 + taskListWidth - scrollX; + const tooltipLeftmostPoint = tooltipWidth + newRelatedX; + const fullChartWidth = taskListWidth + svgContainerWidth; + if (tooltipLeftmostPoint > fullChartWidth) { + newRelatedX = task.x1 + taskListWidth - arrowIndent * 1.5 - scrollX - tooltipWidth; + } + if (newRelatedX < taskListWidth) { + newRelatedX = svgContainerWidth + taskListWidth - tooltipWidth; + newRelatedY += rowHeight; + } + } + + const tooltipLowerPoint = tooltipHeight + newRelatedY - scrollY; + if (tooltipLowerPoint > svgContainerHeight - scrollY) { + newRelatedY = svgContainerHeight - tooltipHeight; + } + setRelatedY(newRelatedY); + setRelatedX(newRelatedX); + } + }, [ + tooltipRef, + task, + arrowIndent, + scrollX, + scrollY, + headerHeight, + taskListWidth, + rowHeight, + svgContainerHeight, + svgContainerWidth, + rtl, + ]); + + return ( +
+ +
+ ); +}; + +export const StandardTooltipContent: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; +}> = ({ task, fontSize, fontFamily }) => { + const style = { + fontSize, + fontFamily, + }; + return ( +
+ + {task.name}: {getYmd(task.start)} ~ {getYmd(task.end)} + + {task.end.getTime() - task.start.getTime() !== 0 && ( +

{`Duration: ${~~( + (task.end.getTime() - task.start.getTime()) / + (1000 * 60 * 60 * 24) + )} day(s)`}

+ )} + +

{!!task.progress && `Progress: ${task.progress}%`}

+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx new file mode 100644 index 000000000..61fe338c5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx @@ -0,0 +1,35 @@ +import React, { SyntheticEvent, useRef, useEffect } from 'react'; +import { cx } from '@emotion/css'; +import { verticalScroll } from './style'; + +export const VerticalScroll: React.FC<{ + scroll: number; + ganttHeight: number; + ganttFullHeight: number; + headerHeight: number; + rtl: boolean; + onScroll: (event: SyntheticEvent) => void; +}> = ({ scroll, ganttHeight, ganttFullHeight, headerHeight, rtl, onScroll }) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scroll; + } + }, [scroll]); + + return ( +
+
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx new file mode 100644 index 000000000..b54e37071 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +type BarDateHandleProps = { + x: number; + y: number; + width: number; + height: number; + barCornerRadius: number; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarDateHandle: React.FC = ({ x, y, width, height, barCornerRadius, onMouseDown }) => { + return ( + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx new file mode 100644 index 000000000..a43e5b6d5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx @@ -0,0 +1,74 @@ +import { cx } from '@emotion/css'; +import React from "react"; +import { barBackground } from './style'; + +type BarDisplayProps = { + x: number; + y: number; + color?: string; + width: number; + height: number; + isSelected: boolean; + /* progress start point */ + progressX: number; + progressWidth: number; + barCornerRadius: number; + styles: { + backgroundColor: string; + backgroundSelectedColor: string; + progressColor: string; + progressSelectedColor: string; + }; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarDisplay: React.FC = ({ + x, + y, + color, + width, + height, + isSelected, + progressX, + progressWidth, + barCornerRadius, + styles, + onMouseDown, +}) => { + const getProcessColor = () => { + if (color) { + return color; + } + return isSelected ? styles.progressSelectedColor : styles.progressColor; + }; + + const getBarColor = () => { + if (color) { + return color; + } + return isSelected ? styles.backgroundSelectedColor : styles.backgroundColor; + }; + + return ( + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx new file mode 100644 index 000000000..7c1a05118 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +type BarProgressHandleProps = { + progressPoint: string; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarProgressHandle: React.FC = ({ progressPoint, onMouseDown }) => { + return ; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx new file mode 100644 index 000000000..2cf3ac0dc --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { cx } from '@emotion/css'; +import { getProgressPoint } from "../../../helpers/bar-helper"; +import { BarDisplay } from "./bar-display"; +import { BarProgressHandle } from "./bar-progress-handle"; +import { TaskItemProps } from "../task-item"; +import { barWrapper } from './style'; + + +export const BarSmall: React.FC = ({ + task, + isProgressChangeable, + isDateChangeable, + onEventStart, + isSelected, +}) => { + const progressPoint = getProgressPoint( + task.progressWidth + task.x1+10, + task.y, + task.height + ); + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + {isProgressChangeable && ( + { + onEventStart("progress", task, e); + }} + /> + )} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx new file mode 100644 index 000000000..f3255c03a --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx @@ -0,0 +1,80 @@ +import { cx } from '@emotion/css'; +import React from "react"; +import { getProgressPoint } from "../../../helpers/bar-helper"; +import { TaskItemProps } from "../task-item"; +import { BarDateHandle } from "./bar-date-handle"; +import { BarDisplay } from "./bar-display"; +import { BarProgressHandle } from "./bar-progress-handle"; +import { barWrapper } from './style'; + + +export const Bar: React.FC = ({ + task, + isProgressChangeable, + isDateChangeable, + rtl, + onEventStart, + isSelected, +}) => { + const progressPoint = getProgressPoint( + +!rtl * task.progressWidth + task.progressX, + task.y, + task.height + ); + const handleHeight = task.height - 2; + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + {isDateChangeable && ( + + {/* left */} + { + onEventStart("start", task, e); + }} + /> + {/* right */} + { + onEventStart("end", task, e); + }} + /> + + )} + {isProgressChangeable && ( + { + onEventStart("progress", task, e); + }} + /> + )} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/style.tsx new file mode 100644 index 000000000..da5335ad5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/style.tsx @@ -0,0 +1,23 @@ +import { css } from '@emotion/css'; + +export const barWrapper = css` + cursor: pointer; + outline: none; + .barHandle{ + fill: #ddd; + cursor: ew-resize; + opacity: 0; + visibility: hidden; + } + &:hover .barHandle { + visibility: visible; + opacity: 1; + } +`; + + +export const barBackground = css` + user-select: none; + stroke-width: 0; + opacity: .6; +`; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx new file mode 100644 index 000000000..07a8f9381 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { cx } from '@emotion/css'; +import { TaskItemProps } from "../task-item"; +import { milestoneWrapper,milestoneBackground } from './style'; + +export const Milestone: React.FC = ({ + task, + isDateChangeable, + onEventStart, + isSelected, +}) => { + const transform = `rotate(45 ${task.x1 + task.height * 0.356} + ${task.y + task.height * 0.85})`; + const getBarColor = () => { + return isSelected + ? task.styles.backgroundSelectedColor + : task.styles.backgroundColor; + }; + + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/style.tsx new file mode 100644 index 000000000..04d2a6891 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/style.tsx @@ -0,0 +1,11 @@ + +import { css } from '@emotion/css'; + +export const milestoneWrapper =css` + cursor: pointer; + outline: none; +` + +export const milestoneBackground =css` + user-select: none; +` diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx new file mode 100644 index 000000000..65e635c4f --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx @@ -0,0 +1,34 @@ +import { cx } from '@emotion/css'; +import React from 'react'; +import { TaskItemProps } from '../task-item'; +import { projectBackground, projectWrapper } from './style'; + +export const Project: React.FC = ({ task, isSelected }) => { + const barColor = isSelected ? task.styles.backgroundSelectedColor : task.styles.backgroundColor; + const processColor = isSelected ? task.styles.progressSelectedColor : task.styles.progressColor; + const projectWith = task.x2 - task.x1; + + return ( + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/style.tsx new file mode 100644 index 000000000..44bb420e2 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/style.tsx @@ -0,0 +1,16 @@ + +import { css } from '@emotion/css'; + +export const projectWrapper =css ` + cursor: pointer; + outline: none; +` + +export const projectBackground =css` + user-select: none; + opacity: 0.6; +` + +export const projectTop =css` + user-select: none; +` diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/style.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/style.tsx new file mode 100644 index 000000000..8dda01f50 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/style.tsx @@ -0,0 +1,37 @@ + +import { css } from '@emotion/css'; + +export const barLabel =css` + fill: #fff; + text-anchor: middle; + font-weight: 400; + dominant-baseline: central; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +` +export const projectLabel =css` + fill: #130d0d; + font-weight: 500; + font-size: 0.9em; + dominant-baseline: central; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +` +export const barLabelOutside =css` + fill: #555; + text-anchor: start; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +` diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx new file mode 100644 index 000000000..4356c3929 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx @@ -0,0 +1,109 @@ +import { cx } from '@emotion/css'; +import React, { useEffect, useRef, useState } from 'react'; +import { getYmd } from '../../helpers/other-helper'; +import { BarTask } from '../../types/bar-task'; +import { GanttContentMoveAction } from '../../types/gantt-task-actions'; +import { Bar } from './bar/bar'; +import { BarSmall } from './bar/bar-small'; +import { Milestone } from './milestone/milestone'; +import { Project } from './project/project'; +import { barLabel, barLabelOutside, projectLabel } from './style'; + +export type TaskItemProps = { + task: BarTask; + arrowIndent: number; + taskHeight: number; + isProgressChangeable: boolean; + isDateChangeable: boolean; + isDelete: boolean; + isSelected: boolean; + rtl: boolean; + onEventStart: ( + action: GanttContentMoveAction, + selectedTask: BarTask, + event?: React.MouseEvent | React.KeyboardEvent, + ) => any; +}; + +export const TaskItem: React.FC = (props) => { + const { task, arrowIndent, isDelete, taskHeight, isSelected, rtl, onEventStart } = { + ...props, + }; + const textRef = useRef(null); + const [taskItem, setTaskItem] = useState(
); + const [isTextInside, setIsTextInside] = useState(true); + const isProjectBar = task.typeInternal === 'project'; + useEffect(() => { + switch (task.typeInternal) { + case 'milestone': + setTaskItem(); + break; + case 'project': + setTaskItem(); + break; + case 'smalltask': + setTaskItem(); + break; + default: + setTaskItem(); + break; + } + }, [task, isSelected]); + + useEffect(() => { + if (textRef.current) { + setIsTextInside(textRef.current.getBBox().width < task.x2 - task.x1); + } + }, [textRef, task]); + + const getX = () => { + const width = task.x2 - task.x1; + const hasChild = task.barChildren.length > 0; + if (isTextInside) { + return task.x1 + width * 0.5; + } + if (rtl && textRef.current) { + return task.x1 - textRef.current.getBBox().width - arrowIndent * +hasChild - arrowIndent * 0.2; + } else { + return task.x1 + width + arrowIndent * +hasChild + arrowIndent * 0.2; + } + }; + return ( + { + switch (e.key) { + case 'Delete': { + if (isDelete) onEventStart('delete', task, e); + break; + } + } + e.stopPropagation(); + }} + onMouseEnter={(e) => { + onEventStart('mouseenter', task, e); + }} + onMouseLeave={(e) => { + onEventStart('mouseleave', task, e); + }} + onDoubleClick={(e) => { + onEventStart('dblclick', task, e); + }} + onClick={(e) => { + onEventStart('click', task, e); + }} + onFocus={() => { + onEventStart('select', task); + }} + > + {taskItem} + + {isProjectBar ? `${task.name}: ${getYmd(task.start)} ~ ${getYmd(task.end)}` : task.name} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/context.ts b/packages/core/client/src/schema-component/antd/gantt/context.ts new file mode 100644 index 000000000..10b2f3277 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/context.ts @@ -0,0 +1,6 @@ +import { createContext, useContext } from 'react'; + +// export const GanttToolbarContext = createContext(null); +export const CalendarContext = createContext(null); +export const DeleteEventContext = createContext(null); + diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts new file mode 100644 index 000000000..653236de9 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts @@ -0,0 +1,490 @@ +import { Task } from '../types/public-types'; +import { BarTask, TaskTypeInternal } from '../types/bar-task'; +import { BarMoveAction } from '../types/gantt-task-actions'; + +export const convertToBarTasks = ( + tasks: Task[], + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string, + projectProgressColor: string, + projectProgressSelectedColor: string, + projectBackgroundColor: string, + projectBackgroundSelectedColor: string, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string, +) => { + let barTasks = tasks.map((t, i) => { + return convertToBarTask( + t, + i, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor, + ); + }); + + // set dependencies + barTasks = barTasks.map((task) => { + const dependencies = task.dependencies || []; + for (let j = 0; j < dependencies.length; j++) { + const dependence = barTasks.findIndex((value) => value.id === dependencies[j]); + if (dependence !== -1) barTasks[dependence].barChildren.push(task); + } + return task; + }); + + return barTasks; +}; + +const convertToBarTask = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string, + projectProgressColor: string, + projectProgressSelectedColor: string, + projectBackgroundColor: string, + projectBackgroundSelectedColor: string, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string, +): BarTask => { + let barTask: BarTask; + switch (task.type) { + case 'milestone': + barTask = convertToMilestone( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor, + ); + break; + case 'project': + barTask = convertToBar( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight * 0.5, + barCornerRadius, + handleWidth, + rtl, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + ); + break; + default: + barTask = convertToBar( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + ); + break; + } + return barTask; +}; + +const convertToBar = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string, +): BarTask => { + let x1: number; + let x2: number; + if (rtl) { + x2 = taskXCoordinateRTL(task.start, dates, columnWidth); + x1 = taskXCoordinateRTL(task.end, dates, columnWidth); + } else { + x1 = taskXCoordinate(task.start, dates, columnWidth); + x2 = taskXCoordinate(task.end, dates, columnWidth); + } + let typeInternal: TaskTypeInternal = task.type; + if (typeInternal === 'task' && x2 - x1 < handleWidth * 2) { + typeInternal = 'smalltask'; + x2 = x1 + handleWidth * 2; + } + + const [progressWidth, progressX] = progressWithByParams(x1, x2, task.progress, rtl); + const y = + task.type === 'project' + ? taskYCoordinate(index, rowHeight, taskHeight) +8 + : taskYCoordinate(index, rowHeight, taskHeight); + const hideChildren = task.type === 'project' ? task.hideChildren : undefined; + + const styles = { + backgroundColor: barBackgroundColor, + backgroundSelectedColor: barBackgroundSelectedColor, + progressColor: barProgressColor, + progressSelectedColor: barProgressSelectedColor, + ...task.styles, + }; + return { + ...task, + typeInternal, + x1, + x2, + y, + index, + progressX, + progressWidth, + barCornerRadius, + handleWidth, + hideChildren, + height: taskHeight, + barChildren: [], + styles, + }; +}; + +const convertToMilestone = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string, +): BarTask => { + const x = taskXCoordinate(task.start, dates, columnWidth); + const y = taskYCoordinate(index, rowHeight, taskHeight); + + const x1 = x - taskHeight * 0.5; + const x2 = x + taskHeight * 0.5; + + const rotatedHeight = taskHeight / 1.414; + const styles = { + backgroundColor: milestoneBackgroundColor, + backgroundSelectedColor: milestoneBackgroundSelectedColor, + progressColor: '', + progressSelectedColor: '', + ...task.styles, + }; + return { + ...task, + end: task.start, + x1, + x2, + y, + index, + progressX: 0, + progressWidth: 0, + barCornerRadius, + handleWidth, + typeInternal: task.type, + progress: 0, + height: rotatedHeight, + hideChildren: undefined, + barChildren: [], + styles, + }; +}; + +const taskXCoordinate = (xDate: Date, dates: Date[], columnWidth: number) => { + const index = dates.findIndex((d) => d?.getTime() >= xDate?.getTime()) - 1; + + const remainderMillis = xDate?.getTime() - dates[index]?.getTime(); + const percentOfInterval = remainderMillis / (dates[index + 1]?.getTime() - dates[index]?.getTime()); + const x = index * columnWidth + percentOfInterval * columnWidth; + return x; +}; +const taskXCoordinateRTL = (xDate: Date, dates: Date[], columnWidth: number) => { + let x = taskXCoordinate(xDate, dates, columnWidth); + x += columnWidth; + return x; +}; +const taskYCoordinate = (index: number, rowHeight: number, taskHeight: number) => { + const y = index * rowHeight + (rowHeight - taskHeight) / 2; + return y; +}; + +export const progressWithByParams = (taskX1: number, taskX2: number, progress: number, rtl: boolean) => { + const progressWidth = (taskX2 - taskX1) * progress * 0.01; + let progressX: number; + if (rtl) { + progressX = taskX2 - progressWidth; + } else { + progressX = taskX1; + } + return [progressWidth, progressX]; +}; + +export const progressByProgressWidth = (progressWidth: number, barTask: BarTask) => { + const barWidth = barTask.x2 - barTask.x1; + const progressPercent = Math.round((progressWidth * 100) / barWidth); + if (progressPercent >= 100) return 100; + else if (progressPercent <= 0) return 0; + else return progressPercent; +}; + +const progressByX = (x: number, task: BarTask) => { + if (x >= task.x2) return 100; + else if (x <= task.x1) return 0; + else { + const barWidth = task.x2 - task.x1; + const progressPercent = Math.round(((x - task.x1) * 100) / barWidth); + return progressPercent; + } +}; +const progressByXRTL = (x: number, task: BarTask) => { + if (x >= task.x2) return 0; + else if (x <= task.x1) return 100; + else { + const barWidth = task.x2 - task.x1; + const progressPercent = Math.round(((task.x2 - x) * 100) / barWidth); + return progressPercent; + } +}; + +export const getProgressPoint = (progressX: number, taskY: number, taskHeight: number) => { + const point = [ + progressX - 5, + taskY + taskHeight, + progressX + 5, + taskY + taskHeight, + progressX, + taskY + taskHeight - 8.66, + ]; + return point.join(','); +}; + +const startByX = (x: number, xStep: number, task: BarTask) => { + if (x >= task.x2 - task.handleWidth * 2) { + x = task.x2 - task.handleWidth * 2; + } + const steps = Math.round((x - task.x1) / xStep); + const additionalXValue = steps * xStep; + const newX = task.x1 + additionalXValue; + return newX; +}; + +const endByX = (x: number, xStep: number, task: BarTask) => { + if (x <= task.x1 + task.handleWidth * 2) { + x = task.x1 + task.handleWidth * 2; + } + const steps = Math.round((x - task.x2) / xStep); + const additionalXValue = steps * xStep; + const newX = task.x2 + additionalXValue; + return newX; +}; + +const moveByX = (x: number, xStep: number, task: BarTask) => { + const steps = Math.round((x - task.x1) / xStep); + const additionalXValue = steps * xStep; + const newX1 = task.x1 + additionalXValue; + const newX2 = newX1 + task.x2 - task.x1; + return [newX1, newX2]; +}; + +const dateByX = (x: number, taskX: number, taskDate: Date, xStep: number, timeStep: number) => { + let newDate = new Date(((x - taskX) / xStep) * timeStep + taskDate.getTime()); + newDate = new Date(newDate.getTime() + (newDate.getTimezoneOffset() - taskDate.getTimezoneOffset()) * 60000); + return newDate; +}; + +/** + * Method handles event in real time(mousemove) and on finish(mouseup) + */ +export const handleTaskBySVGMouseEvent = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number, + rtl: boolean, +): { isChanged: boolean; changedTask: BarTask } => { + let result: { isChanged: boolean; changedTask: BarTask }; + switch (selectedTask.type) { + case 'milestone': + result = handleTaskBySVGMouseEventForMilestone(svgX, action, selectedTask, xStep, timeStep, initEventX1Delta); + break; + default: + result = handleTaskBySVGMouseEventForBar(svgX, action, selectedTask, xStep, timeStep, initEventX1Delta, rtl); + break; + } + return result; +}; + +const handleTaskBySVGMouseEventForBar = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number, + rtl: boolean, +): { isChanged: boolean; changedTask: BarTask } => { + const changedTask: BarTask = { ...selectedTask }; + let isChanged = false; + switch (action) { + case 'progress': + if (rtl) { + changedTask.progress = progressByXRTL(svgX, selectedTask); + } else { + changedTask.progress = progressByX(svgX, selectedTask); + } + isChanged = changedTask.progress !== selectedTask.progress; + if (isChanged) { + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl, + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + case 'start': { + const newX1 = startByX(svgX, xStep, selectedTask); + changedTask.x1 = newX1; + isChanged = changedTask.x1 !== selectedTask.x1; + if (isChanged) { + if (rtl) { + changedTask.end = dateByX(newX1, selectedTask.x1, selectedTask.end, xStep, timeStep); + } else { + changedTask.start = dateByX(newX1, selectedTask.x1, selectedTask.start, xStep, timeStep); + } + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl, + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + case 'end': { + const newX2 = endByX(svgX, xStep, selectedTask); + changedTask.x2 = newX2; + isChanged = changedTask.x2 !== selectedTask.x2; + if (isChanged) { + if (rtl) { + changedTask.start = dateByX(newX2, selectedTask.x2, selectedTask.start, xStep, timeStep); + } else { + changedTask.end = dateByX(newX2, selectedTask.x2, selectedTask.end, xStep, timeStep); + } + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl, + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + case 'move': { + const [newMoveX1, newMoveX2] = moveByX(svgX - initEventX1Delta, xStep, selectedTask); + isChanged = newMoveX1 !== selectedTask.x1; + if (isChanged) { + changedTask.start = dateByX(newMoveX1, selectedTask.x1, selectedTask.start, xStep, timeStep); + changedTask.end = dateByX(newMoveX2, selectedTask.x2, selectedTask.end, xStep, timeStep); + changedTask.x1 = newMoveX1; + changedTask.x2 = newMoveX2; + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl, + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + } + return { isChanged, changedTask }; +}; + +const handleTaskBySVGMouseEventForMilestone = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number, +): { isChanged: boolean; changedTask: BarTask } => { + const changedTask: BarTask = { ...selectedTask }; + let isChanged = false; + switch (action) { + case 'move': { + const [newMoveX1, newMoveX2] = moveByX(svgX - initEventX1Delta, xStep, selectedTask); + isChanged = newMoveX1 !== selectedTask.x1; + if (isChanged) { + changedTask.start = dateByX(newMoveX1, selectedTask.x1, selectedTask.start, xStep, timeStep); + changedTask.end = changedTask.start; + changedTask.x1 = newMoveX1; + changedTask.x2 = newMoveX2; + } + break; + } + } + return { isChanged, changedTask }; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts new file mode 100644 index 000000000..6602ffa2d --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts @@ -0,0 +1,203 @@ +import { Task, ViewMode } from '../types/public-types'; + +const DateTimeFormat = Intl.DateTimeFormat; +type DateTimeFormat = typeof DateTimeFormat; +//@ts-ignore +const DateTimeFormatOptions = Intl.DateTimeFormatOptions; +type DateTimeFormatOptions = typeof DateTimeFormatOptions; +type DateHelperScales = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; + +const intlDTCache = {}; +export const getCachedDateTimeFormat = ( + locString: string | string[], + opts: DateTimeFormatOptions = {}, +): DateTimeFormat => { + const key = JSON.stringify([locString, opts]); + let dtf = intlDTCache[key]; + if (!dtf) { + dtf = new Intl.DateTimeFormat(locString, opts); + intlDTCache[key] = dtf; + } + return dtf; +}; + +export const addToDate = (date: Date, quantity: number, scale: DateHelperScales) => { + const newDate = new Date( + date.getFullYear() + (scale === 'year' ? quantity : 0), + date.getMonth() + (scale === 'month' ? quantity : 0), + date.getDate() + (scale === 'day' ? quantity : 0), + date.getHours() + (scale === 'hour' ? quantity : 0), + date.getMinutes() + (scale === 'minute' ? quantity : 0), + date.getSeconds() + (scale === 'second' ? quantity : 0), + date.getMilliseconds() + (scale === 'millisecond' ? quantity : 0), + ); + return newDate; +}; + +export const startOfDate = (date: Date, scale: DateHelperScales) => { + const scores = ['millisecond', 'second', 'minute', 'hour', 'day', 'month', 'year']; + + const shouldReset = (_scale: DateHelperScales) => { + const maxScore = scores.indexOf(scale); + return scores.indexOf(_scale) <= maxScore; + }; + const newDate = new Date( + date?.getFullYear(), + shouldReset('year') ? 0 : date.getMonth(), + shouldReset('month') ? 1 : date.getDate(), + shouldReset('day') ? 0 : date.getHours(), + shouldReset('hour') ? 0 : date.getMinutes(), + shouldReset('minute') ? 0 : date.getSeconds(), + shouldReset('second') ? 0 : date.getMilliseconds(), + ); + return newDate; +}; + +export const ganttDateRange = (tasks: Task[], viewMode: ViewMode, preStepsCount: number) => { + let newStartDate: Date = tasks[0]?.start || new Date(); + let newEndDate: Date = tasks[0]?.start || new Date() ; + for (const task of tasks) { + if (task.start < newStartDate) { + newStartDate = task.start; + } + if (task.end > newEndDate) { + newEndDate = task.end; + } + } + switch (viewMode) { + case ViewMode.Year: + newStartDate = addToDate(newStartDate, -1, 'year'); + newStartDate = startOfDate(newStartDate, 'year'); + newEndDate = addToDate(newEndDate, 1, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.QuarterYear: + newStartDate = addToDate(newStartDate, -3, 'month'); + newStartDate = startOfDate(newStartDate, 'month'); + newEndDate = addToDate(newEndDate, 3, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.Month: + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'month'); + newStartDate = startOfDate(newStartDate, 'month'); + newEndDate = addToDate(newEndDate, 1, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.Week: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(getMonday(newStartDate), -7 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 1.5, 'month'); + break; + case ViewMode.Day: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 19, 'day'); + break; + case ViewMode.QuarterDay: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 66, 'hour'); // 24(1 day)*3 - 6 + break; + case ViewMode.HalfDay: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 108, 'hour'); // 24(1 day)*5 - 12 + break; + case ViewMode.Hour: + newStartDate = startOfDate(newStartDate, 'hour'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'hour'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 1, 'day'); + break; + } + return [newStartDate, newEndDate]; +}; + +export const seedDates = (startDate: Date, endDate: Date, viewMode: ViewMode) => { + let currentDate: Date = new Date(startDate); + const dates: Date[] = [currentDate]; + while (currentDate < endDate) { + switch (viewMode) { + case ViewMode.Year: + currentDate = addToDate(currentDate, 1, 'year'); + break; + case ViewMode.QuarterYear: + currentDate = addToDate(currentDate, 3, 'month'); + break; + case ViewMode.Month: + currentDate = addToDate(currentDate, 1, 'month'); + break; + case ViewMode.Week: + currentDate = addToDate(currentDate, 7, 'day'); + break; + case ViewMode.Day: + currentDate = addToDate(currentDate, 1, 'day'); + break; + case ViewMode.HalfDay: + currentDate = addToDate(currentDate, 12, 'hour'); + break; + case ViewMode.QuarterDay: + currentDate = addToDate(currentDate, 6, 'hour'); + break; + case ViewMode.Hour: + currentDate = addToDate(currentDate, 1, 'hour'); + break; + } + dates.push(currentDate); + } + return dates; +}; + +export const getLocaleMonth = (date: Date, locale: string) => { + let bottomValue = getCachedDateTimeFormat(locale, { + month: 'long', + //@ts-ignore + }).format(date); + bottomValue = bottomValue.replace(bottomValue[0], bottomValue[0].toLocaleUpperCase()); + return bottomValue; +}; + +export const getLocalDayOfWeek = (date: Date, locale: string, format?: 'long' | 'short' | 'narrow' | undefined) => { + let bottomValue = getCachedDateTimeFormat(locale, { + weekday: format, + //@ts-ignore + }).format(date); + bottomValue = bottomValue.replace(bottomValue[0], bottomValue[0].toLocaleUpperCase()); + return bottomValue; +}; + +/** + * Returns monday of current week + * @param date date for modify + */ +const getMonday = (date: Date) => { + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is sunday + return new Date(date.setDate(diff)); +}; + +export const getWeekNumberISO8601 = (date: Date) => { + const tmpDate = new Date(date.valueOf()); + const dayNumber = (tmpDate.getDay() + 6) % 7; + tmpDate.setDate(tmpDate.getDate() - dayNumber + 3); + const firstThursday = tmpDate.valueOf(); + tmpDate.setMonth(0, 1); + if (tmpDate.getDay() !== 4) { + tmpDate.setMonth(0, 1 + ((4 - tmpDate.getDay() + 7) % 7)); + } + const weekNumber = (1 + Math.ceil((firstThursday - tmpDate.valueOf()) / 604800000)).toString(); + + if (weekNumber.length === 1) { + return `0${weekNumber}`; + } else { + return weekNumber; + } +}; + +export const getDaysInMonth = (month: number, year: number) => { + return new Date(year, month + 1, 0).getDate(); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts new file mode 100644 index 000000000..420f8c9c8 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts @@ -0,0 +1,61 @@ +import { BarTask } from '../types/bar-task'; +import { Task } from '../types/public-types'; + +export function isKeyboardEvent( + event: React.MouseEvent | React.KeyboardEvent | React.FocusEvent, +): event is React.KeyboardEvent { + return (event as React.KeyboardEvent).key !== undefined; +} + +export function isMouseEvent( + event: React.MouseEvent | React.KeyboardEvent | React.FocusEvent, +): event is React.MouseEvent { + return (event as React.MouseEvent).clientX !== undefined; +} + +export function isBarTask(task: Task | BarTask): task is BarTask { + return (task as BarTask).x1 !== undefined; +} + +export function removeHiddenTasks(tasks: Task[]) { + const groupedTasks = tasks.filter((t) => t.hideChildren && t.type === 'project'); + if (groupedTasks.length > 0) { + for (let i = 0; groupedTasks.length > i; i++) { + const groupedTask = groupedTasks[i]; + const children = getChildren(tasks, groupedTask); + tasks = tasks.filter((t) => children.indexOf(t) === -1); + } + } + return tasks; +} + +function getChildren(taskList: Task[], task: Task) { + let tasks: Task[] = []; + if (task.type !== 'project') { + tasks = taskList.filter((t) => t.dependencies && t.dependencies.indexOf(task.id) !== -1); + } else { + tasks = taskList.filter((t) => t.project && t.project === task.id); + } + var taskChildren: Task[] = []; + tasks.forEach((t) => { + taskChildren.push(...getChildren(taskList, t)); + }); + tasks = tasks.concat(tasks, taskChildren); + return tasks; +} + +export const sortTasks = (taskA: Task, taskB: Task) => { + const orderA = taskA.displayOrder || Number.MAX_VALUE; + const orderB = taskB.displayOrder || Number.MAX_VALUE; + if (orderA > orderB) { + return 1; + } else if (orderA < orderB) { + return -1; + } else { + return 0; + } +}; + +export const getYmd = (date: Date) => { + return date.getFullYear() + '/' + `${date.getMonth() + 1}` + '/' + date.getDate(); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/index.ts b/packages/core/client/src/schema-component/antd/gantt/index.ts new file mode 100644 index 000000000..01b30a830 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/index.ts @@ -0,0 +1,15 @@ +import { ActionBar } from '../action'; +import { Gantt } from './components/gantt/gantt'; +import { GanttDesigner } from './Gantt.Designer'; +import { ViewMode } from './types/public-types'; +import {Event} from './components/gantt/Event'; + +Gantt.ActionBar = ActionBar; + +Gantt.ViewMode = ViewMode; +Gantt.Designer = GanttDesigner; +Gantt.Event = Event; + +// const GanttV2 = Gantt; + +export { Gantt }; diff --git a/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx b/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx new file mode 100644 index 000000000..c90f239d7 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx @@ -0,0 +1,73 @@ +import { + seedDates, + addToDate, + getWeekNumberISO8601, +} from "../helpers/date-helper"; +import { ViewMode } from "../types/public-types"; + +describe("seed date", () => { + test("daily", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 2), ViewMode.Day) + ).toEqual([ + new Date(2020, 5, 28), + new Date(2020, 5, 29), + new Date(2020, 5, 30), + new Date(2020, 6, 1), + new Date(2020, 6, 2), + ]); + }); + + test("weekly", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Week) + ).toEqual([ + new Date(2020, 5, 28), + new Date(2020, 6, 5), + new Date(2020, 6, 12), + new Date(2020, 6, 19), + ]); + }); + + test("monthly", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Month) + ).toEqual([new Date(2020, 5, 28), new Date(2020, 6, 28)]); + }); + + test("quarterly", () => { + expect( + seedDates( + new Date(2020, 5, 28), + new Date(2020, 5, 29), + ViewMode.QuarterDay + ) + ).toEqual([ + new Date(2020, 5, 28, 0, 0), + new Date(2020, 5, 28, 6, 0), + new Date(2020, 5, 28, 12, 0), + new Date(2020, 5, 28, 18, 0), + new Date(2020, 5, 29, 0, 0), + ]); + }); +}); + +describe("add to date", () => { + test("add month", () => { + expect(addToDate(new Date(2020, 0, 1), 40, "month")).toEqual( + new Date(2023, 4, 1) + ); + }); + + test("add day", () => { + expect(addToDate(new Date(2020, 0, 1), 40, "day")).toEqual( + new Date(2020, 1, 10) + ); + }); +}); + +test("get week number", () => { + expect(getWeekNumberISO8601(new Date(2019, 11, 31))).toEqual("01"); + expect(getWeekNumberISO8601(new Date(2021, 0, 1))).toEqual("53"); + expect(getWeekNumberISO8601(new Date(2020, 6, 20))).toEqual("30"); +}); diff --git a/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx b/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx new file mode 100644 index 000000000..75b2e2142 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { Gantt } from "../index"; + +describe("gantt", () => { + it("renders without crashing", () => { + const div = document.createElement("div"); + const root = createRoot(div); + root.render( + + ); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts b/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts new file mode 100644 index 000000000..126079d4c --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts @@ -0,0 +1,24 @@ +import { Task, TaskType } from "./public-types"; + +export interface BarTask extends Task { + index: number; + typeInternal: TaskTypeInternal; + x1: number; + x2: number; + y: number; + height: number; + progressX: number; + progressWidth: number; + barCornerRadius: number; + handleWidth: number; + barChildren: BarTask[]; + color?: string; + styles: { + backgroundColor: string; + backgroundSelectedColor: string; + progressColor: string; + progressSelectedColor: string; + }; +} + +export type TaskTypeInternal = TaskType | "smalltask"; diff --git a/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts b/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts new file mode 100644 index 000000000..81115ece0 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts @@ -0,0 +1,6 @@ +import { ViewMode } from "./public-types"; + +export interface DateSetup { + dates: Date[]; + viewMode: ViewMode; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts b/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts new file mode 100644 index 000000000..01e1292cb --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts @@ -0,0 +1,18 @@ +import { BarTask } from "./bar-task"; + +export type BarMoveAction = "progress" | "end" | "start" | "move"; +export type GanttContentMoveAction = + | "mouseenter" + | "mouseleave" + | "delete" + | "dblclick" + | "click" + | "select" + | "" + | BarMoveAction; + +export type GanttEvent = { + changedTask?: BarTask; + originalSelectedTask?: BarTask; + action: GanttContentMoveAction; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts b/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts new file mode 100644 index 000000000..9ae7a29df --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts @@ -0,0 +1,145 @@ +export enum ViewMode { + Hour = "hour", + QuarterDay = "quarterDay", + HalfDay = "halfDay", + Day = "day", + /** ISO-8601 week */ + Week = "week", + Month = "month", + QuarterYear = "quarterYear", + Year = "year", +} +export type TaskType = "task" | "milestone" | "project"; +export interface Task { + id: string; + type: TaskType; + name: string; + start: Date; + end: Date; + /** + * From 0 to 100 + */ + progress: number; + styles?: { + backgroundColor?: string; + backgroundSelectedColor?: string; + progressColor?: string; + progressSelectedColor?: string; + }; + isDisabled?: boolean; + project?: string; + dependencies?: string[]; + hideChildren?: boolean; + displayOrder?: number; +} + +export interface EventOption { + /** + * Time step value for date changes. + */ + timeStep?: number; + /** + * Invokes on bar select on unselect. + */ + onSelect?: (task: Task, isSelected: boolean) => void; + /** + * Invokes on bar double click. + */ + onDoubleClick?: (task: Task) => void; + /** + * Invokes on bar click. + */ + onClick?: (task: Task) => void; + /** + * Invokes on end and start time change. Chart undoes operation if method return false or error. + */ + onDateChange?: ( + task: Task, + children: Task[] + ) => void | boolean | Promise | Promise; + /** + * Invokes on progress change. Chart undoes operation if method return false or error. + */ + onProgressChange?: ( + task: Task, + children: Task[] + ) => void | boolean | Promise | Promise; + /** + * Invokes on delete selected task. Chart undoes operation if method return false or error. + */ + onDelete?: (task: Task) => void | boolean | Promise | Promise; + /** + * Invokes on expander on task list + */ + onExpanderClick?: (task: Task) => void; +} + +export interface DisplayOption { + viewMode?: ViewMode; + viewDate?: Date; + preStepsCount?: number; + /** + * Specifies the month name language. Able formats: ISO 639-2, Java Locale + */ + locale?: string; + rtl?: boolean; +} + +export interface StylingOption { + headerHeight?: number; + columnWidth?: number; + listCellWidth?: string; + rowHeight?: number; + ganttHeight?: number; + barCornerRadius?: number; + handleWidth?: number; + fontFamily?: string; + fontSize?: string; + /** + * How many of row width can be taken by task. + * From 0 to 100 + */ + barFill?: number; + barProgressColor?: string; + barProgressSelectedColor?: string; + barBackgroundColor?: string; + barBackgroundSelectedColor?: string; + projectProgressColor?: string; + projectProgressSelectedColor?: string; + projectBackgroundColor?: string; + projectBackgroundSelectedColor?: string; + milestoneBackgroundColor?: string; + milestoneBackgroundSelectedColor?: string; + arrowColor?: string; + arrowIndent?: number; + todayColor?: string; + TooltipContent?: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; + }>; + TaskListHeader?: React.FC<{ + headerHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + }>; + TaskListTable?: React.FC<{ + rowHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + locale: string; + tasks: Task[]; + selectedTaskId: string; + /** + * Sets selected task by id + */ + setSelectedTask: (taskId: string) => void; + onExpanderClick: (task: Task) => void; + }>; +} + +export interface GanttProps extends EventOption, DisplayOption, StylingOption { + tasks: Task[]; +} diff --git a/packages/core/client/src/schema-component/antd/index.ts b/packages/core/client/src/schema-component/antd/index.ts index 7811fbb21..e9ecf2197 100644 --- a/packages/core/client/src/schema-component/antd/index.ts +++ b/packages/core/client/src/schema-component/antd/index.ts @@ -40,4 +40,5 @@ export * from './time-picker'; export * from './tree-select'; export * from './upload'; export * from './variable'; +export * from './gantt' import './index.less'; diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx index 049da616a..2535496d9 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx @@ -167,6 +167,7 @@ export const Table: any = observer((props: any) => { rowSelection, rowKey, required, + onExpand, ...others } = { ...others1, ...others2 } as any; const schema = useFieldSchema(); @@ -209,11 +210,6 @@ export const Table: any = observer((props: any) => { return; }); }, [requiredValidator]); - // useEffect(() => { - // const data = field.value; - // field.value = null; - // field.value = data; - // }, [treeTable]); useEffect(() => { if (treeTable !== false) { @@ -227,7 +223,7 @@ export const Table: any = observer((props: any) => { } else { setExpandesKeys([]); } - }, [expandFlag]); + }, [expandFlag, allIncludesChildren]); const components = useMemo(() => { return { @@ -332,6 +328,8 @@ export const Table: any = observer((props: any) => { const pageSize = props?.pagination?.pageSize || 20; if (current) { index = index + (current - 1) * pageSize + 1; + } else { + index = index + 1; } if (record.__index) { index = extractIndex(record.__index); @@ -490,6 +488,7 @@ export const Table: any = observer((props: any) => { onExpand: (flag, record) => { const newKeys = flag ? [...expandedKeys, record.id] : expandedKeys.filter((i) => record.id !== i); setExpandesKeys(newKeys); + onExpand?.(flag, record); }, expandedRowKeys: expandedKeys, }} diff --git a/packages/core/client/src/schema-initializer/buttons/BlockInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/BlockInitializers.tsx index 0ead126e8..142959fb9 100644 --- a/packages/core/client/src/schema-initializer/buttons/BlockInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/BlockInitializers.tsx @@ -42,6 +42,12 @@ export const BlockInitializers = { title: '{{t("Kanban")}}', component: 'KanbanBlockInitializer', }, + { + key: 'Gantt', + type: 'item', + title: '{{t("Gantt")}}', + component: 'GanttBlockInitializer', + }, ], }, { diff --git a/packages/core/client/src/schema-initializer/buttons/GanttActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/GanttActionInitializers.tsx new file mode 100644 index 000000000..8305016da --- /dev/null +++ b/packages/core/client/src/schema-initializer/buttons/GanttActionInitializers.tsx @@ -0,0 +1,8 @@ +import { TableActionInitializers } from './TableActionInitializers'; +// 甘特图区块action配置 +export const GanttActionInitializers = { + ...TableActionInitializers, + // items: TableActionInitializers.items.filter((v) => { + // return v.component !== 'ActionBarAssociationFilterAction'; + // }), +}; diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx index 8375ecb80..705983f8c 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx @@ -96,14 +96,16 @@ export const TableActionInitializers = { }, visible: () => { const collection = useCollection(); - return (collection as any).template !== 'view'; + const schema = useFieldSchema(); + return (collection as any).template !== 'view' && schema['x-initializer'] !== 'GanttActionInitializers'; }, }, { type: 'divider', visible: () => { const collection = useCollection(); - return (collection as any).template !== 'view'; + const schema = useFieldSchema(); + return (collection as any).template !== 'view' && schema['x-initializer'] !== 'GanttActionInitializers'; }, }, { diff --git a/packages/core/client/src/schema-initializer/buttons/index.ts b/packages/core/client/src/schema-initializer/buttons/index.ts index 2cfddd22e..0b061f3e0 100644 --- a/packages/core/client/src/schema-initializer/buttons/index.ts +++ b/packages/core/client/src/schema-initializer/buttons/index.ts @@ -20,5 +20,6 @@ export * from './TableActionInitializers'; export * from './TableColumnInitializers'; export * from './TableSelectorInitializers'; export * from './TabPaneInitializers'; +export * from './GanttActionInitializers'; // association filter export * from '../../schema-component/antd/association-filter/AssociationFilter'; diff --git a/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx new file mode 100644 index 000000000..ce74088d3 --- /dev/null +++ b/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx @@ -0,0 +1,119 @@ +import React, { useContext } from 'react'; +import { FormDialog, FormLayout } from '@formily/antd'; +import { FormOutlined } from '@ant-design/icons'; +import { SchemaOptionsContext } from '@formily/react'; +import { useTranslation } from 'react-i18next'; + +import { useCollectionManager } from '../../collection-manager'; +import { SchemaComponent, SchemaComponentOptions } from '../../schema-component'; +import { createGanttBlockSchema } from '../utils'; +import { DataBlockInitializer } from './DataBlockInitializer'; + +export const GanttBlockInitializer = (props) => { + const { insert } = props; + const { t } = useTranslation(); + const { getCollectionFields } = useCollectionManager(); + const options = useContext(SchemaOptionsContext); + return ( + } + onCreateBlockSchema={async ({ item }) => { + const collectionFields = getCollectionFields(item.name); + const stringFields = collectionFields + ?.filter((field) => field.type === 'string') + ?.map((field) => { + return { + label: field?.uiSchema?.title, + value: field.name, + }; + }); + const dateFields = collectionFields + ?.filter((field) => field.type === 'date') + ?.map((field) => { + return { + label: field?.uiSchema?.title, + value: field.name, + }; + }); + const numberFields = collectionFields + ?.filter((field) => field.type === 'float') + ?.map((field) => { + return { + label: field?.uiSchema?.title, + value: field.name, + }; + }); + const values = await FormDialog(t('Create gantt block'), () => { + return ( + + + + + + ); + }).open({ + initialValues: {}, + }); + insert( + createGanttBlockSchema({ + collection: item.name, + fieldNames: { + ...values, + }, + }), + ); + }} + /> + ); +}; diff --git a/packages/core/client/src/schema-initializer/items/index.tsx b/packages/core/client/src/schema-initializer/items/index.tsx index 2673a0959..bf28447dc 100644 --- a/packages/core/client/src/schema-initializer/items/index.tsx +++ b/packages/core/client/src/schema-initializer/items/index.tsx @@ -28,6 +28,7 @@ export * from './FilterCollapseBlockInitializer'; export * from './FilterFormBlockInitializer'; export * from './FormBlockInitializer'; export * from './G2PlotInitializer'; +export * from './GanttBlockInitializer'; export * from './InitializerWithSwitch'; export * from './KanbanBlockInitializer'; export * from './MarkdownBlockInitializer'; diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 439c6681d..98bab3a63 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -1372,6 +1372,135 @@ export const createCalendarBlockSchema = (options) => { return schema; }; +export const createGanttBlockSchema = (options) => { + const { collection, resource, fieldNames, ...others } = options; + const schema: ISchema = { + type: 'void', + 'x-acl-action': `${resource || collection}:list`, + 'x-decorator': 'GanttBlockProvider', + 'x-decorator-props': { + collection: collection, + resource: resource || collection, + action: 'list', + fieldNames: { + id: 'id', + ...fieldNames, + }, + params: { + paginate: false, + }, + ...others, + }, + 'x-designer': 'Gantt.Designer', + 'x-component': 'CardItem', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'Gantt', + 'x-component-props': { + useProps: '{{ useGanttBlockProps }}', + }, + properties: { + toolBar: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 24, + }, + }, + 'x-initializer': 'GanttActionInitializers', + properties: {}, + }, + table: { + type: 'array', + 'x-decorator': 'div', + 'x-decorator-props': { + style: { + float: 'left', + maxWidth: '35%', + }, + }, + + 'x-initializer': 'TableColumnInitializers', + 'x-component': 'TableV2', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useProps: '{{ useTableBlockProps }}', + pagination: false, + }, + properties: { + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-action-column': 'actions', + 'x-decorator': 'TableV2.Column.ActionBar', + 'x-component': 'TableV2.Column', + 'x-designer': 'TableV2.ActionColumnDesigner', + 'x-initializer': 'TableActionColumnInitializers', + properties: { + actions: { + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: {}, + }, + }, + }, + }, + }, + detail: { + type: 'void', + 'x-component': 'Gantt.Event', + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-component-props': { + className: 'nb-action-popup', + }, + title: '{{ t("View record") }}', + 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: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + console.log(JSON.stringify(schema, null, 2)); + return schema; +}; export const createKanbanBlockSchema = (options) => { const { collection, resource, groupField, ...others } = options; const schema: ISchema = {