Skip to content

Commit

Permalink
Merge pull request #2355 from epam/feature/timeline
Browse files Browse the repository at this point in the history
[Timeline]: ProjectDemo with Timeline.
  • Loading branch information
Kuznietsov authored Aug 5, 2024
2 parents b43f7e2 + 1d6fba9 commit 3679e8a
Show file tree
Hide file tree
Showing 59 changed files with 3,748 additions and 640 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: qa
# Controls when the action will run.
on:
push:
branches: [ rtl-support ]
branches: [ feature/timeline ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

Expand Down
24 changes: 24 additions & 0 deletions app/src/demo/tables/editableTable/ProjectTableDemo.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,27 @@
width: 10px;
height: 10px;
}

.statusIconComplete {
fill: var(--uui-accent-50);
}

.statusIconInProgress {
fill: var(--uui-info-50);
}

.statusIconPlanned {
fill: var(--uui-info-20);
}

.statusIconAtRisk {
fill: var(--uui-critical-50);
}

.statusIconBlocked {
fill: var(--uui-warning-50);
}

.statusIconNone {
fill: var(--uui-neutral-30);
}
231 changes: 186 additions & 45 deletions app/src/demo/tables/editableTable/ProjectTableDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { DataTable, Panel, Button, FlexCell, FlexRow, FlexSpacer, IconButton, useForm, SearchInput, Tooltip } from '@epam/uui';
import { AcceptDropParams, DataTableState, DropParams, DropPosition, IImmutableMap, ItemsMap, Metadata, NOT_FOUND_RECORD, Tree, useDataRows, useTree } from '@epam/uui-core';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TimelineController, msPerDay } from '@epam/uui-timeline';
import { Panel, Button, FlexCell, FlexRow, FlexSpacer, IconButton, useForm, SearchInput, Tooltip, MultiSwitch } from '@epam/uui';
import { AcceptDropParams, DataTableState, DropParams, DropPosition, IImmutableMap, ItemsMap, Metadata,
NOT_FOUND_RECORD, Tree, useDataRows, usePrevious, useTree } from '@epam/uui-core';
import { useDataTableFocusManager } from '@epam/uui-components';

import { ReactComponent as undoIcon } from '@epam/assets/icons/content-edit_undo-outline.svg';
Expand All @@ -9,18 +11,28 @@ import { ReactComponent as insertAfter } from '@epam/assets/icons/table-row_plus
import { ReactComponent as insertBefore } from '@epam/assets/icons/table-row_plus_before-outline.svg';
import { ReactComponent as deleteLast } from '@epam/assets/icons/table-row_remove-outline.svg';
import { ReactComponent as add } from '@epam/assets/icons/action-add-outline.svg';

import { ReactComponent as zoomIn } from '@epam/assets/icons/action-add-outline.svg';
import { ReactComponent as zoomOut } from '@epam/assets/icons/content-minus-outline.svg';
import { ReactComponent as fitContent } from '@epam/assets/icons/action-align_center-outline.svg';
import { Task } from './types';
import { getDemoTasks } from './demoData';
import { getColumns } from './columns';
import { deleteTaskWithChildren, setTaskInsertPosition } from './helpers';
import { deleteTaskWithChildren, getMinMaxDate, scheduleTasks, setTaskInsertPosition } from './helpers';
import { ReactComponent as TableViewOutlineIcon } from '@epam/assets/icons/content-view_table-outline.svg';
import { ReactComponent as TimelineViewOutlineIcon } from '@epam/assets/icons/editor-chart_gantt-outline.svg';

import css from './ProjectTableDemo.module.scss';
import { TimelineMode } from './TimelineMode';
import { TableMode } from './TableMode';

interface FormState {
items: IImmutableMap<number, Task>;
}

interface ViewMode {
id: 'timeline' | 'table';
icon: React.FC<React.SVGProps<SVGSVGElement>>;
}

