This commit is contained in:
Marta Kowalska
2025-12-22 20:22:30 +00:00
parent d4429300aa
commit bb81e7a336
90 changed files with 3676 additions and 1828 deletions

View File

@@ -8,34 +8,27 @@ import {
useImperativeHandle,
} from 'react';
import { ContextMenu as WxContextMenu } from '@svar-ui/react-menu';
import {
handleAction,
defaultMenuOptions,
isHandledAction,
} from '@svar-ui/gantt-store';
import { locale } from '@svar-ui/lib-dom';
import { handleAction, getMenuOptions, isHandledAction } from '@svar-ui/gantt-store';
import { locale, locateID } from '@svar-ui/lib-dom';
import { en } from '@svar-ui/gantt-locales';
import { en as coreEn } from '@svar-ui/core-locales';
import { context } from '@svar-ui/react-core';
import { useWritableProp, useStoreLater } from '@svar-ui/lib-react';
import { useStoreLater } from '@svar-ui/lib-react';
import './ContextMenu.css';
const ContextMenu = forwardRef(function ContextMenu(
{
options: optionsInit,
options: optionsInit = [],
api = null,
resolver = null,
filter = null,
at = 'point',
children,
onClick,
css
css,
},
ref,
) {
const ownMenu = useMemo(() => optionsInit ?? [...defaultMenuOptions], [optionsInit]);
const [optionsProp] = useWritableProp(ownMenu);
const menuRef = useRef(null);
const activeIdRef = useRef(null);
@@ -44,10 +37,12 @@ const ContextMenu = forwardRef(function ContextMenu(
const l = useMemo(() => i18nCtx || locale({ ...en, ...coreEn }), [i18nCtx]);
const _ = useMemo(() => l.getGroup('gantt'), [l]);
const rTaskTypesVal = useStoreLater(api, "taskTypes");
const rTasksVal = useStoreLater(api, "_tasks");
const rSelectedVal = useStoreLater(api, "selected");
const rSelectedTasksVal = useStoreLater(api, "_selected");
const taskTypesVal = useStoreLater(api, 'taskTypes');
const selectedVal = useStoreLater(api, 'selected');
const selectedTasksVal = useStoreLater(api, '_selected');
const splitTasksVal = useStoreLater(api, 'splitTasks');
const fullOptions = useMemo(() => getMenuOptions({ splitTasks: true }), []);
useEffect(() => {
if (!api) return;
@@ -71,27 +66,26 @@ const ContextMenu = forwardRef(function ContextMenu(
}
function getOptions() {
const convertOption = optionsProp.find((o) => o.id === 'convert-task');
const finalOptions = optionsInit.length
? optionsInit
: getMenuOptions({ splitTasks: splitTasksVal });
const convertOption = finalOptions.find((o) => o.id === 'convert-task');
if (convertOption) {
convertOption.data = [];
(rTaskTypesVal || []).forEach((t) => {
(taskTypesVal || []).forEach((t) => {
convertOption.data.push(convertOption.dataFactory(t));
});
}
return applyLocaleFn(optionsProp);
return applyLocaleFn(finalOptions);
}
const cOptions = useMemo(() => {
if (api) {
return getOptions();
}
return null;
}, [api, optionsProp, rTaskTypesVal, _]);
return getOptions();
}, [api, optionsInit, taskTypesVal, splitTasksVal, _]);
const selectedTasks = useMemo(
() =>
rSelectedTasksVal && rSelectedTasksVal.length ? rSelectedTasksVal : [],
[rSelectedTasksVal],
() => (selectedTasksVal && selectedTasksVal.length ? selectedTasksVal : []),
[selectedTasksVal],
);
const itemResolver = useCallback(
@@ -102,40 +96,55 @@ const ContextMenu = forwardRef(function ContextMenu(
task = result === true ? task : result;
}
if (task) {
activeIdRef.current = task.id;
if (!Array.isArray(rSelectedVal) || !rSelectedVal.includes(task.id)) {
const segmentIndex = locateID(ev.target, 'data-segment');
if (segmentIndex !== null)
activeIdRef.current = { id: task.id, segmentIndex };
else activeIdRef.current = task.id;
if (!Array.isArray(selectedVal) || !selectedVal.includes(task.id)) {
api && api.exec && api.exec('select-task', { id: task.id });
}
}
return task;
},
[api, resolver, rSelectedVal],
[api, resolver, selectedVal],
);
const menuAction = useCallback(
(ev) => {
const action = ev.action;
if (action) {
const isAction = isHandledAction(defaultMenuOptions, action.id);
const isAction = isHandledAction(fullOptions, action.id);
if (isAction) handleAction(api, action.id, activeIdRef.current, _);
onClick && onClick(ev);
}
},
[api, _, onClick],
[api, _, onClick, fullOptions],
);
const filterMenu = useCallback(
(item, task) => {
// for single selection from resolver _selected are empty
// due to setAsyncState causing _selected to lag
const tasks = selectedTasks.length ? selectedTasks : task ? [task] : [];
let result = filter ? filter(item, task) : true;
if (item.check && result) {
const isDisabled = tasks.some((t) => !item.check(t, rTasksVal));
item.css = isDisabled ? 'wx-disabled' : '';
let result = filter ? tasks.every((t) => filter(item, t)) : true;
if (result) {
if (item.isHidden)
result = !tasks.some((t) =>
item.isHidden(t, api.getState(), activeIdRef.current),
);
if (item.isDisabled) {
const disabled = tasks.some((t) =>
item.isDisabled(t, api.getState(), activeIdRef.current),
);
item.disabled = disabled;
}
}
return result;
},
[filter, selectedTasks, rTasksVal],
[filter, selectedTasks, api],
);
useImperativeHandle(ref, () => ({

View File

@@ -1,5 +1,5 @@
.wx-sidearea .wx-gantt-editor.wx-XkvqDXuw {
width: 400px;
width: 450px;
}
.wx-sidearea .wx-gantt-editor.wx-full-screen.wx-XkvqDXuw {
width: 100%;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useContext } from 'react';
import { Editor as WxEditor, registerEditorItem } from '@svar-ui/react-editor';
import { Locale, RichSelect, Slider, Counter, TwoState } from '@svar-ui/react-core';
import { defaultEditorItems, normalizeDates } from '@svar-ui/gantt-store';
import { getEditorItems, prepareEditTask } from '@svar-ui/gantt-store';
import { dateToString, locale } from '@svar-ui/lib-dom';
import { en } from '@svar-ui/gantt-locales';
import { en as coreEn } from '@svar-ui/core-locales';
@@ -9,7 +9,7 @@ import { context } from '@svar-ui/react-core';
import Links from './editor/Links.jsx';
import DateTimePicker from './editor/DateTimePicker.jsx';
import { useStore, useWritableProp } from '@svar-ui/lib-react';
import { useStore } from '@svar-ui/lib-react';
// helpers
import { modeObserver } from '../helpers/modeResizeObserver';
@@ -25,7 +25,7 @@ registerEditorItem('links', Links);
function Editor({
api,
items = defaultEditorItems,
items = [],
css = '',
layout = 'default',
readonly = false,
@@ -34,6 +34,7 @@ function Editor({
topBar = true,
autoSave = true,
focus = false,
hotkeys = {},
}) {
const lFromCtx = useContext(context.i18n);
const l = useMemo(() => lFromCtx || locale({ ...en, ...coreEn }), [lFromCtx]);
@@ -91,28 +92,70 @@ function Editor({
};
}, [handleResize]);
const activeTask = useStore(api, "_activeTask");
// const taskId = useStore(api, "activeTaskId");
const taskId = useMemo(() => activeTask?.id, [activeTask]);
const unit = useStore(api, "durationUnit");
const unscheduledTasks = useStore(api, "unscheduledTasks");
const taskTypes = useStore(api, "taskTypes");
const activeTask = useStore(api, '_activeTask');
const taskId = useStore(api, 'activeTask');
const unscheduledTasks = useStore(api, 'unscheduledTasks');
const links = useStore(api, 'links');
const splitTasks = useStore(api, 'splitTasks');
const segmentIndex = useMemo(
() => splitTasks && taskId?.segmentIndex,
[splitTasks, taskId],
);
const isSegment = useMemo(
() => segmentIndex || segmentIndex === 0,
[segmentIndex],
);
const baseItems = useMemo(
() => getEditorItems({ unscheduledTasks }),
[unscheduledTasks],
);
const undo = useStore(api, 'undo');
const [taskType, setTaskType] = useWritableProp(activeTask?.type);
const taskUnscheduled = useMemo(() => activeTask?.unscheduled, [activeTask]);
const [linksActionsMap, setLinksActionsMap] = useState({});
const [inProgress, setInProgress] = useState(null);
const [editorValues, setEditorValues] = useState();
const [editorErrors, setEditorErrors] = useState(null);
const taskTypes = useStore(api, 'taskTypes');
const task = useMemo(() => {
if (!activeTask) return null;
let data;
if (isSegment && activeTask.segments)
data = { ...activeTask.segments[segmentIndex] };
else data = { ...activeTask };
if (readonly) {
// preserve parent to differentiate between segment and task
let values = { parent: data.parent };
baseItems.forEach(({ key, comp }) => {
if (comp !== 'links') {
const value = data[key];
if (comp === 'date' && value instanceof Date) {
values[key] = dateFormat(value);
} else if (comp === 'slider' && key === 'progress') {
values[key] = `${value}%`;
} else {
values[key] = value;
}
}
});
return values;
}
return data || null;
}, [activeTask, isSegment, segmentIndex, readonly, baseItems, dateFormat]);
useEffect(() => {
setEditorValues(task);
}, [task]);
useEffect(() => {
setLinksActionsMap({});
setEditorErrors(null);
setInProgress(null);
}, [taskId]);
const milestone = useMemo(() => taskType === 'milestone', [taskType]);
const summary = useMemo(() => taskType === 'summary', [taskType]);
function prepareEditorItems(localItems, isUnscheduled) {
const dates = { start: 1, end: 1, duration: 1 };
function prepareEditorItems(localItems, taskData) {
return localItems.map((a) => {
const item = { ...a };
if (a.config) item.config = { ...item.config };
@@ -122,7 +165,7 @@ function Editor({
item.onLinksChange = handleLinksChange;
}
if (item.comp === 'select' && item.key === 'type') {
let options = item.options ?? (taskTypes ? taskTypes : []);
const options = item.options ?? (taskTypes ? taskTypes : []);
item.options = options.map((t) => ({
...t,
label: _(t.label),
@@ -137,78 +180,28 @@ function Editor({
if (item.config?.placeholder)
item.config.placeholder = _(item.config.placeholder);
if (unscheduledTasks && dates[item.key]) {
if (isUnscheduled) {
if (taskData) {
if (item.isDisabled && item.isDisabled(taskData, api.getState())) {
item.disabled = true;
} else {
delete item.disabled;
}
} else delete item.disabled;
}
return item;
});
}
function filterEditorItems(localItems) {
return localItems.filter(({ comp, key, options }) => {
switch (comp) {
case 'date': {
return (
(!milestone || (key !== 'end' && key !== 'base_end')) && !summary
);
}
case 'select': {
return options.length > 1;
}
case 'twostate': {
return unscheduledTasks && !summary;
}
case 'counter': {
return !summary && !milestone;
}
case 'slider': {
return !milestone;
}
default:
return true;
}
});
}
const editorItems = useMemo(() => {
const eItems = prepareEditorItems(items, taskUnscheduled);
return filterEditorItems(eItems);
}, [
items,
taskUnscheduled,
milestone,
summary,
unscheduledTasks,
taskTypes,
_,
api,
autoSave,
]);
let eItems = items.length ? items : baseItems;
eItems = prepareEditorItems(eItems, editorValues);
if (!editorValues) return eItems;
return eItems.filter(
(item) => !item.isHidden || !item.isHidden(editorValues, api.getState()),
);
}, [items, baseItems, editorValues, taskTypes, _, api, autoSave]);
const task = useMemo(() => {
if (readonly && activeTask) {
let values = {};
editorItems.forEach(({ key, comp }) => {
if (comp !== 'links') {
const value = activeTask[key];
if (comp === 'date' && value instanceof Date) {
values[key] = dateFormat(value);
} else if (comp === 'slider' && key === 'progress') {
values[key] = `${value}%`;
} else {
values[key] = value;
}
}
});
return values;
}
return activeTask ? { ...activeTask } : null;
}, [readonly, activeTask, editorItems, dateFormat]);
const editorKeys = useMemo(
() => editorItems.map((i) => i.key),
[editorItems],
);
function handleLinksChange({ id, action, data }) {
setLinksActionsMap((prev) => ({
@@ -219,64 +212,147 @@ function Editor({
const saveLinks = useCallback(() => {
for (let link in linksActionsMap) {
const { action, data } = linksActionsMap[link];
api.exec(action, data);
if (links.byId(link)) {
const { action, data } = linksActionsMap[link];
api.exec(action, data);
}
}
}, [api, linksActionsMap]);
}, [api, linksActionsMap, links]);
const deleteTask = useCallback(() => {
api.exec('delete-task', { id: taskId });
}, [api, taskId]);
const id = taskId?.id || taskId;
if (isSegment) {
if (activeTask?.segments) {
const segments = activeTask.segments.filter(
(s, index) => index !== segmentIndex,
);
api.exec('update-task', {
id,
task: { segments },
});
}
} else {
api.exec('delete-task', { id });
}
}, [api, taskId, isSegment, activeTask, segmentIndex]);
const hide = useCallback(() => {
api.exec('show-editor', { id: null });
}, [api]);
const handleAction = useCallback((ev) => {
const { item, changes } = ev;
if (item.id === 'delete') {
deleteTask();
}
if (item.id === 'save') {
if (!changes.length) saveLinks();
else hide();
}
if (item.comp) hide();
}, [api, taskId, autoSave, saveLinks, deleteTask, hide]);
const handleAction = useCallback(
(ev) => {
const { item, changes } = ev;
if (item.id === 'delete') {
deleteTask();
}
if (item.id === 'save') {
if (!changes.length) saveLinks();
else hide();
}
if (item.comp) hide();
},
[api, taskId, autoSave, saveLinks, deleteTask, hide],
);
const normalizeTask = useCallback((t, key) => {
if (unscheduledTasks && t.type === 'summary') t.unscheduled = false;
const normalizeTask = useCallback(
(t, key, input) => {
if (unscheduledTasks && t.type === 'summary') t.unscheduled = false;
normalizeDates(t, unit, true, key);
return t;
}, [unscheduledTasks, unit]);
prepareEditTask(t, api.getState(), key);
if (!input) setInProgress(false);
return t;
},
[unscheduledTasks, api],
);
const handleChange = useCallback((ev) => {
let { update, key, value } = ev;
ev.update = normalizeTask({ ...update }, key);
if (!autoSave) {
if (key === 'type') setTaskType(value);
}
}, [api, autoSave]);
const save = useCallback(
(values) => {
values = {
...values,
unscheduled:
unscheduledTasks && values.unscheduled && values.type !== 'summary',
};
delete values.links;
delete values.data;
const handleSave = useCallback((ev) => {
let { values } = ev;
values = {
...values,
unscheduled:
unscheduledTasks && values.unscheduled && values.type !== 'summary',
};
delete values.links;
delete values.data;
if (
editorKeys.indexOf('duration') === -1 ||
(values.segments && !values.duration)
)
delete values.duration;
api.exec('update-task', {
id: taskId,
task: values,
});
const data = {
id: taskId?.id || taskId,
task: values,
...(isSegment && { segmentIndex }),
};
if (autoSave && inProgress) data.inProgress = inProgress;
if (!autoSave) saveLinks();
}, [api, taskId, unscheduledTasks, autoSave, saveLinks]);
api.exec('update-task', data);
if (!autoSave) saveLinks();
},
[
api,
taskId,
unscheduledTasks,
autoSave,
saveLinks,
editorKeys,
isSegment,
segmentIndex,
inProgress,
],
);
const handleChange = useCallback(
(ev) => {
let { update, key, input } = ev;
if (input) setInProgress(true);
ev.update = normalizeTask({ ...update }, key, input);
if (!autoSave) setEditorValues(ev.update);
else if (!editorErrors && !input) {
const item = editorItems.find((i) => i.key === key);
const v = update[key];
const isValid = !item.validation || item.validation(v);
if (isValid && (!item.required || v)) save(ev.update);
}
},
[autoSave, normalizeTask, editorErrors, editorItems, save],
);
const handleSave = useCallback(
(ev) => {
if (!autoSave) save(ev.values);
},
[autoSave, save],
);
const handleValidation = useCallback((check) => {
// get all errors after onchange action
setEditorErrors(check.errors);
}, []);
const defaultHotkeys = useMemo(
() =>
undo
? {
'ctrl+z': (ev) => {
ev.preventDefault();
api.exec('undo');
},
'ctrl+y': (ev) => {
ev.preventDefault();
api.exec('redo');
},
}
: {},
[undo, api],
);
return task ? (
<Locale>
@@ -293,7 +369,9 @@ function Editor({
focus={focus}
onAction={handleAction}
onSave={handleSave}
onValidation={handleValidation}
onChange={handleChange}
hotkeys={hotkeys && { ...defaultHotkeys, ...hotkeys }}
/>
</Locale>
) : null;

View File

@@ -5,17 +5,29 @@ import {
useRef,
useImperativeHandle,
useState,
useContext,
} from 'react';
// core widgets lib
import { Locale } from '@svar-ui/react-core';
import { context } from '@svar-ui/react-core';
// locales
import { locale as l } from '@svar-ui/lib-dom';
import { en } from '@svar-ui/gantt-locales';
import { en as coreEn } from '@svar-ui/core-locales';
// stores
import { EventBusRouter } from '@svar-ui/lib-state';
import { DataStore, defaultColumns, defaultTaskTypes } from '@svar-ui/gantt-store';
import {
DataStore,
defaultColumns,
defaultTaskTypes,
parseTaskDates,
normalizeZoom,
normalizeLinks,
} from '@svar-ui/gantt-store';
// context
// context
import StoreContext from '../context';
// store factory
@@ -24,6 +36,13 @@ import { writable } from '@svar-ui/lib-react';
// ui
import Layout from './Layout.jsx';
// helpers
import {
prepareScales,
prepareFormats,
prepareColumns,
prepareZoom,
} from '../helpers/prepareConfig.js';
const camelize = (s) =>
s
@@ -32,8 +51,8 @@ const camelize = (s) =>
.join('');
const defaultScales = [
{ unit: 'month', step: 1, format: 'MMMM yyy' },
{ unit: 'day', step: 1, format: 'd' },
{ unit: 'month', step: 1, format: '%F %Y' },
{ unit: 'day', step: 1, format: '%j' },
];
const Gantt = forwardRef(function Gantt(
@@ -58,10 +77,17 @@ const Gantt = forwardRef(function Gantt(
cellBorders = 'full',
zoom = false,
baselines = false,
highlightTime = null,
highlightTime: highlightTimeProp = null,
init = null,
autoScale = true,
unscheduledTasks = false,
criticalPath = null,
schedule = { type: 'forward' },
projectStart = null,
projectEnd = null,
calendar = null,
undo = false,
splitTasks = false,
...restProps
},
ref,
@@ -70,9 +96,53 @@ const Gantt = forwardRef(function Gantt(
const restPropsRef = useRef();
restPropsRef.current = restProps;
// init stores
const dataStore = useMemo(() => new DataStore(writable), []);
// locale and formats
// uses same logic as the Locale component
const words = useMemo(() => ({ ...coreEn, ...en }), []);
const i18nCtx = useContext(context.i18n);
const locale = useMemo(() => {
if (!i18nCtx) return l(words);
return i18nCtx.extend(words, true);
}, [i18nCtx, words]);
// prepare configuration objects
const lCalendar = useMemo(() => locale.getRaw().calendar, [locale]);
const normalizedConfig = useMemo(() => {
let config = {
zoom: prepareZoom(zoom, lCalendar),
scales: prepareScales(scales, lCalendar),
columns: prepareColumns(columns, lCalendar),
links: normalizeLinks(links),
cellWidth,
};
if (config.zoom) {
config = {
...config,
...normalizeZoom(
config.zoom,
prepareFormats(lCalendar, locale.getGroup('gantt')),
config.scales,
cellWidth,
),
};
}
return config;
}, [zoom, scales, columns, links, cellWidth, lCalendar, locale]);
// parse task dates effect
const parsedTasksRef = useRef(null);
if (parsedTasksRef.current !== tasks) {
parseTaskDates(tasks, { durationUnit, splitTasks, calendar });
parsedTasksRef.current = tasks;
}
useEffect(() => {
parseTaskDates(tasks, { durationUnit, splitTasks, calendar });
}, [tasks, durationUnit, calendar, splitTasks]);
const firstInRoute = useMemo(() => dataStore.in, [dataStore]);
const lastInRouteRef = useRef(null);
@@ -86,7 +156,6 @@ const Gantt = forwardRef(function Gantt(
firstInRoute.setNext(lastInRouteRef.current);
}
// writable prop for two-way binding tableAPI
const [tableAPI, setTableAPI] = useState(null);
const tableAPIRef = useRef(null);
@@ -112,11 +181,11 @@ const Gantt = forwardRef(function Gantt(
waitRender
? new Promise((res) => setTimeout(() => res(tableAPIRef.current), 1))
: tableAPIRef.current,
getHistory: () => dataStore.getHistory(),
}),
[dataStore, firstInRoute],
);
// expose API via ref
useImperativeHandle(
ref,
@@ -134,17 +203,17 @@ const Gantt = forwardRef(function Gantt(
// const prev = dataStore.getState();
dataStore.init({
tasks,
links,
links: normalizedConfig.links,
start,
columns,
columns: normalizedConfig.columns,
end,
lengthUnit,
cellWidth,
cellWidth: normalizedConfig.cellWidth,
cellHeight,
scaleHeight,
scales,
scales: normalizedConfig.scales,
taskTypes,
zoom,
zoom: normalizedConfig.zoom,
selected,
activeTask,
baselines,
@@ -152,22 +221,28 @@ const Gantt = forwardRef(function Gantt(
unscheduledTasks,
markers,
durationUnit,
criticalPath,
schedule,
projectStart,
projectEnd,
calendar,
undo,
_weekStart: lCalendar.weekStart,
splitTasks,
});
}
initOnceRef.current++;
}, [
api,
init,
tasks,
links,
normalizedConfig,
start,
columns,
end,
lengthUnit,
cellWidth,
cellHeight,
scaleHeight,
scales,
taskTypes,
zoom,
selected,
activeTask,
baselines,
@@ -175,22 +250,31 @@ const Gantt = forwardRef(function Gantt(
unscheduledTasks,
markers,
durationUnit,
criticalPath,
schedule,
projectStart,
projectEnd,
calendar,
undo,
lCalendar,
splitTasks,
dataStore,
]);
if (initOnceRef.current === 0) {
dataStore.init({
tasks,
links,
links: normalizedConfig.links,
start,
columns,
columns: normalizedConfig.columns,
end,
lengthUnit,
cellWidth,
cellWidth: normalizedConfig.cellWidth,
cellHeight,
scaleHeight,
scales,
scales: normalizedConfig.scales,
taskTypes,
zoom,
zoom: normalizedConfig.zoom,
selected,
activeTask,
baselines,
@@ -198,11 +282,31 @@ const Gantt = forwardRef(function Gantt(
unscheduledTasks,
markers,
durationUnit,
criticalPath,
schedule,
projectStart,
projectEnd,
calendar,
undo,
_weekStart: lCalendar.weekStart,
splitTasks,
});
}
// highlightTime from calendar
const highlightTime = useMemo(() => {
if (calendar) {
return (day, unit) => {
if (unit == 'day' && !calendar.getDayHours(day)) return 'wx-weekend';
if (unit == 'hour' && !calendar.getDayHours(day)) return 'wx-weekend';
return '';
};
}
return highlightTimeProp;
}, [calendar, highlightTimeProp]);
return (
<Locale words={en} optional={true}>
<context.i18n.Provider value={locale}>
<StoreContext.Provider value={api}>
<Layout
taskTemplate={taskTemplate}
@@ -212,7 +316,7 @@ const Gantt = forwardRef(function Gantt(
onTableAPIChange={setTableAPI}
/>
</StoreContext.Provider>
</Locale>
</context.i18n.Provider>
);
});

View File

@@ -2,6 +2,7 @@
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.wx-pseudo-rows.wx-jlbQoHOz {
width: 100%;

View File

@@ -14,6 +14,7 @@ import { modeObserver } from '../helpers/modeResizeObserver';
import storeContext from '../context';
import { useStore } from '@svar-ui/lib-react';
import './Layout.css';
import { flushSync } from 'react-dom'
function Layout(props) {
const {
@@ -21,20 +22,21 @@ function Layout(props) {
readonly,
cellBorders,
highlightTime,
onTableAPIChange
onTableAPIChange,
} = props;
const api = useContext(storeContext);
const rTasks = useStore(api, "_tasks");
const rScales = useStore(api, "_scales");
const rCellHeight = useStore(api, "cellHeight");
const rColumns = useStore(api, "columns");
const rScrollTask = useStore(api, "_scrollTask");
const rTasks = useStore(api, '_tasks');
const rScales = useStore(api, '_scales');
const rCellHeight = useStore(api, 'cellHeight');
const rColumns = useStore(api, 'columns');
const rScrollTask = useStore(api, '_scrollTask');
const undo = useStore(api, 'undo');
const [compactMode, setCompactMode] = useState(false);
let [gridWidth, setGridWidth] = useState(0);
const [ganttWidth, setGanttWidth] = useState(undefined);
const [ganttHeight, setGanttHeight] = useState(undefined);
const [ganttWidth, setGanttWidth] = useState(0);
const [ganttHeight, setGanttHeight] = useState(0);
const [innerWidth, setInnerWidth] = useState(undefined);
const [display, setDisplay] = useState('all');
@@ -57,7 +59,6 @@ function Layout(props) {
[display],
);
useEffect(() => {
const ro = modeObserver(handleResize);
ro.observe();
@@ -81,7 +82,6 @@ function Layout(props) {
return w;
}, [rColumns, compactMode, display]);
useEffect(() => {
setGridWidth(gridColumnWidth);
}, [gridColumnWidth]);
@@ -125,6 +125,10 @@ function Layout(props) {
};
}, [chartRef.current, expandScale]);
useEffect(() => {
expandScale();
}, [fullWidth]);
const ganttDivRef = useRef(null);
const pseudoRowsRef = useRef(null);
@@ -191,39 +195,33 @@ function Layout(props) {
);
useEffect(() => {
scrollToTask(rScrollTask);
scrollToTask(rScrollTask);
}, [rScrollTask]);
useEffect(() => {
const el = ganttDivRef.current;
if (!el) return;
const ganttDiv = ganttDivRef.current;
const pseudoRows = pseudoRowsRef.current;
if (!ganttDiv || !pseudoRows) return;
const update = () => {
setGanttHeight(el.offsetHeight);
setGanttWidth(el.offsetWidth);
flushSync(() => {
setGanttHeight(ganttDiv.offsetHeight);
setGanttWidth(ganttDiv.offsetWidth);
setInnerWidth(pseudoRows.offsetWidth);
});
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
ro.observe(ganttDiv);
return () => ro.disconnect();
}, [ganttDivRef.current]);
useEffect(() => {
const el = pseudoRowsRef.current;
if (!el) return;
const update = () => {
setInnerWidth(el.offsetWidth);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [pseudoRowsRef.current]);
const layoutRef = useRef(null);
const cleanupRef = useRef(null);
useEffect(() => {
if (cleanupRef.current) return;
if (cleanupRef.current) {
cleanupRef.current.destroy();
cleanupRef.current = null;
}
const node = layoutRef.current;
if (!node) return;
@@ -234,6 +232,8 @@ function Layout(props) {
'ctrl+x': true,
'ctrl+d': true,
backspace: true,
'ctrl+z': undo,
'ctrl+y': undo,
},
exec: (ev) => {
if (!ev.isInput) api.exec('hotkey', ev);
@@ -244,7 +244,7 @@ function Layout(props) {
cleanupRef.current?.destroy();
cleanupRef.current = null;
};
}, []);
}, [undo]);
return (
<div className="wx-jlbQoHOz wx-gantt" ref={ganttDivRef} onScroll={onScroll}>
@@ -276,8 +276,7 @@ function Layout(props) {
value={gridWidth}
display={display}
compactMode={compactMode}
minValue="50"
maxValue="800"
containerWidth={ganttWidth}
onMove={(value) => setGridWidth(value)}
onDisplayChange={(display) => setDisplay(display)}
/>

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useCallback } from 'react';
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useWritableProp } from '@svar-ui/lib-react';
import './Resizer.css';
@@ -7,16 +7,17 @@ function Resizer(props) {
position = 'after',
size = 4,
dir = 'x',
minValue = 0,
maxValue = 0,
onMove,
onDisplayChange,
compactMode,
containerWidth = 0,
leftThreshold = 50,
rightThreshold = 50,
} = props;
const [value, setValue] = useWritableProp(props.value ?? 0);
const [display, setDisplay] = useWritableProp(props.display ?? 'all');
function getBox(val) {
let offset = 0;
if (position == 'center') offset = size / 2;
@@ -35,24 +36,52 @@ function Resizer(props) {
}
const [active, setActive] = useState(false);
const [initialPosition, setInitialPosition] = useState(null);
const startRef = useRef(0);
const posRef = useRef();
const timeoutRef = useRef();
const displayRef = useRef(display);
useEffect(() => {
displayRef.current = display;
}, [display]);
useEffect(() => {
if (initialPosition === null && value > 0) {
setInitialPosition(value);
}
}, [initialPosition, value]);
function getEventPos(ev) {
return dir == 'x' ? ev.clientX : ev.clientY;
}
const move = useCallback((ev) => {
const newPos = posRef.current + getEventPos(ev) - startRef.current;
if (
(!minValue || minValue <= newPos) &&
(!maxValue || maxValue >= newPos)
) {
const move = useCallback(
(ev) => {
const newPos = posRef.current + getEventPos(ev) - startRef.current;
setValue(newPos);
onMove(newPos)
}
}, []);
let nextDisplay;
if (newPos <= leftThreshold) {
nextDisplay = 'chart';
} else if (containerWidth - newPos <= rightThreshold) {
nextDisplay = 'grid';
} else {
nextDisplay = 'all';
}
if (displayRef.current !== nextDisplay) {
setDisplay(nextDisplay);
displayRef.current = nextDisplay;
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => onMove && onMove(newPos), 100);
},
[containerWidth, leftThreshold, rightThreshold, onMove],
);
const up = useCallback(() => {
document.body.style.cursor = '';
@@ -69,6 +98,11 @@ function Resizer(props) {
const down = useCallback(
(ev) => {
// Prevent dragging when in normal mode and only one view is visible
if (!compactMode && (display === 'grid' || display === 'chart')) {
return;
}
startRef.current = getEventPos(ev);
posRef.current = value;
@@ -80,29 +114,40 @@ function Resizer(props) {
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
},
[cursor, move, up, value],
[cursor, move, up, value, compactMode, display],
);
function handleExpandLeft() {
let newDisplay;
if (compactMode) {
newDisplay = display === 'chart' ? 'grid' : 'chart';
} else {
newDisplay = display === 'all' ? 'chart' : 'all';
function resetToInitial() {
setDisplay('all');
if (initialPosition !== null) {
setValue(initialPosition);
if (onMove) onMove(initialPosition);
}
setDisplay(newDisplay);
onDisplayChange(newDisplay);
}
function handleExpand(direction) {
if (compactMode) {
const newDisplay = display === 'chart' ? 'grid' : 'chart';
setDisplay(newDisplay);
onDisplayChange(newDisplay);
} else {
if (display === 'grid' || display === 'chart') {
resetToInitial();
onDisplayChange('all');
} else {
const newDisplay = direction === 'left' ? 'chart' : 'grid';
setDisplay(newDisplay);
onDisplayChange(newDisplay);
}
}
}
function handleExpandLeft() {
handleExpand('left');
}
function handleExpandRight() {
let newDisplay;
if (compactMode) {
newDisplay = display === 'grid' ? 'chart' : 'grid';
} else {
newDisplay = display === 'all' ? 'grid' : 'all';
}
setDisplay(newDisplay);
onDisplayChange(newDisplay);
handleExpand('right');
}
const b = useMemo(() => getBox(value), [value, position, size, dir]);
@@ -122,25 +167,21 @@ function Resizer(props) {
onMouseDown={down}
style={{ width: b.size[0], height: b.size[1], cursor }}
>
<div className='wx-pFykzMlT wx-button-expand-box'>
<div
className='wx-pFykzMlT wx-button-expand-content wx-button-expand-left'
>
<div className="wx-pFykzMlT wx-button-expand-box">
<div className="wx-pFykzMlT wx-button-expand-content wx-button-expand-left">
<i
className='wx-pFykzMlT wxi-menu-left'
className="wx-pFykzMlT wxi-menu-left"
onClick={handleExpandLeft}
></i>
</div>
<div
className='wx-pFykzMlT wx-button-expand-content wx-button-expand-right'
>
<div className="wx-pFykzMlT wx-button-expand-content wx-button-expand-right">
<i
className='wx-pFykzMlT wxi-menu-right'
className="wx-pFykzMlT wxi-menu-right"
onClick={handleExpandRight}
></i>
</div>
</div>
<div className='wx-pFykzMlT wx-resizer-line'></div>
<div className="wx-pFykzMlT wx-resizer-line"></div>
</div>
);
}

View File

@@ -3,50 +3,76 @@ import { Toolbar as WxToolbar } from '@svar-ui/react-toolbar';
import { useStoreLater } from '@svar-ui/lib-react';
import {
handleAction,
defaultToolbarButtons,
getToolbarButtons,
isHandledAction,
} from '@svar-ui/gantt-store';
import { locale } from '@svar-ui/lib-dom';
import { en } from '@svar-ui/gantt-locales';
import { context } from '@svar-ui/react-core';
export default function Toolbar({
api = null,
items = [...defaultToolbarButtons],
}) {
export default function Toolbar({ api = null, items = [] }) {
const i18nCtx = useContext(context.i18n);
const i18nLocal = useMemo(() => (i18nCtx ? i18nCtx : locale(en)), [i18nCtx]);
const _ = useMemo(() => i18nLocal.getGroup('gantt'), [i18nLocal]);
const rSelected = useStoreLater(api, "_selected");
const rTasks = useStoreLater(api, "_tasks");
const _selected = useStoreLater(api, '_selected');
const undo = useStoreLater(api, 'undo');
const history = useStoreLater(api, 'history');
const splitTasks = useStoreLater(api, 'splitTasks');
const historyActions = ['undo', 'redo'];
const finalItems = useMemo(() => {
return items.map((b) => {
const fullButtons = getToolbarButtons({ undo: true, splitTasks: true });
const buttons = items.length
? items
: getToolbarButtons({
undo,
splitTasks,
});
return buttons.map((b) => {
let item = { ...b, disabled: false };
item.handler = isHandledAction(defaultToolbarButtons, item.id)
item.handler = isHandledAction(fullButtons, item.id)
? (it) => handleAction(api, it.id, null, _)
: item.handler;
if (item.text) item.text = _(item.text);
if (item.menuText) item.menuText = _(item.menuText);
return item;
});
}, [items, api, _]);
}, [items, api, _, undo, splitTasks]);
const buttons = useMemo(() => {
if (api) {
if (rSelected?.length) {
return finalItems.map((item) => {
if (!item.check) return item;
const isDisabled = rSelected.some(
(task) => !item.check(task, rTasks),
);
return { ...item, disabled: isDisabled };
const finalButtons = [];
finalItems.forEach((item) => {
const action = item.id;
if (action === 'add-task') {
finalButtons.push(item);
} else if (!historyActions.includes(action)) {
if (!_selected?.length || !api) return;
finalButtons.push({
...item,
disabled:
item.isDisabled &&
_selected.some((task) => item.isDisabled(task, api.getState())),
});
} else if (historyActions.includes(action)) {
finalButtons.push({
...item,
disabled: item.isDisabled(history),
});
}
}
return [{ ...finalItems[0], disabled: false }];
}, [api, rSelected, rTasks, finalItems]);
});
// filter out consecutive separators
return finalButtons.filter((button, index) => {
if (api && button.isHidden)
return !_selected.some((task) => button.isHidden(task, api.getState()));
if (button.comp === 'separator') {
const nextButton = finalButtons[index + 1];
if (!nextButton || nextButton.comp === 'separator') return false;
}
return true;
});
}, [api, _selected, history, finalItems]);
if (!i18nCtx) {
return (

View File

@@ -0,0 +1,22 @@
.wx-segments.wx-GKbcLEGA {
position: relative;
width: 100%;
height: 100%;
}
.wx-segment.wx-GKbcLEGA {
height: 100%;
}
.wx-segments.wx-GKbcLEGA::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 0;
border-top: 1px dashed #7f7f7f;
transform: translateY(-50%);
}
.wx-progress-percent.wx-GKbcLEGA {
background-color: var(--wx-gantt-task-fill-color);
}

View File

@@ -0,0 +1,60 @@
import './BarSegments.css';
function BarSegments(props) {
const { task, type } = props;
function segmentStyle(i) {
const s = task.segments[i];
return {
left: `${s.$x}px`,
top: '0px',
width: `${s.$w}px`,
height: '100%',
};
}
function getSegProgress(segmentIndex) {
if (!task.progress) return 0;
const progress = (task.duration * task.progress) / 100;
const segments = task.segments;
let duration = 0,
i = 0,
result = null;
do {
const s = segments[i];
if (i === segmentIndex) {
if (duration > progress) result = 0;
else result = Math.min((progress - duration) / s.duration, 1) * 100;
}
duration += s.duration;
i++;
} while (result === null && i < segments.length);
return result || 0;
}
return (
<div className="wx-segments wx-GKbcLEGA">
{task.segments.map((seg, i) => (
<div
key={i}
className={`wx-segment wx-bar wx-${type} wx-GKbcLEGA`}
data-segment={i}
style={segmentStyle(i)}
>
{task.progress ? (
<div className="wx-progress-wrapper">
<div
className="wx-progress-percent wx-GKbcLEGA"
style={{ width: `${getSegProgress(i)}%` }}
></div>
</div>
) : null}
<div className="wx-content">{seg.text || ''}</div>
</div>
))}
</div>
);
}
export default BarSegments;

View File

@@ -17,7 +17,9 @@
overflow: hidden;
}
.wx-bar.wx-GKbcLEGA {
.wx-bar.wx-GKbcLEGA,
.wx-bar.wx-GKbcLEGA .wx-segment {
pointer-events: all;
box-sizing: border-box;
position: absolute;
border-radius: var(--wx-gantt-bar-border-radius);
@@ -37,22 +39,24 @@
.wx-bar.wx-reorder-task.wx-GKbcLEGA {
z-index: 3;
}
.wx-content.wx-GKbcLEGA {
.wx-bar.wx-GKbcLEGA .wx-content {
overflow: hidden;
text-overflow: ellipsis;
}
.wx-task.wx-GKbcLEGA {
.wx-task:not(.wx-split).wx-GKbcLEGA,
.wx-task.wx-GKbcLEGA .wx-segment {
color: var(--wx-gantt-task-font-color);
background-color: var(--wx-gantt-task-color);
border: var(--wx-gantt-task-border);
}
.wx-task.wx-selected.wx-GKbcLEGA {
.wx-task.wx-selected:not(.wx-split).wx-GKbcLEGA {
border: 1px solid var(--wx-gantt-task-border-color);
box-shadow: var(--wx-gantt-bar-shadow);
}
.wx-task:hover.wx-GKbcLEGA {
.wx-task:not(.wx-split):hover.wx-GKbcLEGA,
.wx-task.wx-GKbcLEGA .wx-segment:hover {
box-shadow: var(--wx-gantt-bar-shadow);
}
@@ -71,7 +75,7 @@
box-shadow: var(--wx-gantt-bar-shadow);
}
.wx-milestone .wx-content.wx-GKbcLEGA {
.wx-milestone.wx-GKbcLEGA .wx-content {
position: absolute;
top: 0;
left: 0;
@@ -80,7 +84,7 @@
z-index: 2;
}
.wx-bar:not(.wx-milestone) .wx-content.wx-GKbcLEGA {
.wx-bar:not(.wx-milestone).wx-GKbcLEGA .wx-content {
position: relative;
z-index: 2;
}
@@ -97,19 +101,19 @@
border-color: var(--wx-gantt-milestone-color);
}
.wx-milestone .wx-text-out.wx-GKbcLEGA {
.wx-milestone.wx-GKbcLEGA .wx-text-out {
padding: 0 2px;
left: 100%;
}
.wx-milestone .wx-content.wx-GKbcLEGA {
.wx-milestone.wx-GKbcLEGA .wx-content {
height: 100%;
background-color: var(--wx-gantt-milestone-color);
transform: rotate(45deg) scale(0.75);
border-radius: var(--wx-gantt-milestone-border-radius);
}
.wx-progress-wrapper.wx-GKbcLEGA {
.wx-bar.wx-GKbcLEGA .wx-progress-wrapper {
position: absolute;
width: 100%;
height: 100%;
@@ -118,7 +122,7 @@
overflow: hidden;
}
.wx-progress-percent.wx-GKbcLEGA {
.wx-bar.wx-GKbcLEGA .wx-progress-percent {
height: 100%;
}
@@ -190,14 +194,28 @@
pointer-events: none;
}
.wx-bar.wx-GKbcLEGA button.wx-button.wx-delete-button {
position: absolute;
z-index: 4;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
padding: 0;
}
.wx-delete-button-icon {
display: block;
line-height: 14px;
font-size: 10px;
}
.wx-bar.wx-GKbcLEGA .wx-delete-button.wx-left,
.wx-link.wx-left.wx-GKbcLEGA {
left: -16px;
}
.wx-bar.wx-GKbcLEGA .wx-delete-button.wx-right,
.wx-link.wx-right.wx-GKbcLEGA {
right: -16px;
}
.wx-link.wx-target:hover.wx-GKbcLEGA,
.wx-link.wx-selected.wx-GKbcLEGA,
.wx-bar:hover .wx-link.wx-target.wx-GKbcLEGA,
@@ -206,11 +224,10 @@
cursor: pointer;
}
.wx-link.wx-selected.wx-GKbcLEGA {
.wx-bar:not(.wx-split).wx-GKbcLEGA .wx-link.wx-selected {
border-color: inherit;
}
.wx-link.wx-selected .wx-inner.wx-GKbcLEGA {
.wx-bar:not(.wx-split).wx-GKbcLEGA .wx-link.wx-selected .wx-inner {
border-color: inherit;
}
@@ -235,3 +252,55 @@
outline: 1px solid var(--wx-color-primary);
outline-offset: 1.6px;
}
/* critical path markers */
.wx-task.wx-critical.wx-GKbcLEGA {
background-color: var(--wx-gantt-task-critical-color);
}
.wx-task.wx-critical.wx-selected.wx-GKbcLEGA {
border: 1px solid var(--wx-gantt-task-critical-color);
}
.wx-task.wx-critical .wx-progress-percent {
background-color: var(--wx-gantt-task-critical-fill-color);
}
.wx-milestone.wx-critical.wx-GKbcLEGA .wx-content {
background-color: var(--wx-gantt-critical-color);
}
.wx-milestone.wx-critical.wx-GKbcLEGA {
border-color: var(--wx-gantt-critical-color);
}
.wx-summary.wx-critical.wx-GKbcLEGA {
background-color: var(--wx-gantt-summary-critical-color);
}
.wx-summary.wx-critical .wx-progress-percent {
background-color: var(--wx-gantt-summary-critical-fill-color);
}
.wx-summary.wx-critical.wx-selected.wx-GKbcLEGA {
border: 1px solid var(--wx-gantt-summary-critical-color);
}
/*split tasks*/
.wx-split.wx-selected.wx-GKbcLEGA {
border-color: var(--wx-gantt-task-border-color);
}
.wx-bars.wx-GKbcLEGA .wx-split.wx-bar {
background: transparent;
border-color: transparent;
}
.wx-split.wx-GKbcLEGA .wx-link.wx-selected,
.wx-split.wx-GKbcLEGA .wx-link.wx-selected .wx-inner {
border-color: var(--wx-gantt-task-border-color);
}
.wx-critical.wx-GKbcLEGA .wx-segment {
background-color: var(--wx-gantt-task-critical-color);
}
.wx-critical.wx-selected.wx-GKbcLEGA .wx-segment {
border: 1px solid var(--wx-gantt-task-critical-color);
}
.wx-critical.wx-GKbcLEGA .wx-segment .wx-progress-percent {
background-color: var(--wx-gantt-task-critical-fill-color);
}
.wx-critical.wx-split.wx-GKbcLEGA .wx-link.wx-selected,
.wx-critical.wx-split.wx-GKbcLEGA .wx-link.wx-selected .wx-inner {
border-color: var(--wx-gantt-task-critical-color);
}

View File

@@ -11,6 +11,10 @@ import { locate, locateID } from '@svar-ui/lib-dom';
import { getID } from '../../helpers/locate';
import storeContext from '../../context';
import { useStore, useStoreWithCounter } from '@svar-ui/lib-react';
import { isSegmentMoveAllowed, extendDragOptions } from '@svar-ui/gantt-store';
import { Button } from '@svar-ui/react-core';
import Links from './Links.jsx';
import BarSegments from './BarSegments.jsx';
import './Bars.css';
function Bars(props) {
@@ -18,14 +22,18 @@ function Bars(props) {
const api = useContext(storeContext);
const [rTasksValue, rTasksCounter] = useStoreWithCounter(api, "_tasks");
const [rLinksValue, rLinksCounter] = useStoreWithCounter(api, "_links");
const areaValue = useStore(api, "area");
const scalesValue = useStore(api, "_scales");
const taskTypesValue = useStore(api, "taskTypes");
const baselinesValue = useStore(api, "baselines");
const selectedValue = useStore(api, "_selected");
const scrollTaskStore = useStore(api, "_scrollTask");
const [rTasksValue, rTasksCounter] = useStoreWithCounter(api, '_tasks');
const [rLinksValue, rLinksCounter] = useStoreWithCounter(api, '_links');
const areaValue = useStore(api, 'area');
const scalesValue = useStore(api, '_scales');
const taskTypesValue = useStore(api, 'taskTypes');
const baselinesValue = useStore(api, 'baselines');
const selectedValue = useStore(api, '_selected');
const scrollTaskStore = useStore(api, '_scrollTask');
const criticalPath = useStore(api, 'criticalPath');
const tree = useStore(api, 'tasks');
const schedule = useStore(api, 'schedule');
const splitTasks = useStore(api, 'splitTasks');
const tasks = useMemo(() => {
if (!areaValue || !Array.isArray(rTasksValue)) return [];
@@ -45,6 +53,8 @@ function Bars(props) {
const [taskMove, setTaskMove] = useState(null);
const progressFromRef = useRef(null);
const [selectedLink, setSelectedLink] = useState(null);
const [touched, setTouched] = useState(undefined);
const touchTimerRef = useRef(null);
@@ -66,8 +76,7 @@ function Bars(props) {
}, [hasFocus, selectedValue]);
useEffect(() => {
if (!scrollTaskStore)
return;
if (!scrollTaskStore) return;
if (hasFocus && scrollTaskStore) {
const { id } = scrollTaskStore;
const node = containerRef.current?.querySelector(
@@ -102,13 +111,16 @@ function Bars(props) {
const getMoveMode = useCallback(
(node, e, task) => {
if (e.target.classList.contains('wx-line')) return '';
if (!task) task = api.getTask(getID(node));
if (task.type === 'milestone' || task.type == 'summary') return '';
if (task.type === 'milestone' || task.type === 'summary') return '';
const rect = node.getBoundingClientRect();
const p = (e.clientX - rect.left) / rect.width;
let delta = 0.2 / (rect.width > 200 ? rect.width / 200 : 1);
const segmentNode = locate(e, 'data-segment');
if (segmentNode) node = segmentNode;
const { left, width } = node.getBoundingClientRect();
const p = (e.clientX - left) / width;
let delta = 0.2 / (width > 200 ? width / 200 : 1);
if (p < delta) return 'start';
if (p > 1 - delta) return 'end';
return '';
@@ -122,7 +134,7 @@ function Bars(props) {
const id = getID(node);
const task = api.getTask(id);
const css = point.target.classList;
if (point.target.closest('.wx-delete-button')) return;
if (!readonly) {
if (css.contains('wx-progress-marker')) {
const { progress } = api.getTask(id);
@@ -138,19 +150,29 @@ function Bars(props) {
} else {
const mode = getMoveMode(node, point, task) || 'move';
setTaskMove({
const newTaskMove = {
id,
mode,
x: clientX,
dx: 0,
l: task.$x,
w: task.$w,
});
};
if (splitTasks && task.segments?.length) {
const segNode = locate(point, 'data-segment');
if (segNode) {
newTaskMove.segmentIndex = segNode.dataset['segment'] * 1;
extendDragOptions(task, newTaskMove);
}
}
setTaskMove(newTaskMove);
}
startDrag();
}
},
[api, readonly, getMoveMode, startDrag],
[api, readonly, getMoveMode, startDrag, splitTasks],
);
const mousedown = useCallback(
@@ -162,7 +184,7 @@ function Bars(props) {
down(node, e);
},
[readonly, lengthUnitWidth, totalWidth, taskMove, linkFrom],
[down],
);
const touchstart = useCallback(
@@ -175,22 +197,32 @@ function Bars(props) {
}, 300);
}
},
[readonly],
[down],
);
const onSelectLink = useCallback(
(id) => {
setSelectedLink(id && { ...rLinksValue.find((link) => link.id === id) });
},
[rLinksValue],
);
const up = useCallback(() => {
if (progressFromRef.current) {
const { dx, id, marker, value } = progressFromRef.current;
progressFromRef.current = null;
if (typeof value != 'undefined' && dx)
api.exec('update-task', { id, task: { progress: value } });
api.exec('update-task', {
id,
task: { progress: value },
inProgress: false,
});
marker.classList.remove('wx-progress-in-drag');
ignoreNextClickRef.current = true;
endDrag();
} else if (taskMove) {
const { id, mode, dx, l, w, start } = taskMove;
const { id, mode, dx, l, w, start, segment, index } = taskMove;
setTaskMove(null);
if (start) {
const diff = Math.round(dx / lengthUnitWidth);
@@ -201,19 +233,23 @@ function Bars(props) {
width: w,
left: l,
inProgress: false,
...(segment && { segmentIndex: index }),
});
} else {
let update = {};
let task = api.getTask(id);
if (mode == 'move') {
if (segment) task = task.segments[index];
if (mode === 'move') {
update.start = task.start;
update.end = task.end;
} else update[mode] = task[mode];
api.exec('update-task', {
id,
task: update,
diff,
task: update,
...(segment && { segmentIndex: index }),
});
}
ignoreNextClickRef.current = true;
@@ -245,14 +281,18 @@ function Bars(props) {
inProgress: true,
});
} else if (taskMove) {
const { mode, l, w, x, id, start } = taskMove;
onSelectLink(null);
const { mode, l, w, x, id, start, segment, index } = taskMove;
const task = api.getTask(id);
const dx = clientX - x;
if (
(!start && Math.abs(dx) < 20) ||
(mode === 'start' && w - dx < lengthUnitWidth) ||
(mode === 'end' && w + dx < lengthUnitWidth) ||
(mode == 'move' &&
((dx < 0 && l + dx < 0) || (dx > 0 && l + w + dx > totalWidth)))
(mode === 'move' &&
((dx < 0 && l + dx < 0) ||
(dx > 0 && l + w + dx > totalWidth))) ||
(taskMove.segment && !isSegmentMoveAllowed(task, taskMove))
)
return;
@@ -270,20 +310,18 @@ function Bars(props) {
width = w;
}
let ev = {
api.exec('drag-task', {
id,
width: width,
left: left,
inProgress: true,
};
...(segment && { segmentIndex: index }),
});
api.exec('drag-task', ev);
const task = api.getTask(id);
if (
!nextTaskMove.start &&
((mode == 'move' && task.$x == l) ||
(mode != 'move' && task.$w == w))
((mode === 'move' && task.$x == l) ||
(mode !== 'move' && task.$w == w))
) {
ignoreNextClickRef.current = true;
up();
@@ -292,15 +330,27 @@ function Bars(props) {
nextTaskMove.start = true;
setTaskMove(nextTaskMove);
} else {
const mnode = locate(e);
if (mnode) {
const mode = getMoveMode(mnode, point);
mnode.style.cursor = mode && !readonly ? 'col-resize' : 'pointer';
const taskNode = locate(e);
if (taskNode) {
const task = api.getTask(getID(taskNode));
const segNode = locate(e, 'data-segment');
const barNode = segNode || taskNode;
const mode = getMoveMode(barNode, point, task);
barNode.style.cursor = mode && !readonly ? 'col-resize' : 'pointer';
}
}
}
},
[api, readonly, taskMove, lengthUnitWidth, totalWidth, getMoveMode],
[
api,
readonly,
taskMove,
lengthUnitWidth,
totalWidth,
getMoveMode,
onSelectLink,
up,
],
);
const mousemove = useCallback(
@@ -347,21 +397,22 @@ function Bars(props) {
(e) => {
if (!readonly) {
const id = locateID(e.target);
if (id && !e.target.classList.contains('wx-link'))
api.exec('show-editor', { id });
if (id && !e.target.classList.contains('wx-link')) {
const segmentIndex = locateID(e.target, 'data-segment');
api.exec('show-editor', {
id,
...(segmentIndex !== null && { segmentIndex }),
});
}
}
},
[api, readonly],
);
const types = ['e2s', 's2s', 'e2e', 's2e'];
const getLinkType = useCallback(
(fromStart, toStart) => {
return types[(fromStart ? 1 : 0) + (toStart ? 0 : 2)];
},
[],
);
const getLinkType = useCallback((fromStart, toStart) => {
return types[(fromStart ? 1 : 0) + (toStart ? 0 : 2)];
}, []);
const alreadyLinked = useCallback(
(target, toStart) => {
@@ -413,17 +464,32 @@ function Bars(props) {
},
});
}
} else if (css.contains('wx-delete-button-icon')) {
api.exec('delete-link', { id: selectedLink.id });
setSelectedLink(null);
} else {
let segmentIndex;
const segmentNode = locate(e, 'data-segment');
if (segmentNode) segmentIndex = segmentNode.dataset.segment * 1;
api.exec('select-task', {
id,
toggle: e.ctrlKey || e.metaKey,
range: e.shiftKey,
segmentIndex,
});
}
}
removeLinkMarker();
},
[api, linkFrom, rLinksCounter],
[
api,
linkFrom,
rLinksCounter,
selectedLink,
alreadyLinked,
getLinkType,
removeLinkMarker,
],
);
const taskStyle = useCallback((task) => {
@@ -454,15 +520,20 @@ function Bars(props) {
[touched],
);
const taskTypeIds = useMemo(
() => taskTypesValue.map((t) => t.id),
[taskTypesValue],
);
const taskTypeCss = useCallback(
(type) => {
let css = taskTypesValue.some((t) => type === t.id) ? type : 'task';
if (css !== 'task' && css !== 'milestone' && css !== 'summary')
let css = taskTypeIds.includes(type) ? type : 'task';
if (!['task', 'milestone', 'summary'].includes(type)) {
css = `task ${css}`;
}
return css;
},
[taskTypesValue],
[taskTypeIds],
);
const forward = useCallback(
@@ -472,6 +543,35 @@ function Bars(props) {
[api],
);
const isTaskCritical = useCallback(
(taskId) => {
return criticalPath && tree.byId(taskId).$critical;
},
[criticalPath, tree],
);
const isLinkMarkerVisible = useCallback(
(id) => {
if (schedule?.auto) {
const summaryIds = tree.getSummaryId(id, true);
const linkFromSummaryIds = tree.getSummaryId(linkFrom.id, true);
return (
linkFrom?.id &&
!(Array.isArray(summaryIds) ? summaryIds : [summaryIds]).includes(
linkFrom.id,
) &&
!(
Array.isArray(linkFromSummaryIds)
? linkFromSummaryIds
: [linkFromSummaryIds]
).includes(id)
);
}
return linkFrom;
},
[schedule, tree, linkFrom],
);
return (
<div
className="wx-GKbcLEGA wx-bars"
@@ -490,86 +590,124 @@ function Bars(props) {
return false;
}}
>
<Links
onSelectLink={onSelectLink}
selectedLink={selectedLink}
readonly={readonly}
/>
{tasks.map((task) => {
if (task.$skip && task.$skip_baseline) return null;
const barClass =
`wx-bar wx-${taskTypeCss(task.type)}` +
(touched && taskMove && task.id === taskMove.id ? ' wx-touch' : '') +
(linkFrom && linkFrom.id === task.id ? ' wx-selected' : '') +
(task.$reorder ? ' wx-reorder-task' : '');
(isTaskCritical(task.id) ? ' wx-critical' : '') +
(task.$reorder ? ' wx-reorder-task' : '') +
(splitTasks && task.segments ? ' wx-split' : '');
const leftLinkClass =
'wx-link wx-left' +
(linkFrom ? ' wx-visible' : '') +
(!linkFrom || !alreadyLinked(task.id, true) ? ' wx-target' : '') +
(!linkFrom ||
(!alreadyLinked(task.id, true) && isLinkMarkerVisible(task.id))
? ' wx-target'
: '') +
(linkFrom && linkFrom.id === task.id && linkFrom.start
? ' wx-selected'
: '');
: '') +
(isTaskCritical(task.id) ? ' wx-critical' : '');
const rightLinkClass =
'wx-link wx-right' +
(linkFrom ? ' wx-visible' : '') +
(!linkFrom || !alreadyLinked(task.id, false) ? ' wx-target' : '') +
(!linkFrom ||
(!alreadyLinked(task.id, false) && isLinkMarkerVisible(task.id))
? ' wx-target'
: '') +
(linkFrom && linkFrom.id === task.id && !linkFrom.start
? ' wx-selected'
: '');
: '') +
(isTaskCritical(task.id) ? ' wx-critical' : '');
return (
<Fragment key={task.id}>
{!task.$skip && <div
className={'wx-GKbcLEGA ' + barClass}
style={taskStyle(task)}
data-tooltip-id={task.id}
data-id={task.id}
tabIndex={focused === task.id ? 0 : -1}
>
{!readonly ? (
<div className={'wx-GKbcLEGA ' + leftLinkClass}>
<div className="wx-GKbcLEGA wx-inner"></div>
</div>
) : null}
{task.type !== 'milestone' ? (
<>
{task.progress ? (
<div className="wx-GKbcLEGA wx-progress-wrapper">
<div
className="wx-GKbcLEGA wx-progress-percent"
style={{ width: `${task.progress}%` }}
></div>
</div>
) : null}
{!readonly ? (
<div
className="wx-GKbcLEGA wx-progress-marker"
style={{ left: `calc(${task.progress}% - 10px)` }}
{!task.$skip && (
<div
className={'wx-GKbcLEGA ' + barClass}
style={taskStyle(task)}
data-tooltip-id={task.id}
data-id={task.id}
tabIndex={focused === task.id ? 0 : -1}
>
{!readonly ? (
task.id === selectedLink?.target &&
selectedLink?.type[2] === 's' ? (
<Button
type="danger"
css="wx-left wx-delete-button wx-delete-link"
>
{task.progress}
</div>
) : null}
{TaskTemplate ? (
<TaskTemplate data={task} api={api} onAction={forward} />
<i className="wxi-close wx-delete-button-icon"></i>
</Button>
) : (
<div className="wx-GKbcLEGA wx-content">
{task.text || ''}
<div className={'wx-GKbcLEGA ' + leftLinkClass}>
<div className="wx-GKbcLEGA wx-inner"></div>
</div>
)}
</>
) : (
<>
<div className="wx-GKbcLEGA wx-content"></div>
{TaskTemplate ? (
<TaskTemplate data={task} api={api} onAction={forward} />
) : (
<div className="wx-GKbcLEGA wx-text-out">{task.text}</div>
)}
</>
)}
)
) : null}
{!readonly ? (
<div className={'wx-GKbcLEGA ' + rightLinkClass}>
<div className="wx-GKbcLEGA wx-inner"></div>
</div>
) : null}
</div>
}
{task.type !== 'milestone' ? (
<>
{task.progress && !(splitTasks && task.segments) ? (
<div className="wx-GKbcLEGA wx-progress-wrapper">
<div
className="wx-GKbcLEGA wx-progress-percent"
style={{ width: `${task.progress}%` }}
></div>
</div>
) : null}
{!readonly && !(splitTasks && task.segments) ? (
<div
className="wx-GKbcLEGA wx-progress-marker"
style={{ left: `calc(${task.progress}% - 10px)` }}
>
{task.progress}
</div>
) : null}
{TaskTemplate ? (
<TaskTemplate data={task} api={api} onAction={forward} />
) : splitTasks && task.segments ? (
<BarSegments task={task} type={taskTypeCss(task.type)} />
) : (
<div className="wx-GKbcLEGA wx-content">
{task.text || ''}
</div>
)}
</>
) : (
<>
<div className="wx-GKbcLEGA wx-content"></div>
{TaskTemplate ? (
<TaskTemplate data={task} api={api} onAction={forward} />
) : (
<div className="wx-GKbcLEGA wx-text-out">{task.text}</div>
)}
</>
)}
{!readonly ? (
task.id === selectedLink?.target &&
selectedLink?.type[2] === 'e' ? (
<Button
type="danger"
css="wx-right wx-delete-button wx-delete-link"
>
<i className="wxi-close wx-delete-button-icon"></i>
</Button>
) : (
<div className={'wx-GKbcLEGA ' + rightLinkClass}>
<div className="wx-GKbcLEGA wx-inner"></div>
</div>
)
) : null}
</div>
)}
{baselinesValue && !task.$skip_baseline ? (
<div
className={

View File

@@ -1,22 +1,16 @@
import {
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useContext, useEffect, useRef, useState } from 'react';
import storeContext from '../../context';
import { grid } from '@svar-ui/gantt-store';
import { useStore } from '@svar-ui/lib-react';
function CellGrid({ borders = '' }) {
const api = useContext(storeContext);
const cellWidth = useStore(api, "cellWidth");
const cellHeight = useStore(api, "cellHeight");
const cellWidth = useStore(api, 'cellWidth');
const cellHeight = useStore(api, 'cellHeight');
const nodeRef = useRef(null);
const [color, setColor] = useState('#e4e4e4');
useEffect(() => {
if (typeof getComputedStyle !== 'undefined' && nodeRef.current) {
const border = getComputedStyle(nodeRef.current).getPropertyValue(

View File

@@ -17,8 +17,6 @@
text-align: center;
user-select: none;
transform: scaleX(-1);
}
.wx-default.wx-mR7v2Xag {
background: var(--wx-gantt-marker-color);
}

View File

@@ -8,12 +8,12 @@ import {
} from 'react';
import CellGrid from './CellGrid.jsx';
import Bars from './Bars.jsx';
import Links from './Links.jsx';
import { hotkeys } from '@svar-ui/grid-store';
import storeContext from '../../context';
import { useStore, useStoreWithCounter } from '@svar-ui/lib-react';
import './Chart.css';
import TimeScales from '../TimeScale.jsx';
import TimeScales from './TimeScale.jsx';
import { useRenderTime } from '../../helpers/debug.js';
function Chart(props) {
const {
@@ -27,15 +27,14 @@ function Chart(props) {
const api = useContext(storeContext);
const [selected, selectedCounter] = useStoreWithCounter(api, "_selected");
const rScrollLeft = useStore(api, "scrollLeft");
const rScrollTop = useStore(api, "scrollTop");
const cellHeight = useStore(api, "cellHeight");
const cellWidth = useStore(api, "cellWidth");
const scales = useStore(api, "_scales");
const markers = useStore(api, "_markers");
const rScrollTask = useStore(api, "_scrollTask");
const zoom = useStore(api, "zoom");
const [selected, selectedCounter] = useStoreWithCounter(api, '_selected');
const rScrollTop = useStore(api, 'scrollTop');
const cellHeight = useStore(api, 'cellHeight');
const cellWidth = useStore(api, 'cellWidth');
const scales = useStore(api, '_scales');
const markers = useStore(api, '_markers');
const rScrollTask = useStore(api, '_scrollTask');
const zoom = useStore(api, 'zoom');
const [chartHeight, setChartHeight] = useState();
const chartRef = useRef(null);
@@ -95,7 +94,7 @@ function Chart(props) {
useEffect(() => {
dataRequest();
}, [chartHeight, rScrollTop, rScrollLeft]);
}, [chartHeight, rScrollTop]);
const showTask = useCallback(
(value) => {
@@ -110,21 +109,20 @@ function Chart(props) {
const task = api.getTask(id);
if (task.$x + task.$w < el.scrollLeft) {
api.exec('scroll-chart', { left: task.$x - (cellWidth || 0) });
el.scrollLeft = task.$x - (cellWidth || 0)
el.scrollLeft = task.$x - (cellWidth || 0);
} else if (task.$x >= clientWidth + el.scrollLeft) {
const width = clientWidth < task.$w ? cellWidth || 0 : task.$w;
api.exec('scroll-chart', { left: task.$x - clientWidth + width });
el.scrollLeft = task.$x - clientWidth + width;
}
},
[api, cellWidth, rScrollLeft],
[api, cellWidth],
);
useEffect(() => {
showTask(rScrollTask);
}, [rScrollTask]);
function onWheel(e) {
if (zoom && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -156,10 +154,13 @@ function Chart(props) {
: null;
}, [scales, highlightTime]);
const handleHotkey = useCallback((ev) => {
ev.eventSource = 'chart';
api.exec('hotkey', ev);
}, [api]);
const handleHotkey = useCallback(
(ev) => {
ev.eventSource = 'chart';
api.exec('hotkey', ev);
},
[api],
);
useEffect(() => {
const el = chartRef.current;
@@ -201,8 +202,10 @@ function Chart(props) {
return () => {
node.removeEventListener('wheel', handler);
};
}, [onWheel])
}, [onWheel]);
useRenderTime("chart");
return (
<div
className="wx-mR7v2Xag wx-chart"
@@ -210,7 +213,7 @@ function Chart(props) {
ref={chartRef}
onScroll={onScroll}
>
<TimeScales highlightTime={highlightTime} scales={scales} />
<TimeScales highlightTime={highlightTime} scales={scales} />
{markers && markers.length ? (
<div
className="wx-mR7v2Xag wx-markers"
@@ -219,7 +222,7 @@ function Chart(props) {
{markers.map((marker, i) => (
<div
key={i}
className={`wx-mR7v2Xag wx-marker ${marker.css || 'wx-default'}`}
className={`wx-mR7v2Xag wx-marker ${marker.css || ''}`}
style={{ left: `${marker.left}px` }}
>
<div className="wx-mR7v2Xag wx-content">{marker.text}</div>
@@ -256,18 +259,17 @@ function Chart(props) {
{selected && selected.length
? selected.map((obj, index) =>
obj.$y ? (
<div
key={obj.id}
className="wx-mR7v2Xag wx-selected"
data-id={obj.id}
style={selectStyle[index]}
></div>
) : null,
)
obj.$y ? (
<div
key={obj.id}
className="wx-mR7v2Xag wx-selected"
data-id={obj.id}
style={selectStyle[index]}
></div>
) : null,
)
: null}
<Links />
<Bars readonly={readonly} taskTemplate={taskTemplate} />
</div>
</div>

View File

@@ -10,9 +10,28 @@
user-select: auto;
pointer-events: stroke;
position: relative;
cursor: pointer;
stroke: var(--wx-gantt-link-color);
stroke-width: 2;
z-index: 0;
fill: transparent;
}
.wx-line-selectable.wx-dkx3NwEn:hover {
stroke: var(--wx-gantt-link-color-hovered);
}
.wx-line-selectable.wx-critical.wx-dkx3NwEn:hover {
stroke: var(--wx-gantt-link-critical-color-hovered);
}
.wx-line-selectable.wx-dkx3NwEn {
cursor: pointer;
}
.wx-line.wx-line-selected.wx-dkx3NwEn {
stroke: var(--wx-color-danger);
}
.wx-critical.wx-dkx3NwEn {
stroke: var(--wx-gantt-link-critical-color);
}

View File

@@ -1,21 +1,66 @@
import { useContext } from 'react';
import { useCallback, useContext, useEffect, useRef } from 'react';
import storeContext from '../../context';
import { useStore } from '@svar-ui/lib-react';
import './Links.css';
export default function Links() {
export default function Links({ onSelectLink, selectedLink, readonly }) {
const api = useContext(storeContext);
const links = useStore(api,"_links");
const links = useStore(api, '_links');
const criticalPath = useStore(api, 'criticalPath');
const selectedLineRef = useRef(null);
const onClickOutside = useCallback(
(event) => {
const css = event?.target?.classList;
if (!css?.contains('wx-line') && !css?.contains('wx-delete-button')) {
onSelectLink(null);
}
},
[onSelectLink],
);
useEffect(() => {
if (!readonly && selectedLink && selectedLineRef.current) {
const handler = (event) => {
if (
selectedLineRef.current &&
!selectedLineRef.current.contains(event.target)
) {
onClickOutside(event);
}
};
document.addEventListener('click', handler);
return () => {
document.removeEventListener('click', handler);
};
}
}, [readonly, selectedLink, onClickOutside]);
return (
<svg className="wx-dkx3NwEn wx-links">
{(links || []).map((link) => (
{(links || []).map((link) => {
const className =
'wx-dkx3NwEn wx-line' +
(criticalPath && link.$critical ? ' wx-critical' : '') +
(!readonly ? ' wx-line-selectable' : '');
return (
<polyline
className={className}
points={link.$p}
key={link.id}
onClick={() => !readonly && onSelectLink(link.id)}
data-link-id={link.id}
/>
);
})}
{!readonly && selectedLink && (
<polyline
className="wx-dkx3NwEn wx-line"
points={link.$p}
key={link.id}
ref={selectedLineRef}
className="wx-dkx3NwEn wx-line wx-line-selected wx-line-selectable wx-delete-link"
points={selectedLink.$p}
/>
))}
)}
</svg>
);
}

View File

@@ -0,0 +1,30 @@
.wx-scale.wx-ZkvhDKir {
position: sticky;
top: 0;
background-color: var(--wx-background);
box-shadow: var(--wx-timescale-shadow);
z-index: 5;
}
.wx-row.wx-ZkvhDKir,
.wx-cell.wx-ZkvhDKir {
display: flex;
box-sizing: border-box;
}
.wx-row.wx-ZkvhDKir {
border-bottom: var(--wx-gantt-border);
}
.wx-cell.wx-ZkvhDKir {
justify-content: center;
align-items: center;
border-right: var(--wx-timescale-border);
font: var(--wx-timescale-font);
color: var(--wx-timescale-font-color);
}
.wx-cell.wx-weekend.wx-ZkvhDKir {
background: var(--wx-gantt-holiday-background);
color: var(--wx-gantt-holiday-color);
}

View File

@@ -0,0 +1,42 @@
import { useContext } from 'react';
import storeContext from '../../context';
import { useStore } from '@svar-ui/lib-react';
import './TimeScale.css';
function TimeScale(props) {
const { highlightTime } = props;
const api = useContext(storeContext);
const scales = useStore(api, '_scales');
return (
<div className="wx-ZkvhDKir wx-scale" style={{ width: scales.width }}>
{(scales?.rows || []).map((row, rowIdx) => (
<div
className="wx-ZkvhDKir wx-row"
style={{ height: `${row.height}px` }}
key={rowIdx}
>
{(row.cells || []).map((cell, cellIdx) => {
const extraClass = highlightTime
? highlightTime(cell.date, cell.unit)
: '';
const className =
'wx-cell ' + (cell.css || '') + ' ' + (extraClass || '');
return (
<div
className={'wx-ZkvhDKir ' + className}
style={{ width: `${cell.width}px` }}
key={cellIdx}
>
{cell.value}
</div>
);
})}
</div>
))}
</div>
);
}
export default TimeScale;

View File

@@ -18,6 +18,10 @@
text-overflow: ellipsis;
}
.wx-link-lag.wx-j93aYGQf {
width: 60px;
}
.wx-wrapper.wx-j93aYGQf {
position: relative;
display: flex;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useContext } from 'react';
import { Field, Combo } from '@svar-ui/react-core';
import { Field, Combo, Text } from '@svar-ui/react-core';
import { context } from '@svar-ui/react-core';
import { useStore } from '@svar-ui/lib-react';
import './Links.css';
@@ -8,8 +8,11 @@ export default function Links({ api, autoSave, onLinksChange }) {
const i18n = useContext(context.i18n);
const _ = i18n.getGroup('gantt');
const activeTask = useStore(api,"activeTask");
const links = useStore(api,"_links");
const activeTask = useStore(api, 'activeTask');
const _activeTask = useStore(api, '_activeTask');
const links = useStore(api, '_links');
const schedule = useStore(api, 'schedule');
const unscheduledTasks = useStore(api, 'unscheduledTasks');
const [linksData, setLinksData] = useState();
@@ -63,12 +66,11 @@ export default function Links({ api, autoSave, onLinksChange }) {
}
}
function handleChange(ev, id) {
const value = ev.value;
function handleChange(id, update) {
if (autoSave) {
api.exec('update-link', {
id,
link: { type: value },
link: update,
});
} else {
setLinksData((prev) =>
@@ -76,7 +78,7 @@ export default function Links({ api, autoSave, onLinksChange }) {
...group,
data: group.data.map((item) =>
item.link.id === id
? { ...item, link: { ...item.link, type: value } }
? { ...item, link: { ...item.link, ...update } }
: item,
),
})),
@@ -87,7 +89,7 @@ export default function Links({ api, autoSave, onLinksChange }) {
action: 'update-link',
data: {
id,
link: { type: value },
link: update,
},
});
}
@@ -108,14 +110,31 @@ export default function Links({ api, autoSave, onLinksChange }) {
{obj.task.text || ''}
</div>
</td>
{schedule?.auto && obj.link.type === 'e2s' ? (
<td className="wx-j93aYGQf wx-cell wx-link-lag">
<Text
type="number"
placeholder={_('Lag')}
value={obj.link.lag}
disabled={
unscheduledTasks && _activeTask?.unscheduled
}
onChange={(ev) => {
if (!ev.input)
handleChange(obj.link.id, { lag: ev.value });
}}
/>
</td>
) : null}
<td className="wx-j93aYGQf wx-cell">
<div className="wx-j93aYGQf wx-wrapper">
<Combo
value={obj.link.type}
placeholder={_('Select link type')}
options={list}
onChange={(e) => handleChange(e, obj.link.id)}
onChange={(ev) =>
handleChange(obj.link.id, { type: ev.value })
}
>
{({ option }) => option.label}
</Combo>

View File

@@ -9,7 +9,7 @@ import {
import { context } from '@svar-ui/react-core';
import { locateID, locateAttr } from '@svar-ui/lib-dom';
import { reorder } from '../../helpers/reorder';
import { normalizeDates } from '@svar-ui/gantt-store';
import { prepareEditTask } from '@svar-ui/gantt-store';
import { Grid as WxGrid } from '@svar-ui/react-grid';
import TextCell from './TextCell.jsx';
import ActionCell from './ActionCell.jsx';
@@ -18,7 +18,14 @@ import storeContext from '../../context';
import './Grid.css';
export default function Grid(props) {
const { readonly, compactMode, width = 0, display = 'all', columnWidth: columnWidthProp = 0, onTableAPIChange } = props;
const {
readonly,
compactMode,
width = 0,
display = 'all',
columnWidth: columnWidthProp = 0,
onTableAPIChange,
} = props;
const [columnWidth, setColumnWidthProp] = useWritableProp(columnWidthProp);
const [tableAPI, setTableAPI] = useState();
@@ -26,19 +33,19 @@ export default function Grid(props) {
const _ = useMemo(() => i18n.getGroup('gantt'), [i18n]);
const api = useContext(storeContext);
const scrollTopVal = useStore(api, "scrollTop");
const cellHeightVal = useStore(api, "cellHeight");
const scrollTask = useStore(api, "_scrollTask");
const selectedVal = useStore(api, "_selected");
const areaVal = useStore(api, "area");
const rTasksVal = useStore(api, "_tasks");
const scalesVal = useStore(api, "_scales");
const columnsVal = useStore(api, "columns");
const sortVal = useStore(api, "_sort");
const durationUnitVal = useStore(api, "durationUnit");
const scrollTopVal = useStore(api, 'scrollTop');
const cellHeightVal = useStore(api, 'cellHeight');
const scrollTask = useStore(api, '_scrollTask');
const selectedVal = useStore(api, '_selected');
const areaVal = useStore(api, 'area');
const rTasksVal = useStore(api, '_tasks');
const scalesVal = useStore(api, '_scales');
const columnsVal = useStore(api, 'columns');
const sortVal = useStore(api, '_sort');
const calendarVal = useStore(api, 'calendar');
const durationUnitVal = useStore(api, 'durationUnit');
const splitTasksVal = useStore(api, 'splitTasks');
const touchYRef = useRef(null);
const scrollRef = useRef(true);
const [dragTask, setDragTask] = useState(null);
const tasks = useMemo(() => {
@@ -61,7 +68,7 @@ export default function Grid(props) {
api.exec(action, { id, mode: !task.open });
}
},
[tasks]
[tasks],
);
const onClick = useCallback(
@@ -102,8 +109,6 @@ export default function Grid(props) {
return () => ro.disconnect();
}, []);
const lastDetailRef = useRef(null);
const reorderTasks = useCallback(
@@ -222,7 +227,6 @@ export default function Grid(props) {
return {};
}, [allTasks, sortVal]);
const checkFlex = useCallback(() => {
return cols.some((c) => c.flexgrow && !c.hidden);
}, []); // cols defined later; will use latest value when invoked
@@ -271,7 +275,6 @@ export default function Grid(props) {
return filteredColumns;
}, [display, cols, hasFlexCol, columnWidth, width, gridWidth]);
const setColumnWidth = useCallback(
(resized) => {
if (!checkFlex()) {
@@ -307,41 +310,11 @@ export default function Grid(props) {
[api, readonly], // cols is defined later; relies on latest value at call time
);
const endScroll = useCallback(() => {
scrollRef.current = false;
}, []);
const onTouchstart = useCallback(
(e) => {
scrollRef.current = true;
touchYRef.current = e.touches[0].clientY + (scrollTopVal ?? 0);
},
[scrollTopVal],
);
const onTouchmove = useCallback(
(e) => {
if (scrollRef.current) {
const delta = (touchYRef.current ?? 0) - e.touches[0].clientY;
api.exec('scroll-chart', { top: delta });
e.preventDefault();
return false;
}
},
[api],
);
const sel = useMemo(
() => (Array.isArray(selectedVal) ? selectedVal.map((o) => o.id) : []),
[selectedVal],
);
useEffect(() => {
const handler = () => endScroll();
window.addEventListener('touchend', handler);
return () => window.removeEventListener('touchend', handler);
}, [endScroll]);
const setScrollOffset = useCallback(() => {
if (tableRef.current && allTasks !== null) {
const body = tableRef.current.querySelector('.wx-body');
@@ -373,7 +346,7 @@ export default function Grid(props) {
useEffect(() => {
if (!scrollTask || !tableAPI) return;
const { id } = scrollTask;
const focusCell = tableAPI.getState().focusCell;
if (
@@ -387,7 +360,6 @@ export default function Grid(props) {
column: focusCell.column,
});
}
}, [scrollTask, tableAPI]);
const startReorder = useCallback(
@@ -438,13 +410,12 @@ export default function Grid(props) {
if (!node) return;
const action = reorder(node, {
start: startReorder,
touchStart: endScroll,
end: endReorder,
move: moveReorder,
getTask: api.getTask,
});
return action.destroy;
}, [api, startReorder, endScroll, endReorder, moveReorder]);
}, [api, startReorder, endReorder, moveReorder]);
const handleHotkey = useCallback(
(ev) => {
@@ -479,69 +450,88 @@ export default function Grid(props) {
adjustColumns,
setColumnWidth,
tasks,
calendarVal,
durationUnitVal,
onTableAPIChange
splitTasksVal,
onTableAPIChange,
};
};
setHandlersState();
useEffect(() => {
setHandlersState();
}, [setTableAPI, handleHotkey, sortVal, api, adjustColumns, setColumnWidth, tasks, durationUnitVal, onTableAPIChange]);
}, [
setTableAPI,
handleHotkey,
sortVal,
api,
adjustColumns,
setColumnWidth,
tasks,
calendarVal,
durationUnitVal,
splitTasksVal,
onTableAPIChange,
]);
const init = useCallback(
(tapi) => {
setTableAPI(tapi);
tapi.intercept('hotkey', (ev) => handlersStateRef.current.handleHotkey(ev));
tapi.intercept('scroll', () => false);
tapi.intercept('select-row', () => false);
tapi.intercept('sort-rows', (e) => {
const sortVal = handlersStateRef.current.sortVal;
const { key, add } = e;
const keySort = sortVal ? sortVal.find((s) => s.key === key) : null;
let order = 'asc';
if (keySort)
order = !keySort || keySort.order === 'asc' ? 'desc' : 'asc';
const init = useCallback((tapi) => {
setTableAPI(tapi);
tapi.intercept('hotkey', (ev) => handlersStateRef.current.handleHotkey(ev));
tapi.intercept('scroll', () => false);
tapi.intercept('select-row', () => false);
tapi.intercept('sort-rows', (e) => {
const sortVal = handlersStateRef.current.sortVal;
const { key, add } = e;
const keySort = sortVal ? sortVal.find((s) => s.key === key) : null;
let order = 'asc';
if (keySort) order = !keySort || keySort.order === 'asc' ? 'desc' : 'asc';
api.exec('sort-tasks', {
key,
order,
add,
api.exec('sort-tasks', {
key,
order,
add,
});
return false;
});
tapi.on('resize-column', () => {
handlersStateRef.current.setColumnWidth(true);
});
tapi.on('hide-column', (ev) => {
if (!ev.mode) handlersStateRef.current.adjustColumns();
handlersStateRef.current.setColumnWidth();
});
tapi.intercept('update-cell', (e) => {
const { id, column, value } = e;
const task = handlersStateRef.current.tasks.find((t) => t.id === id);
if (task) {
const update = { ...task };
let v = value;
if (v && !isNaN(v) && !(v instanceof Date)) v *= 1;
update[column] = v;
prepareEditTask(
update,
{
calendar: handlersStateRef.current.calendarVal,
durationUnit: handlersStateRef.current.durationUnitVal,
splitTasks: handlersStateRef.current.splitTasksVal,
},
column,
);
api.exec('update-task', {
id: id,
task: update,
});
return false;
});
}
return false;
});
tapi.on('resize-column', () => {
handlersStateRef.current.setColumnWidth(true);
});
tapi.on('hide-column', (ev) => {
if (!ev.mode) handlersStateRef.current.adjustColumns();
handlersStateRef.current.setColumnWidth();
});
tapi.intercept('update-cell', (e) => {
const { id, column, value } = e;
const task = handlersStateRef.current.tasks.find((t) => t.id === id);
if (task) {
const update = { ...task };
let v = value;
if (v && !isNaN(v) && !(v instanceof Date)) v *= 1;
update[column] = v;
normalizeDates(update, handlersStateRef.current.durationUnitVal, true, column);
api.exec('update-task', {
id: id,
task: update,
});
}
return false;
});
onTableAPIChange && onTableAPIChange(tapi);
}, [],
);
onTableAPIChange && onTableAPIChange(tapi);
}, []);
return (
<div
@@ -553,8 +543,6 @@ export default function Grid(props) {
ref={tableRef}
style={tableStyle}
className="wx-rHj6070p wx-table"
onTouchStart={onTouchstart}
onTouchMove={onTouchmove}
onClick={onClick}
onDoubleClick={onDblClick}
>

40
src/helpers/debug.js Normal file
View File

@@ -0,0 +1,40 @@
import { useRef, useEffect } from 'react';
const renderMetrics = new Map();
export function useRenderTime(label) {
const startTime = useRef(null);
const renderCount = useRef(0);
const rafRef = useRef(null);
const enabled =
typeof window !== 'undefined' && window.__RENDER_METRICS_ENABLED__;
if (startTime.current === null) {
startTime.current = performance.now();
}
renderCount.current++;
useEffect(() => {
if (!enabled) return;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const metric = {
label,
time: performance.now() - startTime.current,
renders: renderCount.current,
timestamp: Date.now(),
};
renderMetrics.set(label, metric);
window.dispatchEvent(
new CustomEvent('render-metric', { detail: metric }),
);
});
return () => cancelAnimationFrame(rafRef.current);
});
}

View File

@@ -0,0 +1,73 @@
import { dateToString } from '@svar-ui/lib-dom';
export function getUnitFormats(_) {
return {
year: '%Y',
quarter: `${_('Q')} %Q`,
month: '%M',
week: `${_('Week')} %w`,
day: '%M %j',
hour: '%H:%i',
};
}
function normalizeFormatter(value, calendarLocale) {
return typeof value === 'function'
? value
: dateToString(value, calendarLocale);
}
export function prepareScales(scales, calendarLocale) {
return scales.map(({ format, ...scale }) => ({
...scale,
format: normalizeFormatter(format, calendarLocale),
}));
}
export function prepareFormats(calendarLocale, _) {
const formats = getUnitFormats(_);
for (let unit in formats) {
formats[unit] = dateToString(formats[unit], calendarLocale);
}
return formats;
}
export function prepareColumns(columns, calendarLocale) {
if (!columns || !columns.length) return columns;
const format = dateToString('%d-%m-%Y', calendarLocale);
return columns.map((col) => {
if (col.template) return col;
if (col.id === 'start' || col.id == 'end') {
return {
...col,
//store locale template for unscheduled tasks
_template: (b) => format(b),
template: (b) => format(b),
};
}
if (col.id === 'duration') {
return {
...col,
_template: (b) => b,
template: (b) => b,
};
}
return col;
});
}
export function prepareZoom(zoom, calendarLocale) {
if (!zoom.levels) {
return zoom;
}
return {
...zoom,
levels: zoom.levels.map((level) => ({
...level,
scales: prepareScales(level.scales, calendarLocale),
})),
};
}

View File

@@ -1,5 +1,4 @@
import Gantt from './components/Gantt.jsx';
import Fullscreen from './components/Fullscreen.jsx';
import Toolbar from './components/Toolbar.jsx';
import ContextMenu from './components/ContextMenu.jsx';
import Editor from './components/Editor.jsx';
@@ -17,6 +16,9 @@ export {
defaultMenuOptions,
defaultColumns,
defaultTaskTypes,
getEditorItems,
getToolbarButtons,
getMenuOptions,
registerScaleUnit,
} from '@svar-ui/gantt-store';
@@ -24,7 +26,6 @@ export { registerEditorItem } from '@svar-ui/react-editor';
export {
Gantt,
Fullscreen,
ContextMenu,
HeaderMenu,
Toolbar,

View File

@@ -9,23 +9,31 @@
var(--wx-font-family);
--wx-gantt-bar-border-radius: 50px;
--wx-gantt-milestone-border-radius: 3px;
--wx-gantt-critical-color: #de3a3a;
--wx-gantt-task-color: #448aff;
--wx-gantt-task-font-color: #fff;
--wx-gantt-task-fill-color: #246cd9;
--wx-gantt-task-border-color: #448aff;
--wx-gantt-task-border: 1px solid #246cd9;
--wx-gantt-task-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-task-critical-fill-color: #c83434;
--wx-gantt-summary-color: #1de9b6;
--wx-gantt-summary-font-color: #5f5f5f;
--wx-gantt-summary-fill-color: #00d19a;
--wx-gantt-summary-border-color: #1de9b6;
--wx-gantt-summary-border: 1px solid #00d19a;
--wx-gantt-summary-critical-color: #d9306f;
--wx-gantt-summary-critical-fill-color: #c32b64;
--wx-gantt-milestone-color: #d33daf;
--wx-gantt-select-color: rgb(201, 244, 240);
--wx-gantt-link-color: #87a4bc;
--wx-gantt-link-color-hovered: #6e777d;
--wx-gantt-link-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-link-critical-color-hovered: #b22e2e;
--wx-gantt-link-marker-background: #f0f0f0;
--wx-gantt-link-marker-color: #87a4bc;
@@ -52,7 +60,6 @@
/* timescale */
--wx-timescale-font: 500 12px Roboto;
--wx-timescale-font-color: #a6a6a6;
--wx-timescale-text-transform: uppercase;
--wx-timescale-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
0px 1px 2px rgba(0, 0, 0, 0.1);
--wx-timescale-border: 1px solid transparent;
@@ -87,23 +94,31 @@
var(--wx-font-family);
--wx-gantt-bar-border-radius: 50px;
--wx-gantt-milestone-border-radius: 3px;
--wx-gantt-critical-color: #de3a3a;
--wx-gantt-task-color: #448aff;
--wx-gantt-task-font-color: #fff;
--wx-gantt-task-fill-color: #246cd9;
--wx-gantt-task-border-color: #448aff;
--wx-gantt-task-border: 1px solid #246cd9;
--wx-gantt-task-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-task-critical-fill-color: #c83434;
--wx-gantt-summary-color: #1de9b6;
--wx-gantt-summary-font-color: #5f5f5f;
--wx-gantt-summary-fill-color: #00d19a;
--wx-gantt-summary-border-color: #1de9b6;
--wx-gantt-summary-border: 1px solid #00d19a;
--wx-gantt-summary-critical-color: #d9306f;
--wx-gantt-summary-critical-fill-color: #c32b64;
--wx-gantt-milestone-color: #d33daf;
--wx-gantt-select-color: rgb(201, 244, 240);
--wx-gantt-link-color: #87a4bc;
--wx-gantt-link-color-hovered: #6e777d;
--wx-gantt-link-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-link-critical-color-hovered: #b22e2e;
--wx-gantt-link-marker-background: #f0f0f0;
--wx-gantt-link-marker-color: #87a4bc;
@@ -130,7 +145,6 @@
/* timescale */
--wx-timescale-font: 500 12px Roboto;
--wx-timescale-font-color: #a6a6a6;
--wx-timescale-text-transform: uppercase;
--wx-timescale-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1),
0px 1px 2px rgba(0, 0, 0, 0.1);
--wx-timescale-border: 1px solid transparent;

View File

@@ -9,23 +9,31 @@
var(--wx-font-family);
--wx-gantt-bar-border-radius: 3px;
--wx-gantt-milestone-border-radius: 3px;
--wx-gantt-critical-color: #de3a3a;
--wx-gantt-task-color: #3983eb;
--wx-gantt-task-font-color: #fff;
--wx-gantt-task-fill-color: #1f6bd9;
--wx-gantt-task-border-color: #1f6bd9;
--wx-gantt-task-border: 1px solid transparent;
--wx-gantt-task-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-task-critical-fill-color: #c83434;
--wx-gantt-summary-color: #00ba94;
--wx-gantt-summary-font-color: #ffffff;
--wx-gantt-summary-fill-color: #099f81;
--wx-gantt-summary-border-color: #099f81;
--wx-gantt-summary-border: 1px solid transparent;
--wx-gantt-summary-critical-color: #d9306f;
--wx-gantt-summary-critical-fill-color: #c32b64;
--wx-gantt-milestone-color: #ad44ab;
--wx-gantt-select-color: #eaedf5;
--wx-gantt-link-color: #9fa1ae;
--wx-gantt-link-color-hovered: #6e777d;
--wx-gantt-link-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-link-critical-color-hovered: #b22e2e;
--wx-gantt-link-marker-background: #eaedf5;
--wx-gantt-link-marker-color: #9fa1ae;
@@ -54,7 +62,6 @@
--wx-timescale-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
var(--wx-font-family);
--wx-timescale-font-color: var(--wx-color-font);
--wx-timescale-text-transform: uppercase;
--wx-timescale-shadow: none;
--wx-timescale-border: var(--wx-gantt-border);

View File

@@ -11,18 +11,23 @@
var(--wx-font-family);
--wx-gantt-bar-border-radius: 3px;
--wx-gantt-milestone-border-radius: 3px;
--wx-gantt-critical-color: #de3a3a;
--wx-gantt-task-color: #37a9ef;
--wx-gantt-task-font-color: #ffffffe5;
--wx-gantt-task-fill-color: #098cdc;
--wx-gantt-task-border-color: #098cdc;
--wx-gantt-task-border: 1px solid transparent;
--wx-gantt-task-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-task-critical-fill-color: #c83434;
--wx-gantt-summary-color: #00ba94;
--wx-gantt-summary-font-color: #ffffffe5;
--wx-gantt-summary-fill-color: #099f81;
--wx-gantt-summary-border-color: #099f81;
--wx-gantt-summary-border: 1px solid transparent;
--wx-gantt-summary-critical-color: #d9306f;
--wx-gantt-summary-critical-fill-color: #c32b64;
--wx-gantt-progress-marker-height: 26px;
--wx-gantt-progress-border-color: #4b5359;
@@ -37,6 +42,9 @@
--wx-gantt-select-color: #384047;
--wx-gantt-link-color: #9fa1ae;
--wx-gantt-link-color-hovered: #c8c7cf;
--wx-gantt-link-critical-color: var(--wx-gantt-critical-color);
--wx-gantt-link-critical-color-hovered: #b22e2e;
--wx-gantt-link-marker-background: #384047;
--wx-gantt-link-marker-color: #9fa1ae;
@@ -57,7 +65,6 @@
--wx-timescale-font: var(--wx-font-weight-md) var(--wx-font-size-sm)
var(--wx-font-family);
--wx-timescale-font-color: var(--wx-color-font);
--wx-timescale-text-transform: uppercase;
--wx-timescale-shadow: none;
--wx-timescale-border: var(--wx-border);