Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ const calendarConfig: CalendarConfig = {
- TaskListHeader: `React.FC<{ headerHeight: number; rowWidth: string; fontFamily: string; fontSize: string;}>;`
- TaskListTable: `React.FC<{ rowHeight: number; rowWidth: string; fontFamily: string; fontSize: string; locale: string; tasks: Task[]; selectedTaskId: string; setSelectedTask: (taskId: string) => void; }>;`

### TaskList 列の表示制御(visibleFields)

TaskList の表示列は `visibleFields` で指定します。`DEFAULT_VISIBLE_FIELDS` に progress 列は含まれないため、**進捗列は opt-in** です。

```typescript
import { DEFAULT_VISIBLE_FIELDS, VisibleField } from "@levelcaptech/gantt-task-react-custom";

const visibleFields: VisibleField[] = [
"name",
"start",
"end",
"progress",
...DEFAULT_VISIBLE_FIELDS.filter(field =>
!["name", "start", "end"].includes(field)
),
];

<Gantt
tasks={tasks}
visibleFields={visibleFields}
onCellCommit={onCellCommit}
/>;
```

progress セルの編集では、入力値は 5 の倍数に丸められ、`onCellCommit` には `"0"〜"100"` の文字列が通知されます。

### Task

| パラメーター名 | 型 | 説明 |
Expand Down
16 changes: 13 additions & 3 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ const JapaneseTooltip: React.FC<{

type TaskFieldValue = Task[VisibleField];

// 表示順と重複除外に使う基準の列(進捗列を含む定義は以下で構築する)
const BASE_VISIBLE_FIELDS: VisibleField[] = ["name", "start", "end"];

const VISIBLE_FIELDS_WITH_PROGRESS: VisibleField[] = [
...BASE_VISIBLE_FIELDS,
"progress",
...DEFAULT_VISIBLE_FIELDS.filter(field => !BASE_VISIBLE_FIELDS.includes(field)),
];

const resolveCellCommitValue = (
columnId: VisibleField,
value: string,
Expand All @@ -114,7 +123,8 @@ const resolveCellCommitValue = (
return Number.isNaN(parsedDate.getTime()) ? fallbackValue : parsedDate;
}
case "plannedEffort":
case "actualEffort": {
case "actualEffort":
case "progress": {
const parsedNumber = Number(value);
return Number.isNaN(parsedNumber) ? fallbackValue : parsedNumber;
}
Expand Down Expand Up @@ -265,7 +275,7 @@ const App = () => {
locale="ja-JP"
calendar={calendarConfig}
TooltipContent={JapaneseTooltip}
visibleFields={DEFAULT_VISIBLE_FIELDS}
visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
onTaskUpdate={handleTaskUpdate}
onCellCommit={handleCellCommit}
effortDisplayUnit={effortUnit}
Expand All @@ -287,7 +297,7 @@ const App = () => {
locale="ja-JP"
calendar={calendarConfig}
TooltipContent={JapaneseTooltip}
visibleFields={DEFAULT_VISIBLE_FIELDS}
visibleFields={VISIBLE_FIELDS_WITH_PROGRESS}
onTaskUpdate={handleTaskUpdate}
onCellCommit={handleCellCommit}
effortDisplayUnit={effortUnit}
Expand Down
9 changes: 9 additions & 0 deletions src/components/task-list/overlay-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const resolveOverlayInputType = (
return "date";
case "plannedEffort":
case "actualEffort":
case "progress":
return "number";
case "process":
case "status":
Expand Down Expand Up @@ -116,6 +117,13 @@ export const OverlayEditor: React.FC<OverlayEditorProps> = ({
}
return [];
}, [editingState.columnId]);
const numberInputAttributes = useMemo<React.InputHTMLAttributes<HTMLInputElement>>(
() =>
editingState.columnId === "progress"
? { min: 0, max: 100, step: 5 }
: {},
[editingState.columnId]
);

const portalRoot = useMemo(() => {
if (typeof document === "undefined") {
Expand Down Expand Up @@ -416,6 +424,7 @@ export const OverlayEditor: React.FC<OverlayEditorProps> = ({
defaultValue={defaultValueRef.current}
style={{ height: "100%" }}
ref={handleInputElementRef}
{...(inputType === "number" ? numberInputAttributes : {})}
readOnly={editingState.pending}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
Expand Down
1 change: 1 addition & 0 deletions src/components/task-list/task-list-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const TaskListHeaderDefault: React.FC<{
name: "タスク名",
start: "開始日",
end: "終了日",
progress: "進捗",
process: "工程",
assignee: "担当者",
plannedStart: "予定開始",
Expand Down
4 changes: 4 additions & 0 deletions src/components/task-list/task-list-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getDefaultWidth, TaskListEditingStateContext } from "./task-list";
import {
formatDate,
formatEffort,
formatProgress,
getStatusBadgeText,
getStatusColor,
normalizeProcess,
Expand Down Expand Up @@ -57,6 +58,7 @@ export const TaskListTableDefault: React.FC<{
"name",
"start",
"end",
"progress",
"process",
"assignee",
"plannedStart",
Expand Down Expand Up @@ -273,6 +275,8 @@ export const TaskListTableDefault: React.FC<{
return <span>{formatDate(t.start)}</span>;
case "end":
return <span>{formatDate(t.end)}</span>;
case "progress":
return <span>{formatProgress(t.progress)}</span>;
case "process":
return <span>{normalizeProcess(t.process)}</span>;
case "assignee":
Expand Down
29 changes: 28 additions & 1 deletion src/components/task-list/task-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import {
formatDate,
parseDateFromInput,
parseProgressInput,
sanitizeEffortInput,
} from "../../helpers/task-helper";
import { ParsedTime, parseTimeString } from "../../helpers/time-helper";
Expand Down Expand Up @@ -306,6 +307,16 @@ export const TaskList: React.FC<TaskListProps> = ({
const rowId = editingState.rowId;
const columnId = editingState.columnId;
const task = tasks.find(row => row.id === rowId);
const resolveProgressCommit = () => {
if (columnId !== "progress") {
return null;
}
const parsedValue = parseProgressInput(value);
if (parsedValue === null) {
return { invalid: true, normalizedValue: null };
}
return { invalid: false, normalizedValue: `${parsedValue}` };
};
const resolveActualsCommit = () => {
if (!task) {
return null;
Expand Down Expand Up @@ -364,6 +375,21 @@ export const TaskList: React.FC<TaskListProps> = ({
updatedFields: Object.keys(updatedFields).length > 0 ? updatedFields : null,
};
};
const progressCommit = resolveProgressCommit();
if (progressCommit?.invalid) {
setEditingState(prev => {
if (
prev.mode !== "editing" ||
prev.pending ||
prev.rowId !== rowId ||
prev.columnId !== columnId
) {
return prev;
}
return { ...prev, errorMessage: "0〜100 の数値を入力してください" };
});
return;
}
const actualsCommit = resolveActualsCommit();
setEditingState(prev => {
if (
Expand All @@ -377,7 +403,8 @@ export const TaskList: React.FC<TaskListProps> = ({
return { ...prev, pending: true, errorMessage: null };
});
try {
const commitValue = actualsCommit?.normalizedValue ?? value;
const commitValue =
progressCommit?.normalizedValue ?? actualsCommit?.normalizedValue ?? value;
await onCellCommit({ rowId, columnId, value: commitValue, trigger });
if (actualsCommit?.updatedFields && onUpdateTask) {
onUpdateTask(rowId, actualsCommit.updatedFields);
Expand Down
28 changes: 28 additions & 0 deletions src/helpers/task-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ export const sanitizeEffortInput = (value: string) => {
return parsed;
};

const clampProgress = (value: number) => Math.min(100, Math.max(0, value));

/** 表示用に 0〜100 の範囲へクランプする(5刻み丸めは行わない) */
export const normalizeProgress = (progress?: number) => {
if (progress === undefined || !Number.isFinite(progress)) {
return null;
}
return clampProgress(progress);
};

export const formatProgress = (progress?: number): string => {
const normalized = normalizeProgress(progress);
return normalized === null ? "" : `${normalized}`;
};

/** commit 用に 5 刻みへ丸めたうえで 0〜100 にクランプする */
export const parseProgressInput = (value: string) => {
if (value.trim() === "") {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
const rounded = Math.round(parsed / 5) * 5;
return clampProgress(rounded);
};

const DEFAULT_TASK_PROCESS: TaskProcess = (
TASK_PROCESS_OPTIONS.includes("その他")
? "その他"
Expand Down
42 changes: 42 additions & 0 deletions src/test/overlay-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe("OverlayEditor", () => {
["name", "INPUT", "text", "タスク名"],
["start", "INPUT", "date", "2026-01-01"],
["plannedEffort", "INPUT", "number", 8],
["progress", "INPUT", "number", 45],
["process", "SELECT", "", "レビュー"],
["status", "SELECT", "", "完了"],
])(
Expand Down Expand Up @@ -211,6 +212,47 @@ describe("OverlayEditor", () => {
}
);

it("sets progress input constraints", async () => {
const rectSpy = jest
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue(rect as DOMRect);
const rafSpy = jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(callback => {
callback(0);
return 1;
});
const { taskListRef, headerRef, bodyRef } = createRefs();

render(
<div ref={taskListRef}>
<div ref={headerRef} />
<div ref={bodyRef}>
<div data-row-id="task-1" data-column-id="progress">
45
</div>
</div>
<OverlayEditor
editingState={createEditingState("progress", false)}
taskListRef={taskListRef}
headerContainerRef={headerRef}
bodyContainerRef={bodyRef}
onCommit={jest.fn().mockResolvedValue(undefined)}
onCancel={jest.fn()}
/>
</div>
);

const overlayInput = await screen.findByTestId("overlay-editor-input");

expect(overlayInput).toHaveAttribute("min", "0");
expect(overlayInput).toHaveAttribute("max", "100");
expect(overlayInput).toHaveAttribute("step", "5");

rectSpy.mockRestore();
rafSpy.mockRestore();
});

it("focuses the input when editing starts", async () => {
const rectSpy = jest
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
Expand Down
24 changes: 24 additions & 0 deletions src/test/task-helper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
parseDateFromInput,
formatEffort,
sanitizeEffortInput,
formatProgress,
parseProgressInput,
normalizeProcess,
normalizeStatus,
getStatusBadgeText,
Expand Down Expand Up @@ -67,6 +69,28 @@ describe("task-helper sanitizeEffortInput", () => {
});
});

describe("task-helper progress helpers", () => {
it("formats progress with clamp only", () => {
expect(formatProgress(42)).toBe("42");
expect(formatProgress(101)).toBe("100");
expect(formatProgress(-3)).toBe("0");
});

it("returns empty string when progress is invalid", () => {
expect(formatProgress(Number.NaN)).toBe("");
});

it("parses progress input with rounding", () => {
expect(parseProgressInput("47")).toBe(45);
expect(parseProgressInput("100")).toBe(100);
});

it("rejects invalid progress input", () => {
expect(parseProgressInput("")).toBeNull();
expect(parseProgressInput("abc")).toBeNull();
});
});

describe("task-helper normalize helpers", () => {
it("normalizes process to defined options", () => {
expect(normalizeProcess("設計")).toBe("設計");
Expand Down
Loading
Loading