const metadata: Metadata<FormState> = {
props: {
items: {
Expand All @@ -39,6 +51,12 @@ let savedValue: FormState = { items: ItemsMap.blank<number, Task>({ getId: (item

const items = Object.values(getDemoTasks());
export function ProjectTableDemo() {
const viewModes: ViewMode[] = [
{ id: 'table', icon: TableViewOutlineIcon },
{ id: 'timeline', icon: TimelineViewOutlineIcon },
];
const [selectedViewMode, setSelectedViewMode] = useState<ViewMode['id']>('timeline');

const {
value, save, isChanged, revert, undo, canUndo, redo, canRedo, setValue, lens,
} = useForm<FormState>({
Expand All @@ -50,7 +68,7 @@ export function ProjectTableDemo() {
getMetadata: () => metadata,
});

const [tableState, setTableState] = useState<DataTableState>({ sorting: [{ field: 'order' }], visibleCount: 1000 });
const [tableState, setTableState] = useState<DataTableState>({ sorting: [{ field: 'order' }], visibleCount: 30 });
const dataTableFocusManager = useDataTableFocusManager<Task['id']>({}, []);

const searchHandler = useCallback(
Expand All @@ -61,12 +79,11 @@ export function ProjectTableDemo() {
[],
);

const { tree, ...restProps } = useTree({
const { tree, applyPatch, ...restProps } = useTree<Task, number>({
type: 'sync',
dataSourceState: tableState,
setDataSourceState: setTableState,
items,

patch: value.items,
getSearchFields: (item) => [item.name],
getId: (i) => i.id,
Expand All @@ -75,30 +92,80 @@ export function ProjectTableDemo() {
isDeleted: ({ isDeleted }) => isDeleted,
}, []);

const treeRef = useRef(tree);
const applyPatchRef = useRef(applyPatch);

treeRef.current = tree;
applyPatchRef.current = applyPatch;
const deleteTask = useCallback((task: Task) => {
setValue((currentValue) => ({
...currentValue,
items: deleteTaskWithChildren(task, currentValue.items, tree),
items: scheduleTasks(
applyPatchRef.current,
deleteTaskWithChildren(task, currentValue.items, treeRef.current),
),
}));
}, [setValue, tree]);
}, [setValue]);

const timelineController = useMemo(
() => new TimelineController({ widthPx: 0, center: new Date(), pxPerMs: 32 / msPerDay }),
[],
);
const prevWidthPx = usePrevious(timelineController.currentViewport?.widthPx);

useEffect(() => {
if (!prevWidthPx && timelineController.currentViewport.widthPx) {
const { from, to } = getMinMaxDate(treeRef.current);
if (from && to) {
timelineController.setViewportRange({ from, to, widthPx: timelineController.currentViewport.widthPx }, false);
}
}
}, [timelineController, timelineController.currentViewport.widthPx, prevWidthPx]);

const handleCanAcceptDrop = useCallback((params: AcceptDropParams<Task & { isTask: boolean }, Task>) => {
if (!params.srcData.isTask || params.srcData.id === params.dstData.id) {
return null;
}
const parents = Tree.getPathById(params.dstData.id, tree);
const parents = Tree.getPathById(params.dstData.id, treeRef.current);
if (parents.some((parent) => parent.id === params.srcData.id)) {
return null;
}

return { bottom: true, top: true, inside: true };
}, [tree]);
}, []);

const insertTask = useCallback((position: DropPosition, relativeTask: Task | null = null, existingTask: Task | null = null) => {
const taskToInsert = existingTask ? { ...existingTask } : { id: lastId--, name: '' };
const task: Task = setTaskInsertPosition(taskToInsert, relativeTask, position, tree);
const taskToInsert: Task = existingTask ? { ...existingTask, type: 'task' } : { id: lastId--, name: '', type: 'task' };
const task: Task = setTaskInsertPosition(taskToInsert, relativeTask, position, treeRef.current);

setValue((currentValue) => {
let parentTask = relativeTask;
if (position === 'inside' && relativeTask.type !== 'story') {
parentTask = { ...relativeTask, type: 'story' };
}

let prevParentTask = taskToInsert.parentId === null ? null : treeRef.current.getById(taskToInsert.parentId);
if (taskToInsert.parentId !== null && prevParentTask !== null && prevParentTask !== NOT_FOUND_RECORD && taskToInsert.parentId !== task.parentId) {
const children = treeRef.current.getItems(taskToInsert.parentId);
const isSomeNotMoved = children.ids.some((id) => id !== taskToInsert.id);
if (!isSomeNotMoved) {
prevParentTask = { ...prevParentTask, type: 'task' };
}
}

let currentItems = currentValue.items
.set(task.id, task)
.set(parentTask.id, parentTask);

if (prevParentTask !== null && prevParentTask !== NOT_FOUND_RECORD) {
currentItems = currentItems.set(prevParentTask.id, prevParentTask);
}

setValue((currentValue) => ({ ...currentValue, items: currentValue.items.set(task.id, task) }));
return {
...currentValue,
items: scheduleTasks(applyPatchRef.current, currentItems),
};
});

setTableState((currentTableState) => ({
...currentTableState,
Expand All @@ -109,33 +176,56 @@ export function ProjectTableDemo() {
}));

dataTableFocusManager?.focusRow(task.id);
}, [setValue, dataTableFocusManager, tree]);
}, [setValue, dataTableFocusManager]);

const handleDrop = useCallback(
(params: DropParams<Task, Task>) => insertTask(params.position, params.dstData, params.srcData),
(params: DropParams<Task, Task>) => {
return insertTask(params.position, params.dstData, params.srcData);
},
[insertTask],
);

const formLens = useMemo(() => {
return lens
.prop('items')
.onChange((prevValue, nextValue) => {
const shouldReschedule = (id: number) => {
const prevTask = prevValue.get(id);
const t = nextValue.get(id);
return !prevValue.has(id)
|| prevTask.estimate !== t.estimate
|| prevTask.startDate !== t.startDate
|| prevTask.dueDate !== t.dueDate
|| prevTask.assignee !== t.assignee
|| prevTask.status !== t.status;
};
for (const [id] of nextValue) {
if (shouldReschedule(id)) {
return scheduleTasks(applyPatchRef.current, nextValue);
}
}

return nextValue;
});
}, [lens]);

const { rows, listProps } = useDataRows({
tree,
...restProps,
getRowOptions: (task) => ({
...lens.prop('items').key(task.id).toProps(), // pass IEditable to each row to allow editing
isSelectable: true,
dnd: {
srcData: { ...task, isTask: true },
dstData: { ...task, isTask: true },
canAcceptDrop: handleCanAcceptDrop,
onDrop: handleDrop,
},
}),
getRowOptions: (task) => {
return {
...formLens.key(task.id).toProps(), // pass IEditable to each row to allow editing
isSelectable: true,
dnd: {
srcData: { ...task, isTask: true },
dstData: { ...task, isTask: true },
canAcceptDrop: handleCanAcceptDrop,
onDrop: handleDrop,
},
};
},
});

const columns = useMemo(
() => getColumns({ insertTask, deleteTask }),
[insertTask, deleteTask],
);

const selectedItem = useMemo(() => {
if (tableState.selectedId !== undefined) {
const item = tree.getById(tableState.selectedId);
Expand Down Expand Up @@ -229,7 +319,46 @@ export function ProjectTableDemo() {
<FlexCell cx={ css.search } width={ 295 }>
<SearchInput value={ tableState.search } onValueChange={ searchHandler } placeholder="Search" debounceDelay={ 1000 } />
</FlexCell>

<div className={ css.divider } />

{ selectedViewMode === 'timeline'
&& (
<FlexRow columnGap="6" background="surface-main">
<FlexCell width="auto">
<Button
icon={ fitContent }
iconPosition="left"
caption="Fit content"
fill="outline"
onClick={ () => {
const minMax = getMinMaxDate(tree);
if (minMax.from && minMax.to) {
timelineController.setViewportRange({
from: minMax.from,
to: minMax.to,
widthPx: timelineController.currentViewport.widthPx,
}, true);
}
} }
/>
</FlexCell>
<FlexCell width="auto">
<Button icon={ zoomOut } iconPosition="right" isDisabled={ !timelineController.canZoomBy(-1) } fill="outline" onClick={ () => timelineController.zoomBy(-1) } />
</FlexCell>
<FlexCell width="auto">
<Button icon={ zoomIn } iconPosition="right" isDisabled={ !timelineController.canZoomBy(1) } fill="outline" onClick={ () => timelineController.zoomBy(1) } />
</FlexCell>
</FlexRow>
)}
<FlexCell width={ 150 }>
<MultiSwitch
items={ viewModes }
value={ selectedViewMode }
onValueChange={ setSelectedViewMode }
/>
</FlexCell>

<FlexCell width="auto">
<IconButton size="18" icon={ undoIcon } onClick={ undo } isDisabled={ !canUndo } />
</FlexCell>
Expand All @@ -243,18 +372,30 @@ export function ProjectTableDemo() {
<Button size="30" color="accent" caption="Save" onClick={ save } isDisabled={ !isChanged } />
</FlexCell>
</FlexRow>
<DataTable
headerTextCase="upper"
rows={ rows }
columns={ columns }
value={ tableState }
onValueChange={ setTableState }
dataTableFocusManager={ dataTableFocusManager }
showColumnsConfig
allowColumnsResizing
allowColumnsReordering
{ ...listProps }
/>

{ selectedViewMode === 'timeline'
? (
<TimelineMode
tableState={ tableState }
setTableState={ setTableState }
listProps={ listProps }
rows={ rows }
timelineController={ timelineController }
dataTableFocusManager={ dataTableFocusManager }
insertTask={ insertTask }
deleteTask={ deleteTask }
/>
) : (
<TableMode
tableState={ tableState }
setTableState={ setTableState }
listProps={ listProps }
rows={ rows }
dataTableFocusManager={ dataTableFocusManager }
insertTask={ insertTask }
deleteTask={ deleteTask }
/>
)}
</Panel>
);
}
Loading

0 comments on commit 3679e8a

Please sign in to comment.