diff --git a/README.md b/README.md index 1b600fc49..2992b0b52 100644 --- a/README.md +++ b/README.md @@ -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) + ), +]; + +; +``` + +progress セルの編集では、入力値は 5 の倍数に丸められ、`onCellCommit` には `"0"〜"100"` の文字列が通知されます。 + ### Task | パラメーター名 | 型 | 説明 | diff --git a/example/src/App.tsx b/example/src/App.tsx index 2c929b468..46d41bcc8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -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, @@ -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; } @@ -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} @@ -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} diff --git a/src/components/task-list/overlay-editor.tsx b/src/components/task-list/overlay-editor.tsx index 6b93d6dab..994568996 100644 --- a/src/components/task-list/overlay-editor.tsx +++ b/src/components/task-list/overlay-editor.tsx @@ -47,6 +47,7 @@ const resolveOverlayInputType = ( return "date"; case "plannedEffort": case "actualEffort": + case "progress": return "number"; case "process": case "status": @@ -116,6 +117,13 @@ export const OverlayEditor: React.FC = ({ } return []; }, [editingState.columnId]); + const numberInputAttributes = useMemo>( + () => + editingState.columnId === "progress" + ? { min: 0, max: 100, step: 5 } + : {}, + [editingState.columnId] + ); const portalRoot = useMemo(() => { if (typeof document === "undefined") { @@ -416,6 +424,7 @@ export const OverlayEditor: React.FC = ({ defaultValue={defaultValueRef.current} style={{ height: "100%" }} ref={handleInputElementRef} + {...(inputType === "number" ? numberInputAttributes : {})} readOnly={editingState.pending} onKeyDown={handleKeyDown} onBlur={handleBlur} diff --git a/src/components/task-list/task-list-header.tsx b/src/components/task-list/task-list-header.tsx index bb8bc3440..6fe50b746 100644 --- a/src/components/task-list/task-list-header.tsx +++ b/src/components/task-list/task-list-header.tsx @@ -46,6 +46,7 @@ export const TaskListHeaderDefault: React.FC<{ name: "タスク名", start: "開始日", end: "終了日", + progress: "進捗", process: "工程", assignee: "担当者", plannedStart: "予定開始", diff --git a/src/components/task-list/task-list-table.tsx b/src/components/task-list/task-list-table.tsx index efcbdb0e8..fa547d3a3 100644 --- a/src/components/task-list/task-list-table.tsx +++ b/src/components/task-list/task-list-table.tsx @@ -5,6 +5,7 @@ import { getDefaultWidth, TaskListEditingStateContext } from "./task-list"; import { formatDate, formatEffort, + formatProgress, getStatusBadgeText, getStatusColor, normalizeProcess, @@ -57,6 +58,7 @@ export const TaskListTableDefault: React.FC<{ "name", "start", "end", + "progress", "process", "assignee", "plannedStart", @@ -273,6 +275,8 @@ export const TaskListTableDefault: React.FC<{ return {formatDate(t.start)}; case "end": return {formatDate(t.end)}; + case "progress": + return {formatProgress(t.progress)}; case "process": return {normalizeProcess(t.process)}; case "assignee": diff --git a/src/components/task-list/task-list.tsx b/src/components/task-list/task-list.tsx index f02c8bf32..67d3689fc 100644 --- a/src/components/task-list/task-list.tsx +++ b/src/components/task-list/task-list.tsx @@ -22,6 +22,7 @@ import { import { formatDate, parseDateFromInput, + parseProgressInput, sanitizeEffortInput, } from "../../helpers/task-helper"; import { ParsedTime, parseTimeString } from "../../helpers/time-helper"; @@ -306,6 +307,16 @@ export const TaskList: React.FC = ({ 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; @@ -364,6 +375,21 @@ export const TaskList: React.FC = ({ 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 ( @@ -377,7 +403,8 @@ export const TaskList: React.FC = ({ 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); diff --git a/src/helpers/task-helper.ts b/src/helpers/task-helper.ts index 83f6c7cb5..acb940960 100644 --- a/src/helpers/task-helper.ts +++ b/src/helpers/task-helper.ts @@ -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("その他") ? "その他" diff --git a/src/test/overlay-editor.test.tsx b/src/test/overlay-editor.test.tsx index 5d074a379..0f8e07593 100644 --- a/src/test/overlay-editor.test.tsx +++ b/src/test/overlay-editor.test.tsx @@ -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", "", "完了"], ])( @@ -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( +
+
+
+
+ 45 +
+
+ +
+ ); + + 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") diff --git a/src/test/task-helper.test.tsx b/src/test/task-helper.test.tsx index 2f93f3345..d89b47fc7 100644 --- a/src/test/task-helper.test.tsx +++ b/src/test/task-helper.test.tsx @@ -3,6 +3,8 @@ import { parseDateFromInput, formatEffort, sanitizeEffortInput, + formatProgress, + parseProgressInput, normalizeProcess, normalizeStatus, getStatusBadgeText, @@ -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("設計"); diff --git a/src/test/task-list-commit.test.tsx b/src/test/task-list-commit.test.tsx index 486af7ed3..50f9a22c9 100644 --- a/src/test/task-list-commit.test.tsx +++ b/src/test/task-list-commit.test.tsx @@ -28,6 +28,12 @@ const MockTaskListTable: React.FC = () => { > Start Effort +
Task 1
@@ -37,6 +43,9 @@ const MockTaskListTable: React.FC = () => {
1
+
+ 50 +
); }; @@ -54,7 +63,8 @@ const createTask = (overrides: Partial = {}): Task => ({ const renderTaskList = ( onCellCommit: jest.Mock, onUpdateTask?: jest.Mock, - tasks: Task[] = [createTask()] + tasks: Task[] = [createTask()], + visibleFields: VisibleField[] = ["name"] ) => { render( ()} @@ -194,4 +204,43 @@ describe("TaskList onCellCommit", () => { expect((update.start as Date).getHours()).toBe(13); expect((update.start as Date).getMinutes()).toBe(30); }); + + it("normalizes progress commit to 5-step and clamps to 100", async () => { + const onCellCommit = jest.fn().mockResolvedValue(undefined); + renderTaskList(onCellCommit, undefined, [createTask({ progress: 50 })], [ + "name", + "progress", + ]); + + fireEvent.click(screen.getByTestId("start-edit-progress")); + const input = await screen.findByTestId("overlay-editor-input"); + fireEvent.change(input, { target: { value: "103" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(onCellCommit).toHaveBeenCalledTimes(1)); + const commitPayload = onCellCommit.mock.calls[0][0]; + expect(commitPayload.columnId).toBe("progress"); + expect(commitPayload.value).toBe("100"); + }); + + it("shows error when progress input is invalid", async () => { + const onCellCommit = jest.fn().mockResolvedValue(undefined); + renderTaskList(onCellCommit, undefined, [createTask({ progress: 50 })], [ + "name", + "progress", + ]); + + fireEvent.click(screen.getByTestId("start-edit-progress")); + const input = await screen.findByTestId("overlay-editor-input"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "0〜100 の数値を入力してください" + ) + ); + expect(onCellCommit).not.toHaveBeenCalled(); + expect(screen.getByTestId("overlay-editor-input")).toBeInTheDocument(); + }); }); diff --git a/src/test/task-list-table-editing.test.tsx b/src/test/task-list-table-editing.test.tsx index 6a6769f58..53c02c12e 100644 --- a/src/test/task-list-table-editing.test.tsx +++ b/src/test/task-list-table-editing.test.tsx @@ -299,6 +299,7 @@ describe("TaskListTable cell display", () => { createMockTask("task-1", "Task 1", { start: new Date(2026, 1, 1), end: new Date(2026, 1, 10), + progress: 42, process: "開発", assignee: "田中太郎", plannedStart: new Date(2026, 1, 1), @@ -315,6 +316,7 @@ describe("TaskListTable cell display", () => { "name", "start", "end", + "progress", "process", "assignee", "plannedStart", @@ -334,6 +336,7 @@ describe("TaskListTable cell display", () => { // start and plannedStart share the same date for this fixture expect(screen.getAllByText("2026-02-01")).toHaveLength(2); expect(screen.getByText("2026-02-10")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); expect(screen.getByText("開発")).toBeInTheDocument(); expect(screen.getByText("田中太郎")).toBeInTheDocument(); expect(screen.getByText("2026-02-15")).toBeInTheDocument(); @@ -350,6 +353,17 @@ describe("TaskListTable cell display", () => { expect(screen.queryAllByRole("spinbutton")).toHaveLength(0); }); + it("does not render progress column when not included in visibleFields", () => { + render( + + ); + + expect(document.querySelector('[data-column-id="progress"]')).toBeNull(); + }); + it("keeps edit triggers available for overlay editing", () => { const context = createEditingContext("selected", "task-1", "name"); diff --git a/src/types/public-types.ts b/src/types/public-types.ts index 854b91ed8..6e092cdb8 100644 --- a/src/types/public-types.ts +++ b/src/types/public-types.ts @@ -7,6 +7,7 @@ export type VisibleField = | "name" | "start" | "end" + | "progress" | "process" | "assignee" | "plannedStart"