commit 80ca059fb8374df2fa92ac88e53b3a21beeb5d0e Author: Marta Kowalska Date: Wed Oct 22 08:13:59 2025 +0000 v2.3.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e5151 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +yarn.lock + +node_modules +dist +dist-demos +dist-full +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8185fec --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/bin/bash + +npx lint-staged diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..5b1b4b2 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + singleQuote: true, + semi: true, + useTabs: false, + tabWidth: 2, + trailingComma: 'all', + }; \ No newline at end of file diff --git a/.sync b/.sync new file mode 100644 index 0000000..2a6c28b --- /dev/null +++ b/.sync @@ -0,0 +1 @@ +0d06fa37fa91f51a38b15b4dac095537d366a653 diff --git a/demos/assets/icons/GitHubLogo.svg b/demos/assets/icons/GitHubLogo.svg new file mode 100644 index 0000000..5e59b65 --- /dev/null +++ b/demos/assets/icons/GitHubLogo.svg @@ -0,0 +1,3 @@ + + + diff --git a/demos/assets/icons/Logo.svg b/demos/assets/icons/Logo.svg new file mode 100644 index 0000000..5828774 --- /dev/null +++ b/demos/assets/icons/Logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/demos/assets/icons/index.js b/demos/assets/icons/index.js new file mode 100644 index 0000000..a5ea345 --- /dev/null +++ b/demos/assets/icons/index.js @@ -0,0 +1,2 @@ +export { default as GitHubLogoIcon } from './GitHubLogo.svg'; +export { default as LogoIcon } from './Logo.svg'; diff --git a/demos/cases/BasicInit.jsx b/demos/cases/BasicInit.jsx new file mode 100644 index 0000000..9e6cba4 --- /dev/null +++ b/demos/cases/BasicInit.jsx @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +export default function BasicInit({ skinSettings }) { + const data = useMemo(() => getData(), []); + + return ( + + ); +} diff --git a/demos/cases/ChartBorders.css b/demos/cases/ChartBorders.css new file mode 100644 index 0000000..a944913 --- /dev/null +++ b/demos/cases/ChartBorders.css @@ -0,0 +1,27 @@ +.rows.wx-gnwCbZe9 { + position: relative; + display: flex; + flex-direction: column; + background: var(--wx-background); + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-gnwCbZe9 { + padding: 10px; + + display: flex; + align-items: center; +} +.gtcell.wx-gnwCbZe9 { + position: relative; + height: 100%; + border-top: var(--wx-gantt-border); + overflow: hidden; +} +.label.wx-gnwCbZe9 { + padding-right: 20px; + font-size: var(--wx-font-size); + font-weight: var(--wx-label-font-weight); +} diff --git a/demos/cases/ChartBorders.jsx b/demos/cases/ChartBorders.jsx new file mode 100644 index 0000000..a2f7e2f --- /dev/null +++ b/demos/cases/ChartBorders.jsx @@ -0,0 +1,42 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import { RadioButtonGroup } from '@svar-ui/react-core'; +import './ChartBorders.css'; + +export default function ChartBorders({ skinSettings }) { + const data = useMemo(() => getData(), []); + const [cellBorders, setCellBorders] = useState('full'); + + const options = useMemo( + () => [ + { id: 'full', label: 'Full' }, + { id: 'column', label: 'Column' }, + ], + [], + ); + + return ( +
+
+
Chart cell borders
+ setCellBorders(value)} + type="inline" + /> +
+ +
+ +
+
+ ); +} diff --git a/demos/cases/ContextMenu.jsx b/demos/cases/ContextMenu.jsx new file mode 100644 index 0000000..cf2e866 --- /dev/null +++ b/demos/cases/ContextMenu.jsx @@ -0,0 +1,26 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu as ContextMenuComponent, Editor } from '../../src/'; + +function ContextMenu(props) { + const { skinSettings } = props; + const [api, setApi] = useState(null); + const data = useMemo(() => getData(), []); + + return ( + <> + + + + {api && } + + ); +} + +export default ContextMenu; diff --git a/demos/cases/ContextMenuHandler.jsx b/demos/cases/ContextMenuHandler.jsx new file mode 100644 index 0000000..6cbce52 --- /dev/null +++ b/demos/cases/ContextMenuHandler.jsx @@ -0,0 +1,41 @@ +import { useState, useMemo, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu, Editor } from '../../src/'; + +function ContextMenuHandler(props) { + const { skinSettings } = props; + const [api, setApi] = useState(); + + const data = useMemo(() => getData(), []); + + const resolver = useCallback((id) => { + return id > 2; + }, []); + + const filter = useCallback((option, task) => { + const type = task.type; + if (option.id) { + const ids = option.id.toString().split(':'); + if (type == 'milestone' && ids[0] == 'add-task') return ids[1] != 'child'; + } + + return true; + }, []); + + return ( + <> + + + + {api && } + + ); +} + +export default ContextMenuHandler; diff --git a/demos/cases/ContextMenuOptions.jsx b/demos/cases/ContextMenuOptions.jsx new file mode 100644 index 0000000..bcaa3a5 --- /dev/null +++ b/demos/cases/ContextMenuOptions.jsx @@ -0,0 +1,53 @@ +import { useState, useContext, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu, Editor, defaultMenuOptions } from '../../src/'; +import { context } from '@svar-ui/react-core'; + +export default function ContextMenuOptions({ skinSettings }) { + const helpers = useContext(context.helpers); + + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(); + + function actionHandler() { + helpers.showNotice({ text: "'My action' clicked" }); + } + + const [options] = useState(() => { + const ids = ['cut-task', 'copy-task', 'paste-task', 'delete-task']; + let arr = [{ id: 'add-task:after', text: ' Add below', icon: 'wxi-plus' }]; + arr = arr.concat( + defaultMenuOptions.filter((op) => ids.indexOf(op.id) >= 0), + ); + arr.push({ + id: 'my-action', + text: 'My action', + icon: 'wxi-empty', + handler: actionHandler, + }); + return arr; + }); + + function onClick({ context: ctx, action }) { + if (!action.handler) + helpers.showNotice({ + text: `'${action.id}' clicked for the '${ctx.id}' task`, + }); + } + + return ( + <> + + + + {api && } + + ); +} diff --git a/demos/cases/DropDownMenu.css b/demos/cases/DropDownMenu.css new file mode 100644 index 0000000..8f55386 --- /dev/null +++ b/demos/cases/DropDownMenu.css @@ -0,0 +1,17 @@ +.rows.wx-T6JFUSGo { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-T6JFUSGo { + padding: 12px; +} +.gtcell.wx-T6JFUSGo { + position: relative; + height: calc(100% - 56px); + border-top: var(--wx-gantt-border); +} diff --git a/demos/cases/DropDownMenu.jsx b/demos/cases/DropDownMenu.jsx new file mode 100644 index 0000000..95a8294 --- /dev/null +++ b/demos/cases/DropDownMenu.jsx @@ -0,0 +1,52 @@ +import { useRef, useState, useCallback, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu, Editor } from '../../src/'; +import { Button } from '@svar-ui/react-core'; +import './DropDownMenu.css'; + +export default function DropDownMenu({ skinSettings }) { + const apiRef = useRef(null); + const [api, setApi] = useState(null); + const menu = useRef(null); + + const data = useMemo(() => getData(), []); + + const resolver = useCallback(() => { + const inst = apiRef.current; + const selected = inst ? inst.getReactiveState().selected : null; + const id = + selected && selected.length ? selected[selected.length - 1] : null; + return id ? inst.getTask(id) : null; + }, []); + + return ( + <> + + +
+
+ +
+ +
+ { + apiRef.current = inst; + setApi(inst); + }} + {...skinSettings} + tasks={data.tasks} + links={data.links} + scales={data.scales} + /> + +
+
+ + ); +} diff --git a/demos/cases/GanttBackend.jsx b/demos/cases/GanttBackend.jsx new file mode 100644 index 0000000..66e291a --- /dev/null +++ b/demos/cases/GanttBackend.jsx @@ -0,0 +1,65 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Gantt, ContextMenu, Editor } from '../../src'; + +const parseDates = (data) => { + data.forEach((item) => { + item.start = new Date(item.start); + if (item.end) item.end = new Date(item.end); + }); + return data; +}; + + +export default function GanttBackend() { + const server = 'https://master--svar-gantt-go--dev.webix.io'; + + const [api, setApi] = useState(); + const [tasks, setTasks] = useState([]); + const [links, setLinks] = useState([]); + + useEffect(() => { + Promise.all([ + fetch(server + '/tasks') + .then((res) => res.json()) + .then((arr) => parseDates(arr)), + fetch(server + '/links').then((res) => res.json()), + ]).then(([t, l]) => { + setTasks(t); + setLinks(l); + }); + }, []); + + const init = useCallback( + (api) => { + setApi(api); + + api.on('request-data', (ev) => { + Promise.all([ + fetch(server + `/tasks/${ev.id}`) + .then((res) => res.json()) + .then((arr) => parseDates(arr)), + fetch(server + `/links/${ev.id}`).then((res) => res.json()), + ]).then(([tasks, links]) => { + api.exec('provide-data', { + id: ev.id, + data: { + tasks, + links, + }, + }); + }); + }); + }, + [], + ); + + + return ( + <> + + + + {api && } + + ); +} diff --git a/demos/cases/GanttBatchProvider.jsx b/demos/cases/GanttBatchProvider.jsx new file mode 100644 index 0000000..0bc4692 --- /dev/null +++ b/demos/cases/GanttBatchProvider.jsx @@ -0,0 +1,53 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { RestDataProvider } from '@svar-ui/gantt-data-provider'; +import { Gantt, ContextMenu, Editor } from '../../src'; + +export default function GanttBatchProvider() { + const restProvider = useMemo( + () => + new RestDataProvider('https://master--svar-gantt-go--dev.webix.io', { + batchURL: 'batch', + }), + [], + ); + + const [api, setApi] = useState(); + const [tasks, setTasks] = useState(); + const [links, setLinks] = useState(); + + useEffect(() => { + restProvider.getData().then(({ tasks: t, links: l }) => { + setTasks(t); + setLinks(l); + }); + }, [restProvider]); + + const init = useCallback( + (api) => { + setApi(api); + api.setNext(restProvider); + + api.on('request-data', (ev) => { + restProvider.getData(ev.id).then(({ tasks, links }) => { + api.exec('provide-data', { + id: ev.id, + data: { + tasks, + links, + }, + }); + }); + }); + }, + [restProvider], + ); + + return ( + <> + + + + {api && } + + ); +} diff --git a/demos/cases/GanttCustomSort.css b/demos/cases/GanttCustomSort.css new file mode 100644 index 0000000..c649a16 --- /dev/null +++ b/demos/cases/GanttCustomSort.css @@ -0,0 +1,31 @@ +.rows.wx-q0L4gF4x { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-q0L4gF4x { + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; +} + +.gtcell.wx-q0L4gF4x { + position: relative; + height: 100%; + border-top: var(--wx-gantt-border); + overflow: hidden; +} +.label.wx-q0L4gF4x { + padding-right: 10px; + font-size: var(--wx-font-size); + font-weight: var(--wx-label-font-weight); +} +i.wx-q0L4gF4x { + position: relative; + right: -5px; +} diff --git a/demos/cases/GanttCustomSort.jsx b/demos/cases/GanttCustomSort.jsx new file mode 100644 index 0000000..afb55c0 --- /dev/null +++ b/demos/cases/GanttCustomSort.jsx @@ -0,0 +1,70 @@ +import { useState, useMemo, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import { Button } from '@svar-ui/react-core'; +import './GanttCustomSort.css'; + +export default function GanttCustomSort({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [gApi, setGApi] = useState(null); + const [sortConfig, setSortConfig] = useState({}); + const getIcons = useCallback(() => { + const obj = { text: '', start: '', duration: '' }; + const { key, order } = sortConfig; + if (key) obj[key] = `wxi-arrow-${order == 'asc' ? 'up' : 'down'}`; + return obj; + }, [sortConfig]); + const [icons, setIcons] = useState(() => getIcons()); + + const sort = useCallback( + (id) => { + const { key, order } = sortConfig; + let newOrder = !key ? 'desc' : 'asc'; + + if (key === id) newOrder = order === 'asc' ? 'desc' : 'asc'; + + if (gApi) gApi.exec('sort-tasks', { key: id, order: newOrder }); + }, + [gApi, sortConfig], + ); + + const init = useCallback((api) => { + api.on('sort-tasks', (config) => { + setSortConfig(config); + // Compute icons based on the incoming config to mirror Svelte's sequential state updates + const obj = { text: '', start: '', duration: '' }; + const { key, order } = config; + if (key) obj[key] = `wxi-arrow-${order == 'asc' ? 'up' : 'down'}`; + setIcons(obj); + }); + setGApi(api); + }, []); + + return ( +
+
+
Sort by
+ + + +
+ +
+ +
+
+ ); +} diff --git a/demos/cases/GanttCustomZoom.css b/demos/cases/GanttCustomZoom.css new file mode 100644 index 0000000..8b2df15 --- /dev/null +++ b/demos/cases/GanttCustomZoom.css @@ -0,0 +1,12 @@ +.demo.wx-6q6Giv9n { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.gtcell.wx-6q6Giv9n { + overflow: hidden; + border: var(--wx-gantt-border); + height: calc(100% - 32px); +} diff --git a/demos/cases/GanttCustomZoom.jsx b/demos/cases/GanttCustomZoom.jsx new file mode 100644 index 0000000..f2c784f --- /dev/null +++ b/demos/cases/GanttCustomZoom.jsx @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; +import { getData, zoomConfig } from '../data'; +import { Gantt } from '../../src/'; +import './GanttCustomZoom.css'; + +const GanttCustomZoom = ({ skinSettings }) => { + const data = useMemo(() => getData(), []); + + return ( +
+

+ Point over Gantt chart, then hold Ctrl and use mouse wheel to zoom +

+
+ +
+
+ ); +}; + +export default GanttCustomZoom; diff --git a/demos/cases/GanttDurationUnitChanges.css b/demos/cases/GanttDurationUnitChanges.css new file mode 100644 index 0000000..629ffbf --- /dev/null +++ b/demos/cases/GanttDurationUnitChanges.css @@ -0,0 +1,28 @@ +.rows.wx-d4Cw5r6y { + display: flex; + flex-direction: column; + background: var(--wx-background); + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-d4Cw5r6y { + padding: 10px; + display: flex; + gap: 40px; +} +.bar.wx-d4Cw5r6y div { + display: flex; +} +.gtcell.wx-d4Cw5r6y { + position: relative; + height: 100%; + border-top: var(--wx-gantt-border); + overflow: hidden; +} +.label.wx-d4Cw5r6y { + padding-right: 20px; + font-size: var(--wx-font-size); + font-weight: var(--wx-label-font-weight); +} diff --git a/demos/cases/GanttDurationUnitChanges.jsx b/demos/cases/GanttDurationUnitChanges.jsx new file mode 100644 index 0000000..9483534 --- /dev/null +++ b/demos/cases/GanttDurationUnitChanges.jsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu, Editor, defaultEditorItems } from '../../src'; +import { RadioButtonGroup } from '@svar-ui/react-core'; +import './GanttDurationUnitChanges.css'; + +export default function GanttDurationUnitChanges({ skinSettings }) { + const initialData = getData(); + const [tasks, setTasks] = useState(initialData.tasks); + const [links] = useState(initialData.links); + + const scalesMap = useMemo( + () => ({ + hour: getData('hour').scales, + day: getData().scales, + }), + [], + ); + + const options = [ + { id: 'hour', label: 'Hour' }, + { id: 'day', label: 'Day' }, + ]; + + const [durationUnit, setDurationUnit] = useState('hour'); + const [scales, setScales] = useState(scalesMap['hour']); + + const [api, setApi] = useState(null); + + const items = useMemo( + () => + defaultEditorItems.map((ed) => ({ + ...ed, + ...(ed.comp === 'date' && { + config: { time: durationUnit === 'hour' }, + }), + })), + [durationUnit], + ); + + function handleUnitChange({ value }) { + const sTasks = api.serialize().map((task) => { + if (task.start && task.end) { + const ms = 1000 * 60 * 60 * (value === 'day' ? 24 : 1); + const duration = Math.floor((task.end - task.start) / ms); + return { ...task, duration }; + } + return task; + }); + setTasks(sTasks); + setDurationUnit(value); + setScales(scalesMap[value]); + } + + return ( +
+
+
Gantt duration unit
+ +
+ +
+ + + + {api && } +
+
+ ); +} diff --git a/demos/cases/GanttDurationUnitHour.jsx b/demos/cases/GanttDurationUnitHour.jsx new file mode 100644 index 0000000..0ad3aba --- /dev/null +++ b/demos/cases/GanttDurationUnitHour.jsx @@ -0,0 +1,63 @@ +import { useMemo, useState, useCallback } from 'react'; +import { + Gantt, + ContextMenu, + Editor, + defaultEditorItems, + defaultColumns, +} from '../../src'; +import { format } from 'date-fns'; +import { getData } from '../data'; + +export default function GanttDurationUnitHour({ skinSettings }) { + const { tasks, links, scales } = useMemo(() => getData('hour'), []); + + const [api, setApi] = useState(null); + + const items = useMemo( + () => + defaultEditorItems.map((ed) => ({ + ...ed, + ...(ed.comp === 'date' && { config: { time: true } }), + })), + [], + ); + + const columns = useMemo( + () => + defaultColumns.map((col) => ({ + ...col, + ...(col.id === 'start' && { + template: (d) => format(d, 'MMM d, HH:mm'), + width: 120, + }), + })), + [], + ); + + const highlightTime = useCallback((date, unit) => { + const h = date.getHours(); + if ((unit === 'hour' && h < 8) || h > 21) return 'wx-weekend'; + return ''; + }, []); + + return ( + <> + + + + {api && } + + ); +} diff --git a/demos/cases/GanttEditor.css b/demos/cases/GanttEditor.css new file mode 100644 index 0000000..0804c65 --- /dev/null +++ b/demos/cases/GanttEditor.css @@ -0,0 +1,14 @@ +.wx-6xDuvBVG .gantt_task_line .gantt_link_control { + display: flex; + justify-content: center; + align-items: center; +} + +.wx-6xDuvBVG .gantt_task_line .gantt_link_control .gantt_link_point { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + margin: 0 3px; +} diff --git a/demos/cases/GanttEditor.jsx b/demos/cases/GanttEditor.jsx new file mode 100644 index 0000000..5f8eb71 --- /dev/null +++ b/demos/cases/GanttEditor.jsx @@ -0,0 +1,34 @@ +import { useMemo, useState, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor } from '../../src/'; + +function GanttEditor(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(); + + const init = useCallback((ganttApi) => { + setApi(ganttApi); + // show Editor on "add-task" action + ganttApi.on('add-task', ({ id }) => { + ganttApi.exec('show-editor', { id }); + }); + }, []); + + return ( + <> + + {api && } + + ); +} + +export default GanttEditor; diff --git a/demos/cases/GanttEditorComments.jsx b/demos/cases/GanttEditorComments.jsx new file mode 100644 index 0000000..d2fcb11 --- /dev/null +++ b/demos/cases/GanttEditorComments.jsx @@ -0,0 +1,81 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { + Gantt, + Editor, + defaultEditorItems, + registerEditorItem, +} from '../../src'; +import { Comments } from '@svar-ui/react-comments'; +registerEditorItem('comments', Comments); + +function GanttEditorComments(props) { + const { skinSettings } = props; + + const data = useMemo(() => { + const data = getData(); + data.tasks.forEach((d, i) => { + d.comments = !i + ? [ + { + id: 1, + user: 1, + content: + 'Greetings, fellow colleagues. I would like to share my insights on this task. I reckon we should deal with at least half of the points in the plan without further delays.', + date: new Date(), + }, + { + id: 2, + user: 2, + content: + "Hi, Diego. I am sure that that's exactly what is thought best out there in Dunwall. Let's just do what we are supposed to do to get the result.", + date: new Date(), + }, + { + id: 5, + user: 3, + content: + "Absolutely, Diego. Action speaks louder than words, and in this case, it's about executing the plan efficiently. Let's prioritize tasks and tackle them head-on.", + date: new Date(), + }, + ] + : []; + }); + return data; + }, []); + + const [api, setApi] = useState(); + const keys = useMemo(() => ['text', 'details'], []); + const items = useMemo(() => { + const items = defaultEditorItems.filter((op) => keys.indexOf(op.key) >= 0); + items.push({ + key: 'comments', + comp: 'comments', + label: 'Comments', + users: [ + { id: 1, name: 'Alex' }, + { id: 2, name: 'John' }, + { id: 3, name: 'Bob' }, + { id: 4, name: 'Mary' }, + { id: 5, name: 'Kate' }, + ], + activeUser: 1, + }); + return items; + }, [keys]); + + return ( + <> + + {api && } + + ); +} + +export default GanttEditorComments; diff --git a/demos/cases/GanttEditorConfig.jsx b/demos/cases/GanttEditorConfig.jsx new file mode 100644 index 0000000..946d70a --- /dev/null +++ b/demos/cases/GanttEditorConfig.jsx @@ -0,0 +1,56 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor, defaultEditorItems } from '../../src'; + +function GanttEditorConfig({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(); + + const bottomBar = useMemo( + () => ({ + items: [ + { comp: 'button', type: 'secondary', text: 'Close', id: 'close' }, + { comp: 'spacer' }, + { comp: 'button', type: 'danger', text: 'Delete', id: 'delete' }, + { comp: 'button', type: 'primary', text: 'Save', id: 'save' }, + ], + }), + [], + ); + + const keys = useMemo( + () => ['text', 'type', 'start', 'end', 'duration', 'progress', 'details'], + [], + ); + + const items = useMemo( + () => + keys.map((key) => ({ + ...defaultEditorItems.find((op) => op.key === key), + })), + [keys], + ); + + return ( + <> + + {api && } + + ); +} + +export default GanttEditorConfig; diff --git a/demos/cases/GanttEditorCustomControls.jsx b/demos/cases/GanttEditorCustomControls.jsx new file mode 100644 index 0000000..0cb82da --- /dev/null +++ b/demos/cases/GanttEditorCustomControls.jsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Gantt, + Editor, + defaultEditorItems, + registerEditorItem, + defaultTaskTypes, +} from '../../src'; +import { RadioButtonGroup } from '@svar-ui/react-core'; +import UsersCustomCombo from '../custom/UsersCustomCombo.jsx'; +import AvatarCell from '../custom/AvatarCell.jsx'; +import { getData, users } from '../data'; + +export default function GanttEditorCustomControls({ skinSettings }) { + useEffect(() => { + registerEditorItem('radio', RadioButtonGroup); + registerEditorItem('custom-combo', UsersCustomCombo); + }, []); + + const items = useMemo(() => { + const items = defaultEditorItems.map((item) => ({ ...item })); + items.splice( + defaultEditorItems.findIndex((d) => d.key === 'type'), + 1, + { + key: 'type', + comp: 'radio', + label: 'Type', + options: defaultTaskTypes.map((o) => ({ + ...o, + value: o.id, + })), + config: { + type: 'inline', + }, + }, + { + key: 'assigned', + comp: 'custom-combo', + label: 'Assigned', + options: users, + }, + ); + + items.forEach((d) => { + if (d.comp === 'date') { + d.config = { + time: true, + }; + } + }); + + return items; + }, []); + + const data = useMemo(() => getData(), []); + const [api, setApi] = useState(); + + const columns = useMemo( + () => [ + { id: 'text', header: 'Task name', width: 220 }, + { id: 'assigned', header: 'Assigned', width: 160, cell: AvatarCell }, + { id: 'start', header: 'Start Date', width: 100 }, + ], + [], + ); + + return ( + <> + + {api && } + + ); +} diff --git a/demos/cases/GanttEditorReadonly.jsx b/demos/cases/GanttEditorReadonly.jsx new file mode 100644 index 0000000..37c9dad --- /dev/null +++ b/demos/cases/GanttEditorReadonly.jsx @@ -0,0 +1,23 @@ +import { useMemo, useState } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor } from '../../src'; + +function GanttEditorReadonly({...skinSettings}) { + const data = useMemo(() => getData(), []); + const [api, setApi] = useState(); + + return ( + <> + + {api && } + + ); +} + +export default GanttEditorReadonly; diff --git a/demos/cases/GanttEditorTasks.jsx b/demos/cases/GanttEditorTasks.jsx new file mode 100644 index 0000000..e591a48 --- /dev/null +++ b/demos/cases/GanttEditorTasks.jsx @@ -0,0 +1,96 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { + Gantt, + Editor, + defaultEditorItems, + registerEditorItem, +} from '../../src'; +import { Tasklist } from '@svar-ui/react-tasklist'; + +registerEditorItem('tasks', Tasklist); + +export default function GanttEditorTasks({ skinSettings }) { + const data = useMemo(() => { + const data = getData(); + data.tasks.forEach((d, i) => { + d.tasks = !i + ? [ + { + id: 1, + content: + 'Research best practices for integrating third-party libraries with React', + status: 1, + }, + { + id: 2, + content: + 'Explore modern approaches to building applications using React', + status: 0, + }, + { + id: 3, + content: + 'Explore different methods for integrating React with existing JavaScript frameworks', + status: 0, + }, + { + id: 4, + date: new Date(), + content: 'Learn about routing in React using React Router', + status: 1, + }, + { + id: 5, + content: + 'Understand principles and best practices for component development in React', + status: 0, + }, + { + id: 6, + content: + 'Explore different methods for integrating React with existing JavaScript frameworks', + status: 0, + }, + { + id: 7, + content: 'Optimize performance in React applications', + status: 0, + }, + { + id: 8, + content: + 'Work with API requests and data handling in React applications', + status: 0, + }, + ] + : []; + }); + return data; + }, []); + + const items = useMemo(() => { + const keys = ['text', 'details']; + const items = defaultEditorItems.filter((op) => keys.indexOf(op.key) >= 0); + items.push({ + key: 'tasks', + comp: 'tasks', + label: 'Tasks', + }); + return items; + }, []); + + const [api, setApi] = useState(); + return ( + <> + + {api && } + + ); +} diff --git a/demos/cases/GanttEditorValidation.css b/demos/cases/GanttEditorValidation.css new file mode 100644 index 0000000..74bd774 --- /dev/null +++ b/demos/cases/GanttEditorValidation.css @@ -0,0 +1,4 @@ +.wx-22L1T8uz { + width: 100%; + height: 100%; +} diff --git a/demos/cases/GanttEditorValidation.jsx b/demos/cases/GanttEditorValidation.jsx new file mode 100644 index 0000000..ee56b32 --- /dev/null +++ b/demos/cases/GanttEditorValidation.jsx @@ -0,0 +1,36 @@ +import { useMemo, useState } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor, defaultEditorItems } from '../../src'; + +function GanttEditorValidation({ skinSettings }) { + const data = useMemo(() => getData(), []); + const [api, setApi] = useState(); + + const items = useMemo( + () => + defaultEditorItems.map((ed) => ({ + ...ed, + ...(ed.comp === 'text' && { required: true }), + ...(ed.comp === 'counter' && { + validation: (v) => v <= 50, + validationMessage: 'Task duration should not exceed 50 days', + }), + })), + [], + ); + + return ( + <> + + {api && } + + ); +} + +export default GanttEditorValidation; diff --git a/demos/cases/GanttFixedColumns.jsx b/demos/cases/GanttFixedColumns.jsx new file mode 100644 index 0000000..7abfbb2 --- /dev/null +++ b/demos/cases/GanttFixedColumns.jsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +export default function GanttFixedColumns(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + const columns = useMemo( + () => [ + { id: 'text', header: 'Task name', width: 120 }, + { + id: 'start', + header: 'Start date', + width: 120, + align: 'center', + }, + { + id: 'duration', + header: 'Duration', + width: 90, + align: 'center', + }, + { id: 'add-task', header: 'Add task', width: 50, align: 'center' }, + ], + [], + ); + + return ( + + ); +} diff --git a/demos/cases/GanttFlexColumns.jsx b/demos/cases/GanttFlexColumns.jsx new file mode 100644 index 0000000..97c1bf1 --- /dev/null +++ b/demos/cases/GanttFlexColumns.jsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +function GanttFlexColumns({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const columns = useMemo( + () => [ + { id: 'text', header: 'Task name', flexgrow: 2 }, + { + id: 'start', + header: 'Start date', + flexgrow: 1, + align: 'center', + }, + { + id: 'duration', + header: 'Duration', + align: 'center', + flexgrow: 1, + }, + { + id: 'add-task', + header: 'Add task', + width: 50, + align: 'center', + }, + ], + [], + ); + + return ( + + ); +} + +export default GanttFlexColumns; diff --git a/demos/cases/GanttForm.css b/demos/cases/GanttForm.css new file mode 100644 index 0000000..3d25fb7 --- /dev/null +++ b/demos/cases/GanttForm.css @@ -0,0 +1,4 @@ +.wrapper.wx-0moLF6Ul { + height: 100%; + overflow: hidden; +} diff --git a/demos/cases/GanttForm.jsx b/demos/cases/GanttForm.jsx new file mode 100644 index 0000000..c8fac0c --- /dev/null +++ b/demos/cases/GanttForm.jsx @@ -0,0 +1,50 @@ +import { useState, useCallback, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, defaultTaskTypes } from '../../src/'; +import Form from '../custom/Form.jsx'; +import './GanttForm.css'; + +export default function GanttForm({ skinSettings }) { + const data = useMemo(() => getData(), []); + const taskTypes = defaultTaskTypes; + + const [task, setTask] = useState(); + const [gApi, setGApi] = useState(); + + const formAction = useCallback( + ({ action, data }) => { + switch (action) { + case 'close-form': + setTask(null); + break; + default: + gApi.exec(action, data); + break; + } + }, + [gApi], + ); + + const init = useCallback((api) => { + api.intercept('show-editor', ({ id }) => { + if (id) setTask(api.getState().tasks.byId(id)); + return false; + }); + setGApi(api); + }, []); + + return ( +
+ + {task ? ( +
+ ) : null} +
+ ); +} diff --git a/demos/cases/GanttFullscreen.css b/demos/cases/GanttFullscreen.css new file mode 100644 index 0000000..e25201e --- /dev/null +++ b/demos/cases/GanttFullscreen.css @@ -0,0 +1,12 @@ +.demo.wx-0qqHrQ85 { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.gtcell.wx-0qqHrQ85 { + overflow: hidden; + border: var(--wx-gantt-border); + height: calc(100% - 32px); +} diff --git a/demos/cases/GanttFullscreen.jsx b/demos/cases/GanttFullscreen.jsx new file mode 100644 index 0000000..5cc196e --- /dev/null +++ b/demos/cases/GanttFullscreen.jsx @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, Fullscreen } from '../../src/'; +import './GanttFullscreen.css'; + +function GanttFullscreen({ skinSettings }) { + const data = useMemo(() => getData(), []); + + return ( +
+

Click the "expand" icon, or click on Gantt and press Ctrl+Shift+F

+
+ + + +
+
+ ); +} + +export default GanttFullscreen; diff --git a/demos/cases/GanttGrid.jsx b/demos/cases/GanttGrid.jsx new file mode 100644 index 0000000..58baa03 --- /dev/null +++ b/demos/cases/GanttGrid.jsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import AvatarCell from '../custom/AvatarCell.jsx'; +import NameAndDateCell from '../custom/NameAndDateCell.jsx'; +import AddTaskCell from '../custom/AddTaskCell.jsx'; + +function GanttGrid({ skinSettings }) { + const columns = useMemo( + () => [ + { id: 'text', header: 'Task', width: 220, cell: NameAndDateCell }, + { id: 'assigned', header: 'Assigned', width: 160, cell: AvatarCell }, + { + id: 'add-task', + header: { cell: AddTaskCell }, + align: 'center', + width: 80, + }, + ], + [], + ); + + const data = useMemo(() => getData(), []); + + return ( + + ); +} + +export default GanttGrid; diff --git a/demos/cases/GanttHolidays.jsx b/demos/cases/GanttHolidays.jsx new file mode 100644 index 0000000..f4b5024 --- /dev/null +++ b/demos/cases/GanttHolidays.jsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +function GanttHolidays({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const scales = useMemo( + () => [ + { unit: 'year', step: 1, format: 'yyyy' }, + { unit: 'month', step: 2, format: 'MMMM yyy' }, + { unit: 'week', step: 1, format: 'wo' }, + { unit: 'day', step: 1, format: 'd, EEEE' /* , css: dayStyle */ } + ], + [], + ); + + function isDayOff(date) { + const d = date.getDay(); + return d == 0 || d == 6; + } + + function isHourOff(date) { + const h = date.getHours(); + return h < 8 || h == 12 || h > 17; + } + + function highlightTime(d, u) { + if (u == 'day' && isDayOff(d)) return 'wx-weekend'; + if (u == 'hour' && (isDayOff(d) || isHourOff(d))) return 'wx-weekend'; + return ''; + } + + return ( + + ); +} + +export default GanttHolidays; diff --git a/demos/cases/GanttLengthUnit.css b/demos/cases/GanttLengthUnit.css new file mode 100644 index 0000000..d631f50 --- /dev/null +++ b/demos/cases/GanttLengthUnit.css @@ -0,0 +1,18 @@ +.demo.wx-VOqDTkHq { + height: 100%; + display: flex; + flex-direction: column; +} + +.bar.wx-VOqDTkHq { + padding: 20px; + background-color: var(--wx-background); + border: var(--wx-border); + + --wx-input-width: 100px; +} + +.gantt.wx-VOqDTkHq { + position: relative; + height: calc(100% - 74px); +} diff --git a/demos/cases/GanttLengthUnit.jsx b/demos/cases/GanttLengthUnit.jsx new file mode 100644 index 0000000..380b73d --- /dev/null +++ b/demos/cases/GanttLengthUnit.jsx @@ -0,0 +1,86 @@ +import { useMemo, useState } from 'react'; +import { getData, bigScales } from '../data'; +import { Gantt } from '../../src/'; +import { Select } from '@svar-ui/react-core'; +import './GanttLengthUnit.css'; + +function GanttLengthUnit({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const options = [ + { id: 'minute', label: 'Minute' }, + { id: 'hour', label: 'Hour' }, + { id: 'day', label: 'Day' }, + { id: 'week', label: 'Week' }, + { id: 'month', label: 'Month' }, + { id: 'quarter', label: 'Quarter' }, + ]; + + const [lengthUnit, setLengthUnit] = useState('day'); + + const scales = useMemo(() => { + let scales; + switch (lengthUnit) { + case 'minute': + scales = [ + { unit: 'day', step: 1, format: 'MMM d' }, + { unit: 'hour', step: 1, format: 'HH:mm' }, + ]; + break; + case 'hour': + scales = [ + { unit: 'month', step: 1, format: 'MMM' }, + { unit: 'day', step: 1, format: 'MMM d' }, + ]; + break; + case 'day': + scales = [ + { unit: 'month', step: 1, format: 'MMM' }, + { unit: 'week', step: 1, format: 'w' }, + ]; + break; + case 'week': + scales = [ + { unit: 'year', step: 1, format: 'yyyy' }, + { unit: 'month', step: 1, format: 'MMM' }, + ]; + break; + case 'month': + scales = [ + { unit: 'year', step: 1, format: 'yyyy' }, + { unit: 'quarter', step: 1, format: 'QQQ' }, + ]; + break; + case 'quarter': + scales = [{ unit: 'year', step: 1, format: 'yyyy' }]; + break; + default: + scales = bigScales; + } + return scales; + }, [lengthUnit]); + + return ( +
+
+ setScaleOption(value)} + /> +
+
+ +
+
+ ); +} diff --git a/demos/cases/GanttMultiple.css b/demos/cases/GanttMultiple.css new file mode 100644 index 0000000..6076bdd --- /dev/null +++ b/demos/cases/GanttMultiple.css @@ -0,0 +1,26 @@ +.rows.wx-NlISAKUQ { + display: flex; + flex-direction: column; + height: 100%; +} + +.row.wx-NlISAKUQ { + padding: 13px; +} + +.ganttCell.wx-NlISAKUQ { + height: 410px; + width: 100%; + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.ganttHeader.wx-NlISAKUQ { + padding: 13px; + border-bottom: var(--wx-gantt-border); +} + +.ganttBox.wx-NlISAKUQ { + overflow: hidden; +} diff --git a/demos/cases/GanttMultiple.jsx b/demos/cases/GanttMultiple.jsx new file mode 100644 index 0000000..d5e579b --- /dev/null +++ b/demos/cases/GanttMultiple.jsx @@ -0,0 +1,72 @@ +import { useRef, useState } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import { Button } from '@svar-ui/react-core'; +import './GanttMultiple.css'; + +function GanttMultiple(props) { + const { skinSettings } = props; + + const counterRef = useRef(0); + const [gantts, setGantts] = useState(() => { + const initial = []; + initial.push({ + id: counterRef.current, + data: getData(), + }); + counterRef.current += 1; + initial.push({ + id: counterRef.current, + data: getData(), + }); + counterRef.current += 1; + return initial; + }); + + function addGantt() { + setGantts((prev) => { + const next = [ + ...prev, + { + id: counterRef.current, + data: getData(), + }, + ]; + counterRef.current += 1; + return next; + }); + } + + function removeGantt(id) { + setGantts((prev) => prev.filter((a) => a.id !== id)); + } + + return ( +
+
+ +
+ + {gantts.map((gantt, id) => ( +
+
+ +
+
+ +
+
+ ))} +
+ ); +} + +export default GanttMultiple; diff --git a/demos/cases/GanttNoGrid.jsx b/demos/cases/GanttNoGrid.jsx new file mode 100644 index 0000000..6b45d31 --- /dev/null +++ b/demos/cases/GanttNoGrid.jsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +function GanttNoGrid(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + return ( + + ); +} + +export default GanttNoGrid; diff --git a/demos/cases/GanttPerformance.css b/demos/cases/GanttPerformance.css new file mode 100644 index 0000000..b9063c9 --- /dev/null +++ b/demos/cases/GanttPerformance.css @@ -0,0 +1,26 @@ +.rows.wx-KB3Eoqwm { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.row.wx-KB3Eoqwm { + padding: 13px; + font-family: var(--wx-font-family); + font-size: var(--wx-font-size); +} + +.gtcell.wx-KB3Eoqwm { + position: relative; + height: 100%; + min-height: 0; + border-top: var(--wx-gantt-border); + margin-bottom: 10px; +} + +.gtcell.wx-KB3Eoqwm:last-of-type { + margin-bottom: 0; +} diff --git a/demos/cases/GanttPerformance.jsx b/demos/cases/GanttPerformance.jsx new file mode 100644 index 0000000..26aa938 --- /dev/null +++ b/demos/cases/GanttPerformance.jsx @@ -0,0 +1,52 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { getGeneratedData, complexScales } from '../data'; +import { Gantt } from '../../src/'; +import { Button } from '@svar-ui/react-core'; +import './GanttPerformance.css'; + +function GanttPerformance(props) { + const { skinSettings } = props; + + const count = 1000; + const years = 3; + const data = useMemo(() => getGeneratedData('', count, years), []); + + const [start, setStart] = useState(null); + const outAreaRef = useRef(null); + + useEffect(() => { + if (start && outAreaRef.current) { + outAreaRef.current.innerHTML = new Date() - start; + } + }, [start]); + + return ( +
+
+ {start ? ( + <> + 1 000 tasks ({years} years ) rendered in{' '} + ms + + ) : ( + + )} +
+ + {start ? ( +
+ +
+ ) : null} +
+ ); +} + +export default GanttPerformance; diff --git a/demos/cases/GanttPreventActions.css b/demos/cases/GanttPreventActions.css new file mode 100644 index 0000000..71694a9 --- /dev/null +++ b/demos/cases/GanttPreventActions.css @@ -0,0 +1,38 @@ +.rows.wx-RPSbwjNq { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; +} + +.bar.wx-RPSbwjNq { + display: flex; + height: 60px; + align-items: end; + font-family: var(--wx-font-family); + font-size: var(--wx-font-size); + padding-top: 12px; + --wx-label-width: 130px; +} + +.bar.wx-RPSbwjNq:first-child { + margin-left: 10px; +} + +.gantt.wx-RPSbwjNq { + height: 100%; + position: relative; + border-top: var(--wx-gantt-border); + overflow: hidden; +} + +.gantt.wx-RPSbwjNq.hide-progress > .wx-gantt .wx-bar .wx-progress-marker { + display: none; +} +.gantt.wx-RPSbwjNq.hide-links > .wx-gantt .wx-bar .wx-link { + display: none; +} +.gantt.wx-RPSbwjNq.hide-drag > .wx-gantt .wx-bar { + cursor: pointer !important; +} diff --git a/demos/cases/GanttPreventActions.jsx b/demos/cases/GanttPreventActions.jsx new file mode 100644 index 0000000..3e3d9f3 --- /dev/null +++ b/demos/cases/GanttPreventActions.jsx @@ -0,0 +1,122 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt, defaultColumns, Editor } from '../../src/'; +import { Field, Switch } from '@svar-ui/react-core'; +import './GanttPreventActions.css'; + +function GanttPreventActions({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [edit, setEdit] = useState(true); // if false - cannot add and edit task + const [drag, setDrag] = useState(true); // if false - cannot drag tasks on scale + const [order, setOrder] = useState(true); // if false - cannot reorder tasks in grid + const [newLink, setNewLink] = useState(true); // if false - cannot create new links + + const ignoreRef = useRef(false); + const [api, setApi] = useState(); + + const editRef = useRef(edit); + const dragRef = useRef(drag); + const orderRef = useRef(order); + + useEffect(() => { + editRef.current = edit; + }, [edit]); + + useEffect(() => { + dragRef.current = drag; + }, [drag]); + + useEffect(() => { + orderRef.current = order; + }, [order]); + + const init = useCallback((gApi) => { + setApi(gApi); + + gApi.intercept('show-editor', () => editRef.current || ignoreRef.current); + gApi.intercept('drag-task', (ev) => { + if (typeof ev.top !== 'undefined') return orderRef.current; + return dragRef.current; // ev.width && ev.left + }); + }, []); + + const columns = useMemo( + () => + edit ? defaultColumns : defaultColumns.filter((a) => a.id != 'add-task'), + [edit], + ); + + // for demo purposes: close editor when checkbox is unchecked + useEffect(() => { + if (!edit) { + ignoreRef.current = true; + api.exec('show-editor', { id: null }); + ignoreRef.current = false; + } + }, [edit, api]); + + return ( +
+
+ + {({ id }) => ( + setEdit(value)} + id={id} + /> + )} + + + {({ id }) => ( + setNewLink(value)} + id={id} + /> + )} + + + {({ id }) => ( + setDrag(value)} + id={id} + /> + )} + + + {({ id }) => ( + setOrder(value)} + id={id} + /> + )} + +
+
+ + {api && } +
+
+ ); +} + +export default GanttPreventActions; diff --git a/demos/cases/GanttProvider.jsx b/demos/cases/GanttProvider.jsx new file mode 100644 index 0000000..f9ee5db --- /dev/null +++ b/demos/cases/GanttProvider.jsx @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { RestDataProvider } from '@svar-ui/gantt-data-provider'; +import { Gantt, ContextMenu, Editor } from '../../src'; + +export default function GanttProvider() { + const restProvider = useMemo( + () => new RestDataProvider('https://master--svar-gantt-go--dev.webix.io'), + [], + ); + + const [api, setApi] = useState(); + const [tasks, setTasks] = useState(); + const [links, setLinks] = useState(); + + useEffect(() => { + restProvider.getData().then(({ tasks: t, links: l }) => { + setTasks(t); + setLinks(l); + }); + }, [restProvider]); + + const init = useCallback( + (api) => { + setApi(api); + + api.setNext(restProvider); + + api.on('request-data', (ev) => { + restProvider.getData(ev.id).then(({ tasks, links }) => { + api.exec('provide-data', { + id: ev.id, + data: { + tasks, + links, + }, + }); + }); + }); + }, + [restProvider], + ); + + return ( + <> + + + + {api && } + + ); +} diff --git a/demos/cases/GanttReadOnly.jsx b/demos/cases/GanttReadOnly.jsx new file mode 100644 index 0000000..49ed6b0 --- /dev/null +++ b/demos/cases/GanttReadOnly.jsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +export default function GanttReadOnly({ skinSettings }) { + const data = useMemo(() => getData(), []); + + return ( + + ); +} diff --git a/demos/cases/GanttScaleUnit.jsx b/demos/cases/GanttScaleUnit.jsx new file mode 100644 index 0000000..eb133dd --- /dev/null +++ b/demos/cases/GanttScaleUnit.jsx @@ -0,0 +1,87 @@ +import { useMemo, useRef } from 'react'; +import { getData } from '../data'; +import { Gantt, registerScaleUnit } from '../../src/'; +import { + startOfMonth, + endOfMonth, + isSameMonth, + addMonths, + addDays, + format, +} from 'date-fns'; + +export default function GanttScaleUnit(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + const getMidDate = (d) => { + const m = d.getMonth(); + return m === 1 ? 15 : 16; + }; + + const sprintStart = (d) => { + const monthStart = startOfMonth(d); + const midDate = getMidDate(d); + if (d.getDate() >= midDate) monthStart.setDate(midDate); + return monthStart; + }; + + const sprintEnd = (d) => { + const monthEnd = endOfMonth(d); + const midDate = getMidDate(d); + if (d.getDate() < midDate) monthEnd.setDate(midDate - 1); + return monthEnd; + }; + + const sprintFormat = (d) => { + const monthStr = format(d, 'MMMM'); + const start = d.getDate(); + const end = sprintEnd(d).getDate(); + return `${monthStr} ${start} - ${end}`; + }; + + const registeredRef = useRef(false); + if (!registeredRef.current) { + registerScaleUnit('sprint', { + start: sprintStart, + end: sprintEnd, + isSame: (a, b) => { + const sameMonth = isSameMonth(a, b); + if (!sameMonth) return false; + const midDate = getMidDate(a); + return a.getDate() < midDate == b.getDate() < midDate; + }, + add: (d, amount) => { + const date = d.getDate(); + const start = sprintStart(d); + const diff = date - start.getDate(); + let newDate = addMonths(start, Math.floor(amount / 2)); + const midDate = getMidDate(newDate); + if (amount % 2) { + newDate = addDays(newDate, midDate); + newDate = sprintStart(newDate); + } + return addDays(newDate, diff); + }, + }); + registeredRef.current = true; + } + + return ( + + ); +} diff --git a/demos/cases/GanttScales.jsx b/demos/cases/GanttScales.jsx new file mode 100644 index 0000000..212edb0 --- /dev/null +++ b/demos/cases/GanttScales.jsx @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; +import { getData, complexScales } from '../data'; +import { Gantt } from '../../src/'; + +function GanttScales({ skinSettings }) { + const data = useMemo(() => getData(), []); + + return ( + + ); +} + +export default GanttScales; diff --git a/demos/cases/GanttSizes.css b/demos/cases/GanttSizes.css new file mode 100644 index 0000000..7ae458e --- /dev/null +++ b/demos/cases/GanttSizes.css @@ -0,0 +1,19 @@ +.rows.wx-MZ5YXrqJ { + position: relative; + display: flex; + flex-direction: column; + background: var(--wx-background); + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-MZ5YXrqJ { + padding: 10px; +} +.gtcell.wx-MZ5YXrqJ { + position: relative; + height: 100%; + border-top: var(--wx-gantt-border); + overflow: hidden; +} diff --git a/demos/cases/GanttSizes.jsx b/demos/cases/GanttSizes.jsx new file mode 100644 index 0000000..de4d118 --- /dev/null +++ b/demos/cases/GanttSizes.jsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from 'react'; +import { getData, complexScales } from '../data'; +import { Gantt } from '../../src/'; +import { Slider } from '@svar-ui/react-core'; +import './GanttSizes.css'; + +function GanttSizes({ skinSettings }) { + const data = useMemo(() => getData(), []); + const [cellWidth, setCellWidth] = useState(100); + const [scaleHeight, setScaleHeight] = useState(38); + const [cellHeight, setCellHeight] = useState(36); + + return ( +
+
+ setCellWidth(value)} + min={20} + max={200} + /> + setCellHeight(value)} + min={20} + max={60} + /> + setScaleHeight(value)} + min={20} + max={60} + /> +
+ +
+ +
+
+ ); +} + +export default GanttSizes; diff --git a/demos/cases/GanttSort.css b/demos/cases/GanttSort.css new file mode 100644 index 0000000..a356147 --- /dev/null +++ b/demos/cases/GanttSort.css @@ -0,0 +1,12 @@ +.demo.wx-fr1PMpDW { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.gtcell.wx-fr1PMpDW { + overflow: hidden; + border: var(--wx-gantt-border); + height: calc(100% - 32px); +} diff --git a/demos/cases/GanttSort.jsx b/demos/cases/GanttSort.jsx new file mode 100644 index 0000000..3d19aa7 --- /dev/null +++ b/demos/cases/GanttSort.jsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { getData, zoomConfig } from '../data'; +import { Gantt } from '../../src/'; +import './GanttSort.css'; + +function GanttSort({ skinSettings }) { + const data = useMemo(() => getData(), []); + + function init(api) { + api.intercept('sort-tasks', (config) => { + return config.key == 'text'; + }); + } + + return ( +
+

Sorting by the "Task Name" column only

+
+ +
+
+ ); +} + +export default GanttSort; diff --git a/demos/cases/GanttStartEnd.css b/demos/cases/GanttStartEnd.css new file mode 100644 index 0000000..1270207 --- /dev/null +++ b/demos/cases/GanttStartEnd.css @@ -0,0 +1,21 @@ +.demo.wx-FJQN2sNt { + height: 100%; + display: flex; + flex-direction: column; +} + +.bar.wx-FJQN2sNt { + display: flex; + padding: 12px; + border-bottom: var(--wx-gantt-border); +} + +.gantt.wx-FJQN2sNt { + position: relative; + height: 100%; + overflow: hidden; +} + +.input.wx-FJQN2sNt { + margin: 4px; +} diff --git a/demos/cases/GanttStartEnd.jsx b/demos/cases/GanttStartEnd.jsx new file mode 100644 index 0000000..9eeac5c --- /dev/null +++ b/demos/cases/GanttStartEnd.jsx @@ -0,0 +1,63 @@ +import { useMemo, useState } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import { DatePicker, Field, Locale, Switch } from '@svar-ui/react-core'; +import './GanttStartEnd.css'; + +export default function GanttStartEnd({ skinSettings }) { + const data = useMemo(() => getData(), []); + const [start, setStart] = useState(new Date(2024, 3, 5)); + const [end, setEnd] = useState(new Date(2024, 4, 1)); + const [autoScale, setAutoScale] = useState(false); + + return ( +
+ +
+ + {({ id }) => ( + setStart(value)} + /> + )} + + + {({ id }) => ( + setEnd(value)} + /> + )} + + + {({ id }) => ( +
+ setAutoScale(value)} + /> +
+ )} +
+
+
+ +
+ +
+
+ ); +} diff --git a/demos/cases/GanttSummariesConvert.jsx b/demos/cases/GanttSummariesConvert.jsx new file mode 100644 index 0000000..b08fc1a --- /dev/null +++ b/demos/cases/GanttSummariesConvert.jsx @@ -0,0 +1,103 @@ +import { useMemo, useCallback, useRef } from 'react'; +import { useStoreLater } from '@svar-ui/lib-react'; +import { getData } from '../data'; +import { + Gantt, + ContextMenu, + Editor, + defaultEditorItems, + defaultTaskTypes, +} from '../../src/'; + +export default function GanttSummariesConvert({ skinSettings }) { + const data = useMemo(() => getData(), []); + const gApi = useRef(null); + + const toSummary = useCallback((id, self) => { + const api = gApi.current; + if (!api) return; + const task = api.getTask(id); + if (!self) id = task.parent; + + if (id && task.type !== 'summary') { + api.exec('update-task', { + id, + task: { type: 'summary' }, + }); + } + }, [gApi]); + + const toTask = useCallback((id) => { + const api = gApi.current; + if (!api) return; + const obj = api.getTask(id); + if (obj && !obj.data?.length) { + api.exec('update-task', { + id, + task: { type: 'task' }, + }); + } + }, [gApi]); + + const init = useCallback( + (api) => { + gApi.current = api; + + api.getState().tasks.forEach((task) => { + if (task.data?.length) { + toSummary(task.id, true); + } + }); + + api.on('add-task', ({ id, mode }) => { + if (mode === 'child') toSummary(id); + }); + + api.on('move-task', ({ id, source, mode, inProgress }) => { + if (inProgress) return; + if (mode == 'child') toSummary(id); + else toTask(source); + }); + + api.on('delete-task', ({ source }) => { + toTask(source); + }); + }, + [toSummary, toTask], + ); + + const activeTaskId = useStoreLater(gApi.current, "activeTask"); + + const items = useMemo(() => { + const api = gApi.current; + const task = activeTaskId ? api?.getTask(activeTaskId) : null; + if (task) { + return defaultEditorItems.map((item) => { + item = { ...item }; + if (item.comp === 'select' && item.key === 'type') { + item.options = + task.type !== 'summary' + ? defaultTaskTypes.filter((t) => t.id !== 'summary') + : []; + } + return item; + }); + } + return undefined; + }, [activeTaskId, gApi.current]); + + return ( +
+ + + + {gApi.current && } +
+ ); +} diff --git a/demos/cases/GanttSummariesNoDrag.jsx b/demos/cases/GanttSummariesNoDrag.jsx new file mode 100644 index 0000000..5044003 --- /dev/null +++ b/demos/cases/GanttSummariesNoDrag.jsx @@ -0,0 +1,31 @@ +import { useState, useMemo, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt, ContextMenu, Editor } from '../../src/'; + +export default function GanttSummariesNoDrag({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(); + + const init = useCallback((api) => { + setApi(api); + api.intercept('drag-task', ({ id, top }) => { + return !(typeof top === 'undefined' && api.getTask(id).type == 'summary'); + }); + }, []); + + return ( +
+ + + + {api && } +
+ ); +} diff --git a/demos/cases/GanttSummariesProgress.css b/demos/cases/GanttSummariesProgress.css new file mode 100644 index 0000000..00e1dd8 --- /dev/null +++ b/demos/cases/GanttSummariesProgress.css @@ -0,0 +1,3 @@ +.gt-cell > .wx-gantt .wx-summary .wx-progress-marker { + display: none; +} diff --git a/demos/cases/GanttSummariesProgress.jsx b/demos/cases/GanttSummariesProgress.jsx new file mode 100644 index 0000000..25d132d --- /dev/null +++ b/demos/cases/GanttSummariesProgress.jsx @@ -0,0 +1,134 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor, defaultEditorItems, ContextMenu } from '../../src'; +import './GanttSummariesProgress.css'; + +function GanttSummariesProgress({ skinSettings }) { + const data = useMemo(() => getData(), []); + const tasks = data.tasks; + + const [api, setApi] = useState(); + + const [items, setItems] = useState(defaultEditorItems); + + const dayDiff = (next, prev) => { + const d = (next - prev) / 1000 / 60 / 60 / 24; + return Math.ceil(Math.abs(d)); + }; + + /** + * The formula of calculation is ∑d*p / ∑d , where "d" is task duration in days, + * "p" is the task progress and "∑" stands for a sum of all loaded tasks + */ + function getSummaryProgress(id) { + const [totalProgress, totalDuration] = collectProgressFromKids(id); + const res = totalProgress / totalDuration; + return isNaN(res) ? 0 : Math.round(res); + } + + function collectProgressFromKids(id) { + let totalProgress = 0, + totalDuration = 0; + const kids = api.getTask(id).data; + + kids?.forEach((kid) => { + let duration = 0; + if (kid.type !== 'milestone' && kid.type !== 'summary') { + duration = kid.duration || dayDiff(kid.end, kid.start); + totalDuration += duration; + totalProgress += duration * kid.progress; + } + + const [p, d] = collectProgressFromKids(kid.id); + totalProgress += p; + totalDuration += d; + }); + return [totalProgress, totalDuration]; + } + + function recalcSummaryProgress(id, self) { + const { tasks } = api.getState(); + const task = api.getTask(id); + + if (task.type != 'milestone') { + const summary = + self && task.type === 'summary' ? id : tasks.getSummaryId(id); + + if (summary) { + const progress = getSummaryProgress(summary); + api.exec('update-task', { + id: summary, + task: { progress }, + }); + } + } + } + const recalcRef = useRef(null); + recalcRef.current = recalcSummaryProgress; + + const init = useCallback((api) => { + setApi(api); + // auto progress calculations + api.on('add-task', ({ id }) => { + recalcRef.current(id); + }); + api.on('update-task', ({ id }) => { + recalcRef.current(id); + }); + + api.on('delete-task', ({ source }) => { + recalcRef.current(source, true); + }); + api.on('copy-task', ({ id }) => { + recalcRef.current(id); + }); + api.on('move-task', ({ id, source, inProgress }) => { + if (inProgress) return; + + if (api.getTask(id).parent != source) recalcRef.current(source, true); + recalcRef.current(id); + }); + + // disabling progress slider for summary tasks + api.on('show-editor', ({ id }) => { + if (id) { + const type = api.getTask(id).type; + setItems( + defaultEditorItems.map((ed) => ({ + ...ed, + ...(ed.key == 'progress' && { + config: { disabled: type === 'summary' }, + }), + })), + ); + } + }); + }, []); + + useEffect(() => { + if (!api) return; + api.getState().tasks.forEach((task) => { + recalcSummaryProgress(task.id, true); + }); + }, [api]); + + return ( +
+ +
+ +
+
+ {api && } +
+ ); +} + +export default GanttSummariesProgress; diff --git a/demos/cases/GanttTaskTypes.css b/demos/cases/GanttTaskTypes.css new file mode 100644 index 0000000..c720101 --- /dev/null +++ b/demos/cases/GanttTaskTypes.css @@ -0,0 +1,55 @@ +.demo.wx-I1glfWSB { + overflow: hidden; + height: 100%; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.narrow { + background-color: #676a81; + color: transparent; + height: 10px !important; + margin-top: 10px; + border: 1px solid #63667a; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.narrow .wx-progress-percent { + background-color: #1a2630; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.narrow .wx-link { + background-color: #384047; + border-radius: 0px; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.narrow .wx-link .wx-inner { + border-radius: 0px; +} + +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.round { + background-color: #ffeb3b; + border: 1px solid #ffeb3b; + color: #384047; + border-radius: 50px; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.round .wx-progress-percent { + background-color: #ffc107; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.round .wx-progress-wrapper { + border-radius: 50px; +} + +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.progress { + background-color: transparent; + color: var(--wx-color-font); + border-radius: 50px; + border: 1px solid #00bcd4; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.progress .wx-progress-percent { + background-color: #00bcd4; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.progress .wx-progress-wrapper { + border-radius: 50px; +} + +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.urgent { + background-color: #f49a82; + border: 1px solid #f45e36; +} +.demo.wx-I1glfWSB > .wx-gantt .wx-bar.wx-task.urgent .wx-progress-percent { + background-color: #f45e36; +} diff --git a/demos/cases/GanttTaskTypes.jsx b/demos/cases/GanttTaskTypes.jsx new file mode 100644 index 0000000..7f2510e --- /dev/null +++ b/demos/cases/GanttTaskTypes.jsx @@ -0,0 +1,27 @@ +import { useMemo, useState } from 'react'; +import { getTypedData, taskTypes } from '../data'; +import { Gantt, Editor } from '../../src/'; +import './GanttTaskTypes.css'; + +function GanttTaskTypes(props) { + const { skinSettings } = props; + + const data = useMemo(() => getTypedData(), []); + const [api, setApi] = useState(null); + + return ( +
+ + {api && } +
+ ); +} + +export default GanttTaskTypes; diff --git a/demos/cases/GanttText.jsx b/demos/cases/GanttText.jsx new file mode 100644 index 0000000..53ef94c --- /dev/null +++ b/demos/cases/GanttText.jsx @@ -0,0 +1,30 @@ +import { useMemo, useRef, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import MyTaskContent from '../custom/MyTaskContent.jsx'; + +export default function GanttText({ skinSettings }) { + const data = useMemo(() => getData(), []); + const api = useRef(null); + + const doClick = useCallback((ev) => { + api.current.exec('update-task', { + id: ev.id, + task: { + clicked: ev.clicked, + }, + }); + }, []); + + return ( + + ); +} diff --git a/demos/cases/GanttToolbar.css b/demos/cases/GanttToolbar.css new file mode 100644 index 0000000..f7a0db1 --- /dev/null +++ b/demos/cases/GanttToolbar.css @@ -0,0 +1,4 @@ +.gtcell.wx-2rSWAdWv { + height: calc(100% - 50px); + border-top: var(--wx-gantt-border); +} diff --git a/demos/cases/GanttToolbar.jsx b/demos/cases/GanttToolbar.jsx new file mode 100644 index 0000000..7ba8081 --- /dev/null +++ b/demos/cases/GanttToolbar.jsx @@ -0,0 +1,28 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, Toolbar, Editor } from '../../src/'; +import './GanttToolbar.css'; + +export default function GanttToolbar(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(); + + return ( + <> + +
+ + {api && } +
+ + ); +} diff --git a/demos/cases/GanttToolbarButtons.css b/demos/cases/GanttToolbarButtons.css new file mode 100644 index 0000000..2e1336c --- /dev/null +++ b/demos/cases/GanttToolbarButtons.css @@ -0,0 +1,4 @@ +.gtcell.wx-vkht5Uhi { + height: calc(100% - 50px); + border-top: var(--wx-gantt-border); +} diff --git a/demos/cases/GanttToolbarButtons.jsx b/demos/cases/GanttToolbarButtons.jsx new file mode 100644 index 0000000..e7e9551 --- /dev/null +++ b/demos/cases/GanttToolbarButtons.jsx @@ -0,0 +1,47 @@ +import { useState, useMemo, useCallback, useContext } from 'react'; +import { context } from '@svar-ui/react-core'; +import { getData } from '../data'; +import { Gantt, Toolbar, Editor, defaultToolbarButtons } from '../../src/'; +import './GanttToolbarButtons.css'; + +export default function GanttToolbarButtons({ skinSettings }) { + const helpers = useContext(context.helpers); + const [api, setApi] = useState(); + + const data = useMemo(() => getData(), []); + + const actionHandler = useCallback(() => { + helpers.showNotice({ text: "'My action' clicked" }); + }, [helpers]); + + const items = useMemo(() => { + const items = defaultToolbarButtons.filter((b) => { + return b.id?.indexOf('indent') === -1; + }); + + items.push({ + id: 'my-action', + comp: 'icon', + icon: 'wxi-cat', + handler: actionHandler, + }); + + return items; + }, [actionHandler]); + + return ( + <> + +
+ + {api && } +
+ + ); +} diff --git a/demos/cases/GanttToolbarCustom.css b/demos/cases/GanttToolbarCustom.css new file mode 100644 index 0000000..f257d7c --- /dev/null +++ b/demos/cases/GanttToolbarCustom.css @@ -0,0 +1,4 @@ +.gtcell.wx-7sTOb4gt { + height: calc(100% - 50px); + border-top: var(--wx-gantt-border); +} diff --git a/demos/cases/GanttToolbarCustom.jsx b/demos/cases/GanttToolbarCustom.jsx new file mode 100644 index 0000000..7ddf712 --- /dev/null +++ b/demos/cases/GanttToolbarCustom.jsx @@ -0,0 +1,102 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, Editor } from '../../src/'; +import { Toolbar } from '@svar-ui/react-toolbar'; +import { useStoreLater } from '@svar-ui/lib-react'; +import './GanttToolbarCustom.css'; + +export default function GanttToolbarCustom({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(null); + const selectedValue = useStoreLater(api, "selected"); + + function handleAdd() { + if (!api) return; + api.exec('add-task', { + task: { + text: 'New task', + }, + target: selectedValue[0], + mode: 'after', + }); + } + + function handleDelete() { + if (!api) return; + const order = getActionOrder(true); + order.forEach((id) => api.exec('delete-task', { id })); + } + + function handleMove(mode) { + if (!api) return; + const changeDir = mode === 'down'; + const order = getActionOrder(changeDir); + order.forEach((id) => api.exec('move-task', { id, mode })); + } + + function getActionOrder(changeDir) { + if (!api) return []; + const tasks = selectedValue + .map((id) => api.getTask(id)) + .sort((a, b) => { + return a.$level - b.$level || a.$y - b.$y; + }); + const idOrder = tasks.map((o) => o.id); + if (changeDir) return idOrder.reverse(); + return idOrder; + } + + const allItems = [ + { + comp: 'button', + type: 'primary', + text: 'Add task', + handler: handleAdd, + }, + { + comp: 'button', + text: 'Delete task', + handler: handleDelete, + }, + { + comp: 'button', + type: 'primary', + text: 'Move task down', + handler: () => handleMove('down'), + }, + { + comp: 'button', + type: 'primary', + text: 'Move task up', + handler: () => handleMove('up'), + }, + ]; + + const items = useMemo(() => { + if (api && selectedValue) { + return selectedValue.length ? allItems : [allItems[0]]; + } + return [allItems[0]]; + }, [api, selectedValue, allItems]); + + const GanttInitialised = useMemo(() => { + return + }, [skinSettings, data.tasks, data.links, data.scales]); + + return ( + <> + +
+ {GanttInitialised} + {api && } +
+ + ); +} diff --git a/demos/cases/GanttTooltips.jsx b/demos/cases/GanttTooltips.jsx new file mode 100644 index 0000000..2b3b672 --- /dev/null +++ b/demos/cases/GanttTooltips.jsx @@ -0,0 +1,23 @@ +import { useMemo, useState } from 'react'; +import { getData } from '../data'; +import { Gantt, Tooltip } from '../../src/'; +import MyTooltipContent from '../custom/MyTooltipContent.jsx'; + +function GanttTooltips({ skinSettings }) { + const data = useMemo(() => getData(), []); + const [api, setApi] = useState(null); + + return ( + + + + ); +} + +export default GanttTooltips; diff --git a/demos/cases/GanttZoom.css b/demos/cases/GanttZoom.css new file mode 100644 index 0000000..d2648c0 --- /dev/null +++ b/demos/cases/GanttZoom.css @@ -0,0 +1,12 @@ +.demo.wx-HQBKHlAu { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.gtcell.wx-HQBKHlAu { + overflow: hidden; + border: var(--wx-gantt-border); + height: calc(100% - 32px); +} diff --git a/demos/cases/GanttZoom.jsx b/demos/cases/GanttZoom.jsx new file mode 100644 index 0000000..e874138 --- /dev/null +++ b/demos/cases/GanttZoom.jsx @@ -0,0 +1,34 @@ +import { useMemo, useCallback } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; +import './GanttZoom.css'; + +function GanttZoom({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const init = useCallback((api) => { + api.on('zoom-scale', () => { + console.log('The current zoom level is', api.getState().zoom); + }); + }, []); + + return ( +
+

+ Point over Gantt chart, then hold Ctrl and use mouse wheel to zoom +

+
+ +
+
+ ); +} + +export default GanttZoom; diff --git a/demos/cases/GridHeaderMenu.css b/demos/cases/GridHeaderMenu.css new file mode 100644 index 0000000..350ccce --- /dev/null +++ b/demos/cases/GridHeaderMenu.css @@ -0,0 +1,27 @@ +.rows.wx-DZzpn0qn { + position: relative; + display: flex; + flex-direction: column; + background: var(--wx-background); + width: 100%; + height: 100%; + overflow: hidden; +} + +.bar.wx-DZzpn0qn { + padding: 10px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.gtcell.wx-DZzpn0qn { + position: relative; + height: 100%; + border-top: var(--wx-gantt-border); + overflow: hidden; +} +.label.wx-DZzpn0qn { + padding-right: 20px; + font-size: var(--wx-font-size); +} diff --git a/demos/cases/GridHeaderMenu.jsx b/demos/cases/GridHeaderMenu.jsx new file mode 100644 index 0000000..d108ad1 --- /dev/null +++ b/demos/cases/GridHeaderMenu.jsx @@ -0,0 +1,53 @@ +import { useState, useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt, HeaderMenu } from '../../src/'; +import { RadioButtonGroup } from '@svar-ui/react-core'; +import './GridHeaderMenu.css'; + +function GridHeaderMenu({ skinSettings }) { + const data = useMemo(() => getData(), []); + + const [api, setApi] = useState(null); + + const [selected, setSelected] = useState('all'); + const options = [ + { id: 'all', label: 'All' }, + { id: 'some', label: 'Some' }, + ]; + const hidable = { start: true, duration: true }; + + const columns = useMemo( + () => (selected === 'some' ? hidable : null), + [selected], + ); + + return ( +
+
+
Right-click the grid header and select visible columns
+
+
Columns that can be hidden:
+ setSelected(value)} + type="inline" + /> +
+
+
+ + + +
+
+ ); +} + +export default GridHeaderMenu; diff --git a/demos/cases/GridInlineEditors.jsx b/demos/cases/GridInlineEditors.jsx new file mode 100644 index 0000000..6408002 --- /dev/null +++ b/demos/cases/GridInlineEditors.jsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react'; +import { getData } from '../data'; +import { Gantt } from '../../src/'; + +function GridInlineEditors(props) { + const { skinSettings } = props; + + const data = useMemo(() => getData(), []); + + const columns = useMemo( + () => [ + { + id: 'text', + header: 'Task name', + width: 170, + sort: true, + editor: 'text', + }, + { + id: 'start', + header: 'Start date', + width: 120, + align: 'center', + sort: true, + editor: 'datepicker', + }, + { + id: 'duration', + header: 'Duration', + width: 80, + sort: true, + align: 'center', + editor: 'text', + }, + { id: 'add-task', header: 'Add task', width: 50, align: 'center' }, + ], + [], + ); + + return ( + + ); +} + +export default GridInlineEditors; diff --git a/demos/common/Index.css b/demos/common/Index.css new file mode 100644 index 0000000..c14de09 --- /dev/null +++ b/demos/common/Index.css @@ -0,0 +1,232 @@ +.wx-demos.layout { + box-sizing: border-box; + display: flex; + height: 100%; + width: 100%; +} + +.wx-demos.page-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; +} + +.wx-demos.page-content-header { + height: 70px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 40px; + padding: 16px; + border-bottom: 1px solid #ebebeb; +} + +.wx-demos.header-title-box { + display: flex; + align-items: center; + gap: 16px; +} + +.wx-demos.header-actions-container { + display: flex; + align-items: center; + gap: 16px; +} + +.wx-demos.layout.active { + flex-direction: row; +} + +.wx-demos.sidebar { + width: 0; + height: 100%; + overflow: hidden; + transition: width 0.3s; +} + +.wx-demos.sidebar-header { + display: flex; + justify-content: space-between; + position: sticky; + top: 0px; + background-color: #fbfbfb; + padding: 19px 16px 19px 18px; +} + +.wx-demos.box-title { + display: flex; + align-items: center; + gap: 12px; +} + +.wx-demos.box-title img:hover, +.wx-demos.box-title img:focus { + opacity: 0.6; +} + +.wx-demos.box-title img:active { + opacity: 0.8; +} + +.wx-demos.box-title .wx-demos.separator { + width: 1px; + height: 20px; + background: #ebebeb; +} + +.wx-demos.sidebar.active { + width: 300px; +} + +.wx-demos.sidebar-content { + display: flex; + flex-direction: column; + width: 300px; + gap: 16px; + height: 100%; + border-right: 1px solid #ebebeb; + overflow-y: auto; + font-family: Roboto, Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 20px; + background-color: #fbfbfb; + border-bottom: 1px solid #ebebeb; +} + +.wx-demos.btn-box button.toggle-btn { + display: flex; + gap: 8px; + border: 1px solid #ebebeb; + color: #42454d; +} + +.wx-demos.btn-box button.toggle-btn:hover, +.wx-demos.btn-box button.toggle-btn:focus { + border: 1px solid #ebebeb; + color: #42454d; + background: #f7f7f7; +} + +.wx-demos.btn-box button.toggle-btn:active { + background: #f1f1f1; +} +.wx-demos a { + display: flex; + text-decoration: none; +} + +.wx-demos.wrapper-content { + flex: 1; + height: calc(100% - 70px); +} + +.wx-demos.wrapper-content .wx-demos.content { + width: 100%; + height: 100%; + transition: + transform 0.3s, + width 0.3s; + overflow-y: auto; +} + +.wx-demos.box-links { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wx-demos.hint { + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: #42454d; +} + +.wx-demos.title { + margin: 0; + font-size: 18px; + font-weight: 500; + line-height: 24px; + color: #42454d; +} + +.wx-demos.segmented-box div.segmented-themes { + padding: 2px; + border-radius: 4px; +} + +.wx-demos.segmented-box div.segmented-themes button { + font-size: 14px; + font-weight: 400; + line-height: 18px; + color: #595b66; + background-color: transparent; +} + +.wx-demos.segmented-box div.segmented-themes button.wx-selected { + border-radius: 2px; + font-weight: 500; + color: #42454d; + background: #fff; + box-shadow: 0px 0px 7px 0px rgba(66, 69, 76, 0.07); +} + +.wx-demos.content h4 { + font-size: 16px; + font-weight: 300; + margin-bottom: 12px; + border-bottom: var(--wx-border); +} + +.wx-demos.content h3 { + font-size: 18px; + margin: 12px 0; + font-weight: normal; +} +.wx-demos.content .demo-box { + margin: 20px; +} +.wx-demos.content .demo-box + .demo-box { + margin-top: 40px; +} +.wx-demos.content .demo-code { + font-family: monospace; + line-height: 30px; +} + +.wx-demos.content .demo-status { + height: 30px; + line-height: 30px; + background: rgba(0, 0, 0, 0.15); + padding-left: 10px; +} + +.wx-demos.content .demo-toolbar { + border: 2px solid var(--wx-background-alt); + max-width: 600px; +} + +.wx-demos.demo { + display: flex; + align-items: center; + height: 37px; + font-weight: 400; + padding-left: 16px; + border-left: 4px solid transparent; + color: #595b66; + list-style: none; + cursor: pointer; + text-decoration: none; +} + +.wx-demos.demo.active { + border-left-color: #087a9f; +} + +.wx-demos.demo.active, +.wx-demos.demo:hover { + font-weight: 500; + color: #42454d; + background-color: #f1f1f1; +} diff --git a/demos/common/Index.jsx b/demos/common/Index.jsx new file mode 100644 index 0000000..6c1e63e --- /dev/null +++ b/demos/common/Index.jsx @@ -0,0 +1,183 @@ +import { useState, useEffect, useCallback } from 'react'; +import { HashRouter, NavLink, useNavigate } from 'react-router-dom'; + +import Router from './Router'; +import { links } from '../routes'; +import { GitHubLogoIcon, LogoIcon } from '../assets/icons'; +import './Index.css'; + +function DemoExplorerContent({ + productTag, + publicName, + skins, + Globals, + Button, + Segmented, +}) { + const navigate = useNavigate(); + const [skin, setSkin] = useState(skins[0].id); + const [title, setTitle] = useState(''); + const [githubLink, setGithubLink] = useState(''); + const [show, setShow] = useState(false); + + const baseLink = + 'https://github.com/svar-widgets/react-' + + productTag + + '/tree/main/demos/cases/'; + + useEffect(() => { + document.body.className = `wx-willow-theme`; + }, []); + + const handleRouteChange = useCallback( + (path) => { + const parts = path.split('/'); + const page = parts[1]; + const newSkin = parts[2]; + + if (newSkin && newSkin !== skin) { + setSkin(newSkin); + } + + const targetPage = `/${page}/:skin`; + const matched = links.find((a) => a[0] === targetPage); + if (matched) { + setTitle(matched[1]); + const name = matched[3] || matched[1]; + setGithubLink(`${baseLink}${name}.jsx`); + } + }, + [skin], + ); + + const handleSkinChange = ({ value }) => { + setSkin(value); + const currentPath = window.location.hash.slice(1); + const parts = currentPath.split('/'); + if (parts[1]) { + navigate(`/${parts[1]}/${value}`); + } + }; + + const toggleSidebar = () => { + setShow(!show); + }; + + return ( +
+
+
+ +
+ {links.map((data) => ( + + `wx-demos demo ${isActive ? 'active' : ''}` + } + > + {data[1]} + + ))} +
+
+
+ +
+
+
+ {!show && ( +
+
+ )} +
{title}
+
+ +
+ +
setShow(false)} + role="none" + > +
+ + + +
+
+
+
+ ); +} + +export default function DemoExplorer(props) { + const skins = props.skins; + return ( + <> + {skins.map((skin) => ( + + ))} + + + + + ); +} diff --git a/demos/common/Router.jsx b/demos/common/Router.jsx new file mode 100644 index 0000000..4cdddda --- /dev/null +++ b/demos/common/Router.jsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { + Routes, + Route, + Navigate, + useLocation, + useNavigate, +} from 'react-router-dom'; +import { links } from '../routes'; + +export default function Router({ onRouteChange }) { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (location.pathname === '/') { + navigate('/base/willow', { replace: true }); + } else { + onRouteChange(location.pathname); + } + }, [location.pathname, onRouteChange, navigate]); + + return ( + + } /> + {links.map(([path, , Component]) => ( + } /> + ))} + + ); +} diff --git a/demos/custom/AddTaskCell.css b/demos/custom/AddTaskCell.css new file mode 100644 index 0000000..13e3f25 --- /dev/null +++ b/demos/custom/AddTaskCell.css @@ -0,0 +1,9 @@ +.text.wx-4RuF7aAO { + display: flex; + align-items: center; + cursor: pointer; +} +.text.wx-4RuF7aAO:hover { + text-decoration: underline; + color: var(--wx-color-link); +} diff --git a/demos/custom/AddTaskCell.jsx b/demos/custom/AddTaskCell.jsx new file mode 100644 index 0000000..a84f104 --- /dev/null +++ b/demos/custom/AddTaskCell.jsx @@ -0,0 +1,15 @@ +import { useContext, useMemo } from 'react'; +import { context } from '@svar-ui/react-core'; +import './AddTaskCell.css'; + +export default function AddTaskCell() { + const i18n = useContext(context.i18n); + const _ = useMemo(() => i18n.getGroup('gantt'), []); + const text = useMemo(() => _('Add task'), []); + + return ( +
+ {text} +
+ ); +} diff --git a/demos/custom/AvatarCell.jsx b/demos/custom/AvatarCell.jsx new file mode 100644 index 0000000..5f8e272 --- /dev/null +++ b/demos/custom/AvatarCell.jsx @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import UserStub from './UserStub.jsx'; +import { users } from '../data'; + +function AvatarCell(props) { + const { row } = props; + + const user = useMemo( + () => users.find((u) => u.id == row.assigned), + [row.assigned, users], + ); + + return ; +} + +export default AvatarCell; diff --git a/demos/custom/Form.css b/demos/custom/Form.css new file mode 100644 index 0000000..f1d8757 --- /dev/null +++ b/demos/custom/Form.css @@ -0,0 +1,66 @@ +.backdrop.wx-QkE5vh0y { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 5; + background: var(--wx-modal-backdrop); +} + +.modal.wx-QkE5vh0y { + position: relative; + width: 340px; + padding: 20px; + border-radius: 6px; + box-shadow: + 0 4px 4px rgba(0, 0, 0, 0.12), + 0 0 10px rgba(0, 0, 0, 0.06); + background-color: var(--wx-background); + font-family: var(--wx-font-family); + font-size: var(--wx-font-size); + color: var(--wx-color-font); +} + +.title.wx-QkE5vh0y { + margin: 0; +} + +.close.wx-QkE5vh0y { + position: absolute; + top: 20px; + right: 20px; + cursor: pointer; + font-weight: 700; + transition: color 0.15s ease-in; +} + +.close.wx-QkE5vh0y:hover { + color: rgb(255, 122, 122); +} + +.body.wx-QkE5vh0y { + margin: 20px 0 0 0; +} + +.button.wx-QkE5vh0y { + padding: 10px; + margin: 1.5em 0 0 0; + box-sizing: border-box; + border: 1px solid #ccc; + border-radius: 2px; + font-family: var(--wx-font-family); + font-size: var(--wx-font-size); + border-radius: 3px; + cursor: pointer; +} + +.button.wx-QkE5vh0y:focus { + outline: none; + opacity: 0.7; +} + +.danger.wx-QkE5vh0y { + color: var(--wx-color-danger-font); + background-color: var(--wx-color-danger); +} diff --git a/demos/custom/Form.jsx b/demos/custom/Form.jsx new file mode 100644 index 0000000..0cd5d22 --- /dev/null +++ b/demos/custom/Form.jsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Field, + Text, + TextArea, + Select, + Slider, + DatePicker, +} from '@svar-ui/react-core'; +import { useWritableProp } from '@svar-ui/lib-react'; +import './Form.css'; + +export default function Form(props) { + const [task, setTask] = useWritableProp(props.task); + const { taskTypes, onAction } = props; + + const nodeRef = useRef(null); + const [left, setLeft] = useState(); + const [top, setTop] = useState(); + + useEffect(() => { + if (nodeRef.current) { + setLeft((window.innerWidth - nodeRef.current.offsetWidth) / 2); + setTop((window.innerHeight - nodeRef.current.offsetHeight) / 2); + } + }, []); + + function deleteTask() { + onAction && onAction({ action: 'delete-task', data: { id: task.id } }); + onAction && onAction({ action: 'close-form' }); + } + + function onClose() { + onAction && onAction({ action: 'close-form' }); + } + + function handleChange({ value }, key) { + let t = task; + if (key === 'type' && value === 'milestone') { + delete t.end; + t.duration = 0; + } else if (t.start > t.end) { + t.start = t.end; + t.duration = 1; + t.end = 0; + } + t = { + ...t, + [key]: value, + }; + setTask(t); + onAction && + onAction({ + action: 'update-task', + data: { id: t.id, task: t }, + }); + } + + return ( +
+
+
+

Edit task

+ +
+
+ + {({ id }) => ( + handleChange(ev, 'text')} + /> + )} + + + + {({ id }) => ( +