Make task schema fields explicit with null sentinels

This commit is contained in:
lesetong
2026-08-11 13:36:09 +08:00
parent 28d7e66d8a
commit 8af1c53c83
8 changed files with 270 additions and 75 deletions
+46 -24
View File
@@ -307,17 +307,23 @@ export class TaskState {
}
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
if (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
if (
draft.description !== undefined &&
draft.description !== null &&
(typeof draft.description !== "string" || !draft.description.trim())
) {
errors.push(`${path}.description must not be empty`);
}
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
errors.push(`${path}.status is invalid`);
}
if (nested && draft.parentId !== undefined) errors.push(`${path}.parentId is not allowed inside children`);
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
errors.push(`${path}.parentId is not allowed inside children`);
}
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
let parentId = implicitParentId;
if (!nested && draft.parentId !== undefined) {
if (!nested && draft.parentId !== undefined && draft.parentId !== null) {
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
} else {
@@ -338,7 +344,8 @@ export class TaskState {
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
});
for (const [index, child] of (draft.children ?? []).entries()) {
const children = Array.isArray(draft.children) ? draft.children : [];
for (const [index, child] of children.entries()) {
append(child, id, `${path}.children[${index}]`, true);
}
};
@@ -354,7 +361,7 @@ export class TaskState {
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force !== undefined) throw new TaskValidationError(["force is allowed only in replace mode"]);
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
@@ -365,7 +372,9 @@ export class TaskState {
errors.push("replace requires force when unfinished tasks exist");
}
drafts.forEach((draft, index) => {
if (draft?.parentId !== undefined) errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
if (draft?.parentId !== undefined && draft.parentId !== null) {
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
}
});
if (errors.length > 0) throw new TaskValidationError(errors);
@@ -394,36 +403,49 @@ export class TaskState {
continue;
}
const hasChange = ["name", "description", "status", "parentId", "position"].some((key) =>
Object.prototype.hasOwnProperty.call(patch, key),
);
if (!hasChange) errors.push(`${path} must include at least one field to update`);
if (patch.name !== undefined && (typeof patch.name !== "string" || !patch.name.trim())) {
const hasNameChange = patch.name !== undefined && patch.name !== null;
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
const hasStatusChange = patch.status !== undefined && patch.status !== null;
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
errors.push(`${path} must include at least one field to update`);
}
if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) {
errors.push(`${path}.name must not be empty`);
}
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
if (patch.description !== undefined && (typeof patch.description !== "string" || !patch.description.trim())) {
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) errors.push(`${path}.status is invalid`);
if (patch.parentId !== undefined && patch.parentId !== null) {
if (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
if (
hasParentChange &&
patch.parentId !== null &&
patch.parentId !== 0 &&
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (patch.name !== undefined) task.name = patch.name.trim();
if (patch.description !== undefined) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== task.status) {
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
task.status = patch.status;
}
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
task.parentId = patch.parentId;
if (
patch.parentId !== undefined &&
patch.parentId !== -1 &&
(patch.parentId === null ||
patch.parentId === 0 ||
working.some((item) => item.id === patch.parentId))
) {
task.parentId = patch.parentId === 0 ? null : patch.parentId;
moveToSiblingEnd(working, task.id);
}
if (patch.position !== undefined) {
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
if (positionError) errors.push(`${path}.position: ${positionError}`);
}