feat: implement custom field form and utility functions for managing custom fields
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
|
||||||
|
|
||||||
|
const buildDefinition = (
|
||||||
|
overrides: Partial<TaskCustomFieldDefinitionRead> = {},
|
||||||
|
): TaskCustomFieldDefinitionRead => ({
|
||||||
|
id: "field-1",
|
||||||
|
organization_id: "org-1",
|
||||||
|
field_key: "client_name",
|
||||||
|
field_type: "text",
|
||||||
|
ui_visibility: "always",
|
||||||
|
label: "Client name",
|
||||||
|
required: false,
|
||||||
|
default_value: null,
|
||||||
|
description: null,
|
||||||
|
validation_regex: null,
|
||||||
|
board_ids: ["board-1"],
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TaskCustomFieldsEditor", () => {
|
||||||
|
it("renders loading and empty states", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TaskCustomFieldsEditor
|
||||||
|
definitions={[]}
|
||||||
|
values={{}}
|
||||||
|
setValues={vi.fn()}
|
||||||
|
isLoading
|
||||||
|
disabled={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading custom fields…")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TaskCustomFieldsEditor
|
||||||
|
definitions={[]}
|
||||||
|
values={{}}
|
||||||
|
setValues={vi.fn()}
|
||||||
|
isLoading={false}
|
||||||
|
disabled={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("No custom fields configured for this board."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates field values and respects visibility rules", () => {
|
||||||
|
const setValues = vi.fn();
|
||||||
|
render(
|
||||||
|
<TaskCustomFieldsEditor
|
||||||
|
definitions={[
|
||||||
|
buildDefinition({
|
||||||
|
field_key: "hidden_if_unset",
|
||||||
|
ui_visibility: "if_set",
|
||||||
|
}),
|
||||||
|
buildDefinition({ id: "field-2", field_key: "client_name" }),
|
||||||
|
]}
|
||||||
|
values={{}}
|
||||||
|
setValues={setValues}
|
||||||
|
isLoading={false}
|
||||||
|
disabled={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("hidden_if_unset")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox"), {
|
||||||
|
target: { value: "Acme Corp" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updater = setValues.mock.calls.at(-1)?.[0] as (
|
||||||
|
prev: Record<string, unknown>,
|
||||||
|
) => Record<string, unknown>;
|
||||||
|
expect(updater({})).toEqual({ client_name: "Acme Corp" });
|
||||||
|
});
|
||||||
|
});
|
||||||
155
frontend/src/app/boards/[boardId]/TaskCustomFieldsEditor.tsx
Normal file
155
frontend/src/app/boards/[boardId]/TaskCustomFieldsEditor.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import {
|
||||||
|
customFieldInputText,
|
||||||
|
isCustomFieldVisible,
|
||||||
|
parseCustomFieldInputValue,
|
||||||
|
type TaskCustomFieldValues,
|
||||||
|
} from "./custom-field-utils";
|
||||||
|
|
||||||
|
type TaskCustomFieldsEditorProps = {
|
||||||
|
definitions: TaskCustomFieldDefinitionRead[];
|
||||||
|
values: TaskCustomFieldValues;
|
||||||
|
setValues: Dispatch<SetStateAction<TaskCustomFieldValues>>;
|
||||||
|
isLoading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
loadingMessage?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskCustomFieldsEditor({
|
||||||
|
definitions,
|
||||||
|
values,
|
||||||
|
setValues,
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
loadingMessage = "Loading custom fields…",
|
||||||
|
emptyMessage = "No custom fields configured for this board.",
|
||||||
|
}: TaskCustomFieldsEditorProps) {
|
||||||
|
if (isLoading)
|
||||||
|
return <p className="text-xs text-slate-500">{loadingMessage}</p>;
|
||||||
|
if (definitions.length === 0) {
|
||||||
|
return <p className="text-xs text-slate-500">{emptyMessage}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{definitions.map((definition) => {
|
||||||
|
const fieldValue = values[definition.field_key];
|
||||||
|
if (!isCustomFieldVisible(definition, fieldValue)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={definition.id} className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{definition.label || definition.field_key}
|
||||||
|
{definition.required === true ? (
|
||||||
|
<span className="ml-1 text-rose-600">*</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{definition.field_type === "boolean" ? (
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
fieldValue === true
|
||||||
|
? "true"
|
||||||
|
: fieldValue === false
|
||||||
|
? "false"
|
||||||
|
: "unset"
|
||||||
|
}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[definition.field_key]:
|
||||||
|
value === "unset" ? null : value === "true",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Optional" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unset">Optional</SelectItem>
|
||||||
|
<SelectItem value="true">True</SelectItem>
|
||||||
|
<SelectItem value="false">False</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : definition.field_type === "text_long" ||
|
||||||
|
definition.field_type === "json" ? (
|
||||||
|
<Textarea
|
||||||
|
value={customFieldInputText(fieldValue)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFieldValue = parseCustomFieldInputValue(
|
||||||
|
definition,
|
||||||
|
event.target.value,
|
||||||
|
);
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[definition.field_key]: nextFieldValue,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
definition.default_value !== undefined &&
|
||||||
|
definition.default_value !== null
|
||||||
|
? `Default: ${customFieldInputText(definition.default_value)}`
|
||||||
|
: "Optional"
|
||||||
|
}
|
||||||
|
rows={definition.field_type === "text_long" ? 3 : 4}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={
|
||||||
|
definition.field_type === "integer" ||
|
||||||
|
definition.field_type === "decimal"
|
||||||
|
? "number"
|
||||||
|
: definition.field_type === "date"
|
||||||
|
? "date"
|
||||||
|
: definition.field_type === "date_time"
|
||||||
|
? "datetime-local"
|
||||||
|
: definition.field_type === "url"
|
||||||
|
? "url"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
step={definition.field_type === "decimal" ? "any" : undefined}
|
||||||
|
value={customFieldInputText(fieldValue)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFieldValue = parseCustomFieldInputValue(
|
||||||
|
definition,
|
||||||
|
event.target.value,
|
||||||
|
);
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[definition.field_key]: nextFieldValue,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
definition.default_value !== undefined &&
|
||||||
|
definition.default_value !== null
|
||||||
|
? `Default: ${customFieldInputText(definition.default_value)}`
|
||||||
|
: "Optional"
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definition.description ? (
|
||||||
|
<p className="text-xs text-slate-500">{definition.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/app/boards/[boardId]/custom-field-utils.test.tsx
Normal file
150
frontend/src/app/boards/[boardId]/custom-field-utils.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import {
|
||||||
|
boardCustomFieldValues,
|
||||||
|
canonicalizeCustomFieldValues,
|
||||||
|
customFieldPayload,
|
||||||
|
customFieldPatchPayload,
|
||||||
|
firstMissingRequiredCustomField,
|
||||||
|
formatCustomFieldDetailValue,
|
||||||
|
isCustomFieldVisible,
|
||||||
|
parseCustomFieldInputValue,
|
||||||
|
type TaskCustomFieldValues,
|
||||||
|
} from "./custom-field-utils";
|
||||||
|
|
||||||
|
const buildDefinition = (
|
||||||
|
overrides: Partial<TaskCustomFieldDefinitionRead> = {},
|
||||||
|
): TaskCustomFieldDefinitionRead => ({
|
||||||
|
id: "field-1",
|
||||||
|
organization_id: "org-1",
|
||||||
|
field_key: "client_name",
|
||||||
|
field_type: "text",
|
||||||
|
ui_visibility: "always",
|
||||||
|
label: "Client name",
|
||||||
|
required: false,
|
||||||
|
default_value: null,
|
||||||
|
description: null,
|
||||||
|
validation_regex: null,
|
||||||
|
board_ids: ["board-1"],
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom-field-utils", () => {
|
||||||
|
it("normalizes board field values with defaults", () => {
|
||||||
|
const definitions = [
|
||||||
|
buildDefinition({ field_key: "priority", default_value: "medium" }),
|
||||||
|
buildDefinition({
|
||||||
|
id: "field-2",
|
||||||
|
field_key: "estimate",
|
||||||
|
default_value: 1,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(boardCustomFieldValues(definitions, { priority: "high" })).toEqual({
|
||||||
|
priority: "high",
|
||||||
|
estimate: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds payload and patch payload with only changed keys", () => {
|
||||||
|
const definitions = [
|
||||||
|
buildDefinition({ field_key: "priority" }),
|
||||||
|
buildDefinition({
|
||||||
|
id: "field-2",
|
||||||
|
field_key: "estimate",
|
||||||
|
field_type: "integer",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const currentValues: TaskCustomFieldValues = {
|
||||||
|
priority: "high",
|
||||||
|
estimate: 2,
|
||||||
|
};
|
||||||
|
const nextPayload = customFieldPayload(definitions, {
|
||||||
|
priority: "high",
|
||||||
|
estimate: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextPayload).toEqual({ priority: "high", estimate: 3 });
|
||||||
|
expect(
|
||||||
|
customFieldPatchPayload(definitions, currentValues, nextPayload),
|
||||||
|
).toEqual({
|
||||||
|
estimate: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the first missing required custom field", () => {
|
||||||
|
const definitions = [
|
||||||
|
buildDefinition({
|
||||||
|
field_key: "required_a",
|
||||||
|
label: "Required A",
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
buildDefinition({
|
||||||
|
id: "field-2",
|
||||||
|
field_key: "required_b",
|
||||||
|
label: "Required B",
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
firstMissingRequiredCustomField(definitions, { required_a: "value" }),
|
||||||
|
).toBe("Required B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses typed custom-field input values", () => {
|
||||||
|
expect(
|
||||||
|
parseCustomFieldInputValue(
|
||||||
|
buildDefinition({ field_type: "integer" }),
|
||||||
|
"42",
|
||||||
|
),
|
||||||
|
).toBe(42);
|
||||||
|
expect(
|
||||||
|
parseCustomFieldInputValue(
|
||||||
|
buildDefinition({ field_type: "boolean" }),
|
||||||
|
"false",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseCustomFieldInputValue(
|
||||||
|
buildDefinition({ field_type: "json" }),
|
||||||
|
'{"a":1}',
|
||||||
|
),
|
||||||
|
).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles visibility and canonicalization", () => {
|
||||||
|
expect(
|
||||||
|
isCustomFieldVisible(
|
||||||
|
buildDefinition({ ui_visibility: "hidden" }),
|
||||||
|
"value",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isCustomFieldVisible(buildDefinition({ ui_visibility: "if_set" }), ""),
|
||||||
|
).toBe(false);
|
||||||
|
expect(canonicalizeCustomFieldValues({ b: 2, a: { z: 1, y: 2 } })).toBe(
|
||||||
|
canonicalizeCustomFieldValues({ a: { y: 2, z: 1 }, b: 2 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders url detail values as links", () => {
|
||||||
|
const definition = buildDefinition({
|
||||||
|
field_type: "url",
|
||||||
|
field_key: "website",
|
||||||
|
});
|
||||||
|
const node = formatCustomFieldDetailValue(
|
||||||
|
definition,
|
||||||
|
"https://example.com",
|
||||||
|
);
|
||||||
|
render(<>{node}</>);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /https:\/\/example.com/i }),
|
||||||
|
).toHaveAttribute("href", "https://example.com/");
|
||||||
|
});
|
||||||
|
});
|
||||||
329
frontend/src/app/boards/[boardId]/custom-field-utils.tsx
Normal file
329
frontend/src/app/boards/[boardId]/custom-field-utils.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
|
||||||
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import { parseApiDatetime } from "@/lib/datetime";
|
||||||
|
|
||||||
|
export type TaskCustomFieldValues = Record<string, unknown>;
|
||||||
|
|
||||||
|
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const normalizeCustomFieldValues = (
|
||||||
|
value: unknown,
|
||||||
|
): TaskCustomFieldValues => {
|
||||||
|
if (!isRecordObject(value)) return {};
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return {};
|
||||||
|
return entries
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.reduce((acc, [key, rawValue]) => {
|
||||||
|
if (isRecordObject(rawValue)) {
|
||||||
|
acc[key] = normalizeCustomFieldValues(rawValue);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
acc[key] = rawValue.map((item) =>
|
||||||
|
isRecordObject(item) ? normalizeCustomFieldValues(item) : item,
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[key] = rawValue;
|
||||||
|
return acc;
|
||||||
|
}, {} as TaskCustomFieldValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalizeCustomFieldValues = (value: unknown): string =>
|
||||||
|
JSON.stringify(normalizeCustomFieldValues(value));
|
||||||
|
|
||||||
|
export const customFieldInputText = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateOnlyValue = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
||||||
|
if (match) {
|
||||||
|
const year = Number.parseInt(match[1], 10);
|
||||||
|
const month = Number.parseInt(match[2], 10);
|
||||||
|
const day = Number.parseInt(match[3], 10);
|
||||||
|
const parsed = new Date(year, month - 1, day);
|
||||||
|
if (
|
||||||
|
parsed.getFullYear() === year &&
|
||||||
|
parsed.getMonth() === month - 1 &&
|
||||||
|
parsed.getDate() === day
|
||||||
|
) {
|
||||||
|
return parsed.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return trimmed;
|
||||||
|
return parsed.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTimeValue = (value: string): string => {
|
||||||
|
const parsed = parseApiDatetime(value) ?? new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return parsed.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCustomFieldDetailValue = (
|
||||||
|
definition: TaskCustomFieldDefinitionRead,
|
||||||
|
value: unknown,
|
||||||
|
): ReactNode => {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
|
||||||
|
const fieldType = definition.field_type ?? "text";
|
||||||
|
if (fieldType === "boolean") {
|
||||||
|
if (value === true) return "True";
|
||||||
|
if (value === false) return "False";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "true") return "True";
|
||||||
|
if (normalized === "false") return "False";
|
||||||
|
}
|
||||||
|
return customFieldInputText(value) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "integer" || fieldType === "decimal") {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "—";
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
if (Number.isFinite(parsed)) return parsed.toLocaleString();
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return customFieldInputText(value) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "date") {
|
||||||
|
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
||||||
|
if (!value.trim()) return "—";
|
||||||
|
return formatDateOnlyValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "date_time") {
|
||||||
|
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
||||||
|
if (!value.trim()) return "—";
|
||||||
|
return formatDateTimeValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "url") {
|
||||||
|
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "—";
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(trimmed);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={parsedUrl.toString()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-blue-700 underline decoration-blue-300 underline-offset-2 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
<span className="break-all">{parsedUrl.toString()}</span>
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "json") {
|
||||||
|
try {
|
||||||
|
const normalized = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
|
return (
|
||||||
|
<pre className="whitespace-pre-wrap break-words rounded border border-slate-200 bg-white px-2 py-1 font-mono text-xs leading-relaxed text-slate-800">
|
||||||
|
{JSON.stringify(normalized, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return customFieldInputText(value) || "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "text_long") {
|
||||||
|
const text = customFieldInputText(value);
|
||||||
|
return text ? (
|
||||||
|
<span className="whitespace-pre-wrap break-words">{text}</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return customFieldInputText(value) || "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomFieldValueSet = (value: unknown): boolean => {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
if (typeof value === "string") return value.trim().length > 0;
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
if (isRecordObject(value)) return Object.keys(value).length > 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCustomFieldVisible = (
|
||||||
|
definition: TaskCustomFieldDefinitionRead,
|
||||||
|
value: unknown,
|
||||||
|
): boolean => {
|
||||||
|
if (definition.ui_visibility === "hidden") return false;
|
||||||
|
if (definition.ui_visibility === "if_set")
|
||||||
|
return isCustomFieldValueSet(value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCustomFieldInputValue = (
|
||||||
|
definition: TaskCustomFieldDefinitionRead,
|
||||||
|
text: string,
|
||||||
|
): unknown | null => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (
|
||||||
|
definition.field_type === "text" ||
|
||||||
|
definition.field_type === "text_long"
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (definition.field_type === "integer") {
|
||||||
|
if (!/^-?\d+$/.test(trimmed)) return trimmed;
|
||||||
|
return Number.parseInt(trimmed, 10);
|
||||||
|
}
|
||||||
|
if (definition.field_type === "decimal") {
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
||||||
|
return Number.parseFloat(trimmed);
|
||||||
|
}
|
||||||
|
if (definition.field_type === "boolean") {
|
||||||
|
if (trimmed.toLowerCase() === "true") return true;
|
||||||
|
if (trimmed.toLowerCase() === "false") return false;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
definition.field_type === "date" ||
|
||||||
|
definition.field_type === "date_time" ||
|
||||||
|
definition.field_type === "url"
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (definition.field_type === "json") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const boardCustomFieldValues = (
|
||||||
|
definitions: TaskCustomFieldDefinitionRead[],
|
||||||
|
value: unknown,
|
||||||
|
): TaskCustomFieldValues => {
|
||||||
|
const source = normalizeCustomFieldValues(value);
|
||||||
|
return definitions.reduce((acc, definition) => {
|
||||||
|
const key = definition.field_key;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
acc[key] = source[key];
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[key] = definition.default_value ?? null;
|
||||||
|
return acc;
|
||||||
|
}, {} as TaskCustomFieldValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const customFieldPayload = (
|
||||||
|
definitions: TaskCustomFieldDefinitionRead[],
|
||||||
|
values: TaskCustomFieldValues,
|
||||||
|
): TaskCustomFieldValues =>
|
||||||
|
definitions.reduce((acc, definition) => {
|
||||||
|
const key = definition.field_key;
|
||||||
|
acc[key] =
|
||||||
|
Object.prototype.hasOwnProperty.call(values, key) &&
|
||||||
|
values[key] !== undefined
|
||||||
|
? values[key]
|
||||||
|
: null;
|
||||||
|
return acc;
|
||||||
|
}, {} as TaskCustomFieldValues);
|
||||||
|
|
||||||
|
const canonicalizeCustomFieldValue = (value: unknown): string => {
|
||||||
|
if (value === undefined) return "__undefined__";
|
||||||
|
if (value === null) return "__null__";
|
||||||
|
if (isRecordObject(value)) {
|
||||||
|
return JSON.stringify(normalizeCustomFieldValues(value));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const customFieldPatchPayload = (
|
||||||
|
definitions: TaskCustomFieldDefinitionRead[],
|
||||||
|
currentValues: TaskCustomFieldValues,
|
||||||
|
nextValues: TaskCustomFieldValues,
|
||||||
|
): TaskCustomFieldValues =>
|
||||||
|
definitions.reduce((acc, definition) => {
|
||||||
|
const key = definition.field_key;
|
||||||
|
const currentValue = Object.prototype.hasOwnProperty.call(
|
||||||
|
currentValues,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
? currentValues[key]
|
||||||
|
: null;
|
||||||
|
const nextValue = Object.prototype.hasOwnProperty.call(nextValues, key)
|
||||||
|
? nextValues[key]
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
canonicalizeCustomFieldValue(currentValue) ===
|
||||||
|
canonicalizeCustomFieldValue(nextValue)
|
||||||
|
) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[key] = nextValue ?? null;
|
||||||
|
return acc;
|
||||||
|
}, {} as TaskCustomFieldValues);
|
||||||
|
|
||||||
|
export const firstMissingRequiredCustomField = (
|
||||||
|
definitions: TaskCustomFieldDefinitionRead[],
|
||||||
|
values: TaskCustomFieldValues,
|
||||||
|
): string | null => {
|
||||||
|
for (const definition of definitions) {
|
||||||
|
if (definition.required !== true) continue;
|
||||||
|
const value = values[definition.field_key];
|
||||||
|
if (value !== null && value !== undefined) continue;
|
||||||
|
return definition.label || definition.field_key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -2,15 +2,7 @@
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import {
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
memo,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
@@ -38,6 +30,7 @@ import {
|
|||||||
} from "@/components/molecules/DependencyBanner";
|
} from "@/components/molecules/DependencyBanner";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
||||||
|
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -117,11 +110,20 @@ import {
|
|||||||
} from "@/lib/datetime";
|
} from "@/lib/datetime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageActive } from "@/hooks/usePageActive";
|
import { usePageActive } from "@/hooks/usePageActive";
|
||||||
|
import {
|
||||||
|
boardCustomFieldValues,
|
||||||
|
canonicalizeCustomFieldValues,
|
||||||
|
customFieldPayload,
|
||||||
|
customFieldPatchPayload,
|
||||||
|
firstMissingRequiredCustomField,
|
||||||
|
formatCustomFieldDetailValue,
|
||||||
|
isCustomFieldVisible,
|
||||||
|
type TaskCustomFieldValues,
|
||||||
|
} from "./custom-field-utils";
|
||||||
|
|
||||||
type Board = BoardRead;
|
type Board = BoardRead;
|
||||||
|
|
||||||
type TaskStatus = Exclude<TaskCardRead["status"], undefined>;
|
type TaskStatus = Exclude<TaskCardRead["status"], undefined>;
|
||||||
type TaskCustomFieldValues = Record<string, unknown>;
|
|
||||||
|
|
||||||
type TaskCustomFieldPayload = {
|
type TaskCustomFieldPayload = {
|
||||||
custom_field_values?: TaskCustomFieldValues;
|
custom_field_values?: TaskCustomFieldValues;
|
||||||
@@ -452,330 +454,6 @@ const normalizeTask = (task: TaskCardRead): Task => ({
|
|||||||
approvals_pending_count: task.approvals_pending_count ?? 0,
|
approvals_pending_count: task.approvals_pending_count ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
|
|
||||||
!!value && typeof value === "object" && !Array.isArray(value);
|
|
||||||
|
|
||||||
const normalizeCustomFieldValues = (value: unknown): TaskCustomFieldValues => {
|
|
||||||
if (!isRecordObject(value)) return {};
|
|
||||||
const entries = Object.entries(value);
|
|
||||||
if (entries.length === 0) return {};
|
|
||||||
return entries
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.reduce((acc, [key, rawValue]) => {
|
|
||||||
if (isRecordObject(rawValue)) {
|
|
||||||
acc[key] = normalizeCustomFieldValues(rawValue);
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
if (Array.isArray(rawValue)) {
|
|
||||||
acc[key] = rawValue.map((item) =>
|
|
||||||
isRecordObject(item) ? normalizeCustomFieldValues(item) : item,
|
|
||||||
);
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc[key] = rawValue;
|
|
||||||
return acc;
|
|
||||||
}, {} as TaskCustomFieldValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalizeCustomFieldValues = (value: unknown): string =>
|
|
||||||
JSON.stringify(normalizeCustomFieldValues(value));
|
|
||||||
|
|
||||||
const customFieldInputText = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateOnlyValue = (value: string): string => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
||||||
if (match) {
|
|
||||||
const year = Number.parseInt(match[1], 10);
|
|
||||||
const month = Number.parseInt(match[2], 10);
|
|
||||||
const day = Number.parseInt(match[3], 10);
|
|
||||||
const parsed = new Date(year, month - 1, day);
|
|
||||||
if (
|
|
||||||
parsed.getFullYear() === year &&
|
|
||||||
parsed.getMonth() === month - 1 &&
|
|
||||||
parsed.getDate() === day
|
|
||||||
) {
|
|
||||||
return parsed.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parsed = new Date(trimmed);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return trimmed;
|
|
||||||
return parsed.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTimeValue = (value: string): string => {
|
|
||||||
const parsed = parseApiDatetime(value) ?? new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
|
||||||
return parsed.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCustomFieldDetailValue = (
|
|
||||||
definition: TaskCustomFieldDefinitionRead,
|
|
||||||
value: unknown,
|
|
||||||
): ReactNode => {
|
|
||||||
if (value === null || value === undefined) return "—";
|
|
||||||
|
|
||||||
const fieldType = definition.field_type ?? "text";
|
|
||||||
if (fieldType === "boolean") {
|
|
||||||
if (value === true) return "True";
|
|
||||||
if (value === false) return "False";
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (normalized === "true") return "True";
|
|
||||||
if (normalized === "false") return "False";
|
|
||||||
}
|
|
||||||
return customFieldInputText(value) || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "integer" || fieldType === "decimal") {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value.toLocaleString();
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return "—";
|
|
||||||
const parsed = Number(trimmed);
|
|
||||||
if (Number.isFinite(parsed)) return parsed.toLocaleString();
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return customFieldInputText(value) || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "date") {
|
|
||||||
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
|
||||||
if (!value.trim()) return "—";
|
|
||||||
return formatDateOnlyValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "date_time") {
|
|
||||||
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
|
||||||
if (!value.trim()) return "—";
|
|
||||||
return formatDateTimeValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "url") {
|
|
||||||
if (typeof value !== "string") return customFieldInputText(value) || "—";
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return "—";
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(trimmed);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={parsedUrl.toString()}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-blue-700 underline decoration-blue-300 underline-offset-2 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
<span className="break-all">{parsedUrl.toString()}</span>
|
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "json") {
|
|
||||||
try {
|
|
||||||
const normalized = typeof value === "string" ? JSON.parse(value) : value;
|
|
||||||
return (
|
|
||||||
<pre className="whitespace-pre-wrap break-words rounded border border-slate-200 bg-white px-2 py-1 font-mono text-xs leading-relaxed text-slate-800">
|
|
||||||
{JSON.stringify(normalized, null, 2)}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return customFieldInputText(value) || "—";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldType === "text_long") {
|
|
||||||
const text = customFieldInputText(value);
|
|
||||||
return text ? (
|
|
||||||
<span className="whitespace-pre-wrap break-words">{text}</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return customFieldInputText(value) || "—";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCustomFieldValueSet = (value: unknown): boolean => {
|
|
||||||
if (value === null || value === undefined) return false;
|
|
||||||
if (typeof value === "string") return value.trim().length > 0;
|
|
||||||
if (Array.isArray(value)) return value.length > 0;
|
|
||||||
if (isRecordObject(value)) return Object.keys(value).length > 0;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCustomFieldVisible = (
|
|
||||||
definition: TaskCustomFieldDefinitionRead,
|
|
||||||
value: unknown,
|
|
||||||
): boolean => {
|
|
||||||
if (definition.ui_visibility === "hidden") return false;
|
|
||||||
if (definition.ui_visibility === "if_set")
|
|
||||||
return isCustomFieldValueSet(value);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseCustomFieldInputValue = (
|
|
||||||
definition: TaskCustomFieldDefinitionRead,
|
|
||||||
text: string,
|
|
||||||
): unknown | null => {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (
|
|
||||||
definition.field_type === "text" ||
|
|
||||||
definition.field_type === "text_long"
|
|
||||||
) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
if (definition.field_type === "integer") {
|
|
||||||
if (!/^-?\d+$/.test(trimmed)) return trimmed;
|
|
||||||
return Number.parseInt(trimmed, 10);
|
|
||||||
}
|
|
||||||
if (definition.field_type === "decimal") {
|
|
||||||
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
||||||
return Number.parseFloat(trimmed);
|
|
||||||
}
|
|
||||||
if (definition.field_type === "boolean") {
|
|
||||||
if (trimmed.toLowerCase() === "true") return true;
|
|
||||||
if (trimmed.toLowerCase() === "false") return false;
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
definition.field_type === "date" ||
|
|
||||||
definition.field_type === "date_time" ||
|
|
||||||
definition.field_type === "url"
|
|
||||||
) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
if (definition.field_type === "json") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (
|
|
||||||
parsed === null ||
|
|
||||||
typeof parsed !== "object" ||
|
|
||||||
(!Array.isArray(parsed) && typeof parsed !== "object")
|
|
||||||
) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const boardCustomFieldValues = (
|
|
||||||
definitions: TaskCustomFieldDefinitionRead[],
|
|
||||||
value: unknown,
|
|
||||||
): TaskCustomFieldValues => {
|
|
||||||
const source = normalizeCustomFieldValues(value);
|
|
||||||
return definitions.reduce((acc, definition) => {
|
|
||||||
const key = definition.field_key;
|
|
||||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
||||||
acc[key] = source[key];
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc[key] = definition.default_value ?? null;
|
|
||||||
return acc;
|
|
||||||
}, {} as TaskCustomFieldValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const customFieldPayload = (
|
|
||||||
definitions: TaskCustomFieldDefinitionRead[],
|
|
||||||
values: TaskCustomFieldValues,
|
|
||||||
): TaskCustomFieldValues =>
|
|
||||||
definitions.reduce((acc, definition) => {
|
|
||||||
const key = definition.field_key;
|
|
||||||
acc[key] =
|
|
||||||
Object.prototype.hasOwnProperty.call(values, key) &&
|
|
||||||
values[key] !== undefined
|
|
||||||
? values[key]
|
|
||||||
: null;
|
|
||||||
return acc;
|
|
||||||
}, {} as TaskCustomFieldValues);
|
|
||||||
|
|
||||||
const canonicalizeCustomFieldValue = (value: unknown): string => {
|
|
||||||
if (value === undefined) return "__undefined__";
|
|
||||||
if (value === null) return "__null__";
|
|
||||||
if (isRecordObject(value)) {
|
|
||||||
return JSON.stringify(normalizeCustomFieldValues(value));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const customFieldPatchPayload = (
|
|
||||||
definitions: TaskCustomFieldDefinitionRead[],
|
|
||||||
currentValues: TaskCustomFieldValues,
|
|
||||||
nextValues: TaskCustomFieldValues,
|
|
||||||
): TaskCustomFieldValues =>
|
|
||||||
definitions.reduce((acc, definition) => {
|
|
||||||
const key = definition.field_key;
|
|
||||||
const currentValue = Object.prototype.hasOwnProperty.call(
|
|
||||||
currentValues,
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
? currentValues[key]
|
|
||||||
: null;
|
|
||||||
const nextValue = Object.prototype.hasOwnProperty.call(nextValues, key)
|
|
||||||
? nextValues[key]
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
canonicalizeCustomFieldValue(currentValue) ===
|
|
||||||
canonicalizeCustomFieldValue(nextValue)
|
|
||||||
) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
acc[key] = nextValue ?? null;
|
|
||||||
return acc;
|
|
||||||
}, {} as TaskCustomFieldValues);
|
|
||||||
|
|
||||||
const firstMissingRequiredCustomField = (
|
|
||||||
definitions: TaskCustomFieldDefinitionRead[],
|
|
||||||
values: TaskCustomFieldValues,
|
|
||||||
): string | null => {
|
|
||||||
for (const definition of definitions) {
|
|
||||||
if (definition.required !== true) continue;
|
|
||||||
const value = values[definition.field_key];
|
|
||||||
if (value !== null && value !== undefined) continue;
|
|
||||||
return definition.label || definition.field_key;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAgent = (agent: AgentRead): Agent => ({
|
const normalizeAgent = (agent: AgentRead): Agent => ({
|
||||||
...agent,
|
...agent,
|
||||||
status: agent.status ?? "offline",
|
status: agent.status ?? "offline",
|
||||||
@@ -4345,133 +4023,13 @@ export default function BoardDetailPage() {
|
|||||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Custom fields
|
Custom fields
|
||||||
</label>
|
</label>
|
||||||
{customFieldDefinitionsQuery.isLoading ? (
|
<TaskCustomFieldsEditor
|
||||||
<p className="text-xs text-slate-500">Loading custom fields…</p>
|
definitions={boardCustomFieldDefinitions}
|
||||||
) : boardCustomFieldDefinitions.length === 0 ? (
|
values={editCustomFieldValues}
|
||||||
<p className="text-xs text-slate-500">
|
setValues={setEditCustomFieldValues}
|
||||||
No custom fields configured for this board.
|
isLoading={customFieldDefinitionsQuery.isLoading}
|
||||||
</p>
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{boardCustomFieldDefinitions.map((definition) => {
|
|
||||||
const fieldValue =
|
|
||||||
editCustomFieldValues[definition.field_key];
|
|
||||||
if (!isCustomFieldVisible(definition, fieldValue)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={definition.id} className="space-y-1">
|
|
||||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
{definition.label || definition.field_key}
|
|
||||||
{definition.required === true ? (
|
|
||||||
<span className="ml-1 text-rose-600">*</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
{definition.field_type === "boolean" ? (
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
fieldValue === true
|
|
||||||
? "true"
|
|
||||||
: fieldValue === false
|
|
||||||
? "false"
|
|
||||||
: "unset"
|
|
||||||
}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setEditCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
value === "unset" ? null : value === "true",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
!selectedTask || isSavingTask || !canWrite
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Optional" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="unset">Optional</SelectItem>
|
|
||||||
<SelectItem value="true">True</SelectItem>
|
|
||||||
<SelectItem value="false">False</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : definition.field_type === "text_long" ||
|
|
||||||
definition.field_type === "json" ? (
|
|
||||||
<Textarea
|
|
||||||
value={customFieldInputText(fieldValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEditCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
parseCustomFieldInputValue(
|
|
||||||
definition,
|
|
||||||
event.target.value,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
definition.default_value !== undefined &&
|
|
||||||
definition.default_value !== null
|
|
||||||
? `Default: ${customFieldInputText(definition.default_value)}`
|
|
||||||
: "Optional"
|
|
||||||
}
|
|
||||||
rows={definition.field_type === "text_long" ? 3 : 4}
|
|
||||||
disabled={
|
|
||||||
!selectedTask || isSavingTask || !canWrite
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type={
|
|
||||||
definition.field_type === "integer" ||
|
|
||||||
definition.field_type === "decimal"
|
|
||||||
? "number"
|
|
||||||
: definition.field_type === "date"
|
|
||||||
? "date"
|
|
||||||
: definition.field_type === "date_time"
|
|
||||||
? "datetime-local"
|
|
||||||
: definition.field_type === "url"
|
|
||||||
? "url"
|
|
||||||
: "text"
|
|
||||||
}
|
|
||||||
step={
|
|
||||||
definition.field_type === "decimal"
|
|
||||||
? "any"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
value={customFieldInputText(fieldValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEditCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
parseCustomFieldInputValue(
|
|
||||||
definition,
|
|
||||||
event.target.value,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
definition.default_value !== undefined &&
|
|
||||||
definition.default_value !== null
|
|
||||||
? `Default: ${customFieldInputText(definition.default_value)}`
|
|
||||||
: "Optional"
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
!selectedTask || isSavingTask || !canWrite
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{definition.description ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{definition.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -4801,127 +4359,13 @@ export default function BoardDetailPage() {
|
|||||||
<label className="text-sm font-medium text-strong">
|
<label className="text-sm font-medium text-strong">
|
||||||
Custom fields
|
Custom fields
|
||||||
</label>
|
</label>
|
||||||
{customFieldDefinitionsQuery.isLoading ? (
|
<TaskCustomFieldsEditor
|
||||||
<p className="text-xs text-slate-500">Loading custom fields…</p>
|
definitions={boardCustomFieldDefinitions}
|
||||||
) : boardCustomFieldDefinitions.length === 0 ? (
|
values={createCustomFieldValues}
|
||||||
<p className="text-xs text-slate-500">
|
setValues={setCreateCustomFieldValues}
|
||||||
No custom fields configured for this board.
|
isLoading={customFieldDefinitionsQuery.isLoading}
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{boardCustomFieldDefinitions.map((definition) => {
|
|
||||||
const fieldValue =
|
|
||||||
createCustomFieldValues[definition.field_key];
|
|
||||||
if (!isCustomFieldVisible(definition, fieldValue)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={definition.id} className="space-y-1">
|
|
||||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
{definition.label || definition.field_key}
|
|
||||||
{definition.required === true ? (
|
|
||||||
<span className="ml-1 text-rose-600">*</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
{definition.field_type === "boolean" ? (
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
fieldValue === true
|
|
||||||
? "true"
|
|
||||||
: fieldValue === false
|
|
||||||
? "false"
|
|
||||||
: "unset"
|
|
||||||
}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setCreateCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
value === "unset" ? null : value === "true",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canWrite || isCreating}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Optional" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="unset">Optional</SelectItem>
|
|
||||||
<SelectItem value="true">True</SelectItem>
|
|
||||||
<SelectItem value="false">False</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : definition.field_type === "text_long" ||
|
|
||||||
definition.field_type === "json" ? (
|
|
||||||
<Textarea
|
|
||||||
value={customFieldInputText(fieldValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
setCreateCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
parseCustomFieldInputValue(
|
|
||||||
definition,
|
|
||||||
event.target.value,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
definition.default_value !== undefined &&
|
|
||||||
definition.default_value !== null
|
|
||||||
? `Default: ${customFieldInputText(definition.default_value)}`
|
|
||||||
: "Optional"
|
|
||||||
}
|
|
||||||
rows={definition.field_type === "text_long" ? 3 : 4}
|
|
||||||
disabled={!canWrite || isCreating}
|
disabled={!canWrite || isCreating}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type={
|
|
||||||
definition.field_type === "integer" ||
|
|
||||||
definition.field_type === "decimal"
|
|
||||||
? "number"
|
|
||||||
: definition.field_type === "date"
|
|
||||||
? "date"
|
|
||||||
: definition.field_type === "date_time"
|
|
||||||
? "datetime-local"
|
|
||||||
: definition.field_type === "url"
|
|
||||||
? "url"
|
|
||||||
: "text"
|
|
||||||
}
|
|
||||||
step={
|
|
||||||
definition.field_type === "decimal"
|
|
||||||
? "any"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
value={customFieldInputText(fieldValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
setCreateCustomFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[definition.field_key]:
|
|
||||||
parseCustomFieldInputValue(
|
|
||||||
definition,
|
|
||||||
event.target.value,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
definition.default_value !== undefined &&
|
|
||||||
definition.default_value !== null
|
|
||||||
? `Default: ${customFieldInputText(definition.default_value)}`
|
|
||||||
: "Optional"
|
|
||||||
}
|
|
||||||
disabled={!canWrite || isCreating}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{definition.description ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{definition.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-strong">
|
<label className="text-sm font-medium text-strong">
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { type FormEvent, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
@@ -20,521 +19,17 @@ import {
|
|||||||
useListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGet,
|
useListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGet,
|
||||||
useUpdateOrgCustomFieldApiV1OrganizationsMeCustomFieldsTaskCustomFieldDefinitionIdPatch,
|
useUpdateOrgCustomFieldApiV1OrganizationsMeCustomFieldsTaskCustomFieldDefinitionIdPatch,
|
||||||
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
||||||
import type {
|
import type { TaskCustomFieldDefinitionUpdate } from "@/api/generated/model";
|
||||||
BoardRead,
|
import { CustomFieldForm } from "@/components/custom-fields/CustomFieldForm";
|
||||||
TaskCustomFieldDefinitionRead,
|
|
||||||
TaskCustomFieldDefinitionUpdate,
|
|
||||||
} from "@/api/generated/model";
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
buildCustomFieldUpdatePayload,
|
||||||
SelectContent,
|
deriveFormStateFromCustomField,
|
||||||
SelectItem,
|
extractApiErrorMessage,
|
||||||
SelectTrigger,
|
type NormalizedCustomFieldFormValues,
|
||||||
SelectValue,
|
} from "@/components/custom-fields/custom-field-form-utils";
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
fieldKey: string;
|
|
||||||
label: string;
|
|
||||||
fieldType:
|
|
||||||
| "text"
|
|
||||||
| "text_long"
|
|
||||||
| "integer"
|
|
||||||
| "decimal"
|
|
||||||
| "boolean"
|
|
||||||
| "date"
|
|
||||||
| "date_time"
|
|
||||||
| "url"
|
|
||||||
| "json";
|
|
||||||
uiVisibility: "always" | "if_set" | "hidden";
|
|
||||||
validationRegex: string;
|
|
||||||
description: string;
|
|
||||||
required: boolean;
|
|
||||||
defaultValue: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditCustomFieldFormProps = {
|
|
||||||
field: TaskCustomFieldDefinitionRead;
|
|
||||||
boards: BoardRead[];
|
|
||||||
boardsLoading: boolean;
|
|
||||||
boardsError: string | null;
|
|
||||||
saving: boolean;
|
|
||||||
onSubmit: (updates: TaskCustomFieldDefinitionUpdate) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRING_FIELD_TYPES = new Set([
|
|
||||||
"text",
|
|
||||||
"text_long",
|
|
||||||
"date",
|
|
||||||
"date_time",
|
|
||||||
"url",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parseDefaultValue = (
|
|
||||||
fieldType: FormState["fieldType"],
|
|
||||||
value: string,
|
|
||||||
): { value: unknown | null; error: string | null } => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return { value: null, error: null };
|
|
||||||
if (fieldType === "text" || fieldType === "text_long") {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "integer") {
|
|
||||||
if (!/^-?\d+$/.test(trimmed)) {
|
|
||||||
return { value: null, error: "Default value must be a valid integer." };
|
|
||||||
}
|
|
||||||
return { value: Number.parseInt(trimmed, 10), error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "decimal") {
|
|
||||||
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
||||||
return { value: null, error: "Default value must be a valid decimal." };
|
|
||||||
}
|
|
||||||
return { value: Number.parseFloat(trimmed), error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "boolean") {
|
|
||||||
if (trimmed.toLowerCase() === "true") return { value: true, error: null };
|
|
||||||
if (trimmed.toLowerCase() === "false") return { value: false, error: null };
|
|
||||||
return { value: null, error: "Default value must be true or false." };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
fieldType === "date" ||
|
|
||||||
fieldType === "date_time" ||
|
|
||||||
fieldType === "url"
|
|
||||||
) {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "json") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (
|
|
||||||
parsed === null ||
|
|
||||||
typeof parsed !== "object" ||
|
|
||||||
(!Array.isArray(parsed) && typeof parsed !== "object")
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
value: null,
|
|
||||||
error: "Default value must be valid JSON (object or array).",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { value: parsed, error: null };
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
value: null,
|
|
||||||
error: "Default value must be valid JSON (object or array).",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return { value: JSON.parse(trimmed), error: null };
|
|
||||||
} catch {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDefaultValue = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalJson = (value: unknown): string =>
|
|
||||||
JSON.stringify(value) ?? "undefined";
|
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
|
||||||
if (error instanceof ApiError) return error.message || fallback;
|
|
||||||
if (error instanceof Error) return error.message || fallback;
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
function EditCustomFieldForm({
|
|
||||||
field,
|
|
||||||
boards,
|
|
||||||
boardsLoading,
|
|
||||||
boardsError,
|
|
||||||
saving,
|
|
||||||
onSubmit,
|
|
||||||
}: EditCustomFieldFormProps) {
|
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
|
||||||
fieldKey: field.field_key,
|
|
||||||
label: field.label ?? field.field_key,
|
|
||||||
fieldType: field.field_type ?? "text",
|
|
||||||
uiVisibility: field.ui_visibility ?? "always",
|
|
||||||
validationRegex: field.validation_regex ?? "",
|
|
||||||
description: field.description ?? "",
|
|
||||||
required: field.required === true,
|
|
||||||
defaultValue: formatDefaultValue(field.default_value),
|
|
||||||
}));
|
|
||||||
const [boardSearch, setBoardSearch] = useState("");
|
|
||||||
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
|
|
||||||
() => new Set(field.board_ids ?? []),
|
|
||||||
);
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const filteredBoards = useMemo(() => {
|
|
||||||
const query = boardSearch.trim().toLowerCase();
|
|
||||||
if (!query) return boards;
|
|
||||||
return boards.filter(
|
|
||||||
(board) =>
|
|
||||||
board.name.toLowerCase().includes(query) ||
|
|
||||||
board.slug.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}, [boardSearch, boards]);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSaveError(null);
|
|
||||||
|
|
||||||
const trimmedLabel = formState.label.trim();
|
|
||||||
const trimmedValidationRegex = formState.validationRegex.trim();
|
|
||||||
if (!trimmedLabel) {
|
|
||||||
setSaveError("Label is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedBoardIds.size === 0) {
|
|
||||||
setSaveError("Select at least one board.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
trimmedValidationRegex &&
|
|
||||||
!STRING_FIELD_TYPES.has(formState.fieldType)
|
|
||||||
) {
|
|
||||||
setSaveError(
|
|
||||||
"Validation regex is only supported for string field types.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsedDefaultValue = parseDefaultValue(
|
|
||||||
formState.fieldType,
|
|
||||||
formState.defaultValue,
|
|
||||||
);
|
|
||||||
if (parsedDefaultValue.error) {
|
|
||||||
setSaveError(parsedDefaultValue.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
label: trimmedLabel,
|
|
||||||
field_type: formState.fieldType,
|
|
||||||
ui_visibility: formState.uiVisibility,
|
|
||||||
validation_regex: trimmedValidationRegex || null,
|
|
||||||
description: formState.description.trim() || null,
|
|
||||||
required: formState.required,
|
|
||||||
default_value: parsedDefaultValue.value,
|
|
||||||
board_ids: Array.from(selectedBoardIds),
|
|
||||||
};
|
|
||||||
|
|
||||||
const updates: TaskCustomFieldDefinitionUpdate = {};
|
|
||||||
if (payload.label !== (field.label ?? field.field_key)) {
|
|
||||||
updates.label = payload.label;
|
|
||||||
}
|
|
||||||
if (payload.field_type !== (field.field_type ?? "text")) {
|
|
||||||
updates.field_type = payload.field_type;
|
|
||||||
}
|
|
||||||
if (payload.ui_visibility !== (field.ui_visibility ?? "always")) {
|
|
||||||
updates.ui_visibility = payload.ui_visibility;
|
|
||||||
}
|
|
||||||
if (payload.validation_regex !== (field.validation_regex ?? null)) {
|
|
||||||
updates.validation_regex = payload.validation_regex;
|
|
||||||
}
|
|
||||||
if (payload.description !== (field.description ?? null)) {
|
|
||||||
updates.description = payload.description;
|
|
||||||
}
|
|
||||||
if (payload.required !== (field.required === true)) {
|
|
||||||
updates.required = payload.required;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
canonicalJson(payload.default_value) !==
|
|
||||||
canonicalJson(field.default_value)
|
|
||||||
) {
|
|
||||||
updates.default_value = payload.default_value;
|
|
||||||
}
|
|
||||||
const currentBoardIds = [...(field.board_ids ?? [])].sort();
|
|
||||||
const nextBoardIds = [...payload.board_ids].sort();
|
|
||||||
if (JSON.stringify(currentBoardIds) !== JSON.stringify(nextBoardIds)) {
|
|
||||||
updates.board_ids = payload.board_ids;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
setSaveError("No changes were made.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit(updates);
|
|
||||||
} catch (error) {
|
|
||||||
setSaveError(
|
|
||||||
extractErrorMessage(error, "Failed to update custom field."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="max-w-3xl rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Basic configuration
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Field key
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={formState.fieldKey}
|
|
||||||
placeholder="e.g. client_name"
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
Field key cannot be changed after creation.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">Label</span>
|
|
||||||
<Input
|
|
||||||
value={formState.label}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({ ...prev, label: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="e.g. Client name"
|
|
||||||
disabled={saving}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Field type
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={formState.fieldType}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldType: value as FormState["fieldType"],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select field type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="text">Text</SelectItem>
|
|
||||||
<SelectItem value="text_long">Text (long)</SelectItem>
|
|
||||||
<SelectItem value="integer">Integer</SelectItem>
|
|
||||||
<SelectItem value="decimal">Decimal</SelectItem>
|
|
||||||
<SelectItem value="boolean">Boolean (true/false)</SelectItem>
|
|
||||||
<SelectItem value="date">Date</SelectItem>
|
|
||||||
<SelectItem value="date_time">Date & time</SelectItem>
|
|
||||||
<SelectItem value="url">URL</SelectItem>
|
|
||||||
<SelectItem value="json">JSON</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
UI visible
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={formState.uiVisibility}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
uiVisibility: value as FormState["uiVisibility"],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select visibility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="always">Always</SelectItem>
|
|
||||||
<SelectItem value="if_set">If set</SelectItem>
|
|
||||||
<SelectItem value="hidden">Hidden</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="mt-4 flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formState.required}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
required: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
Required
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Validation and defaults
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Validation regex
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={formState.validationRegex}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
validationRegex: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional. Example: ^[A-Z]{3}$"
|
|
||||||
disabled={saving || !STRING_FIELD_TYPES.has(formState.fieldType)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Supported for text/date/date-time/url fields.
|
|
||||||
</p>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Default value
|
|
||||||
</span>
|
|
||||||
<Textarea
|
|
||||||
value={formState.defaultValue}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
defaultValue: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder='Optional default value. For booleans use "true"/"false"; for JSON use an object or array.'
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Description
|
|
||||||
</span>
|
|
||||||
<Textarea
|
|
||||||
value={formState.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
description: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Optional description used by agents and UI"
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Board bindings
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{selectedBoardIds.size} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<Input
|
|
||||||
value={boardSearch}
|
|
||||||
onChange={(event) => setBoardSearch(event.target.value)}
|
|
||||||
placeholder="Search boards..."
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
|
||||||
{boardsLoading ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
Loading boards…
|
|
||||||
</div>
|
|
||||||
) : boardsError ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-rose-700">
|
|
||||||
{boardsError}
|
|
||||||
</div>
|
|
||||||
) : filteredBoards.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
No boards found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-slate-200">
|
|
||||||
{filteredBoards.map((board) => {
|
|
||||||
const checked = selectedBoardIds.has(board.id);
|
|
||||||
return (
|
|
||||||
<li key={board.id} className="px-4 py-3">
|
|
||||||
<label className="flex cursor-pointer items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setSelectedBoardIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(board.id)) {
|
|
||||||
next.delete(board.id);
|
|
||||||
} else {
|
|
||||||
next.add(board.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
|
||||||
{board.name}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
{board.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Required. The custom field appears on tasks in selected boards.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveError ? <p className="text-sm text-rose-600">{saveError}</p> : null}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href="/custom-fields"
|
|
||||||
className={buttonVariants({ variant: "outline" })}
|
|
||||||
aria-disabled={saving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Link>
|
|
||||||
<Button type="submit" disabled={saving}>
|
|
||||||
{saving ? "Saving..." : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditCustomFieldPage() {
|
export default function EditCustomFieldPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -555,6 +50,7 @@ export default function EditCustomFieldPage() {
|
|||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const field = useMemo(() => {
|
const field = useMemo(() => {
|
||||||
if (!fieldId || customFieldsQuery.data?.status !== 200) return null;
|
if (!fieldId || customFieldsQuery.data?.status !== 200) return null;
|
||||||
return (
|
return (
|
||||||
@@ -575,6 +71,7 @@ export default function EditCustomFieldPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const boards = useMemo(
|
const boards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardsQuery.data?.status === 200
|
boardsQuery.data?.status === 200
|
||||||
@@ -585,14 +82,13 @@ export default function EditCustomFieldPage() {
|
|||||||
|
|
||||||
const updateMutation =
|
const updateMutation =
|
||||||
useUpdateOrgCustomFieldApiV1OrganizationsMeCustomFieldsTaskCustomFieldDefinitionIdPatch<ApiError>();
|
useUpdateOrgCustomFieldApiV1OrganizationsMeCustomFieldsTaskCustomFieldDefinitionIdPatch<ApiError>();
|
||||||
const saving = updateMutation.isPending;
|
|
||||||
const customFieldsKey =
|
const customFieldsKey =
|
||||||
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey();
|
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey();
|
||||||
|
|
||||||
const loadError = useMemo(() => {
|
const loadError = useMemo(() => {
|
||||||
if (!fieldId) return "Missing custom field id.";
|
if (!fieldId) return "Missing custom field id.";
|
||||||
if (customFieldsQuery.error) {
|
if (customFieldsQuery.error) {
|
||||||
return extractErrorMessage(
|
return extractApiErrorMessage(
|
||||||
customFieldsQuery.error,
|
customFieldsQuery.error,
|
||||||
"Failed to load custom field.",
|
"Failed to load custom field.",
|
||||||
);
|
);
|
||||||
@@ -602,8 +98,15 @@ export default function EditCustomFieldPage() {
|
|||||||
return null;
|
return null;
|
||||||
}, [customFieldsQuery.error, customFieldsQuery.isLoading, field, fieldId]);
|
}, [customFieldsQuery.error, customFieldsQuery.isLoading, field, fieldId]);
|
||||||
|
|
||||||
const handleSubmit = async (updates: TaskCustomFieldDefinitionUpdate) => {
|
const handleSubmit = async (values: NormalizedCustomFieldFormValues) => {
|
||||||
if (!fieldId) return;
|
if (!fieldId || !field) return;
|
||||||
|
|
||||||
|
const updates: TaskCustomFieldDefinitionUpdate =
|
||||||
|
buildCustomFieldUpdatePayload(field, values);
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
throw new Error("No changes were made.");
|
||||||
|
}
|
||||||
|
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({
|
||||||
taskCustomFieldDefinitionId: fieldId,
|
taskCustomFieldDefinitionId: fieldId,
|
||||||
data: updates,
|
data: updates,
|
||||||
@@ -636,13 +139,18 @@ export default function EditCustomFieldPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!customFieldsQuery.isLoading && !loadError && field ? (
|
{!customFieldsQuery.isLoading && !loadError && field ? (
|
||||||
<EditCustomFieldForm
|
<CustomFieldForm
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
mode="edit"
|
||||||
|
initialFormState={deriveFormStateFromCustomField(field)}
|
||||||
|
initialBoardIds={field.board_ids ?? []}
|
||||||
boards={boards}
|
boards={boards}
|
||||||
boardsLoading={boardsQuery.isLoading}
|
boardsLoading={boardsQuery.isLoading}
|
||||||
boardsError={boardsQuery.error?.message ?? null}
|
boardsError={boardsQuery.error?.message ?? null}
|
||||||
saving={saving}
|
isSubmitting={updateMutation.isPending}
|
||||||
|
submitLabel="Save changes"
|
||||||
|
submittingLabel="Saving..."
|
||||||
|
submitErrorFallback="Failed to update custom field."
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { type FormEvent, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
@@ -18,139 +17,21 @@ import {
|
|||||||
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey,
|
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey,
|
||||||
useCreateOrgCustomFieldApiV1OrganizationsMeCustomFieldsPost,
|
useCreateOrgCustomFieldApiV1OrganizationsMeCustomFieldsPost,
|
||||||
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
||||||
import type { TaskCustomFieldDefinitionCreate } from "@/api/generated/model";
|
import { CustomFieldForm } from "@/components/custom-fields/CustomFieldForm";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DEFAULT_CUSTOM_FIELD_FORM_STATE } from "@/components/custom-fields/custom-field-form-types";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
createCustomFieldPayload,
|
||||||
SelectContent,
|
type NormalizedCustomFieldFormValues,
|
||||||
SelectItem,
|
} from "@/components/custom-fields/custom-field-form-utils";
|
||||||
SelectTrigger,
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
fieldKey: string;
|
|
||||||
label: string;
|
|
||||||
fieldType:
|
|
||||||
| "text"
|
|
||||||
| "text_long"
|
|
||||||
| "integer"
|
|
||||||
| "decimal"
|
|
||||||
| "boolean"
|
|
||||||
| "date"
|
|
||||||
| "date_time"
|
|
||||||
| "url"
|
|
||||||
| "json";
|
|
||||||
uiVisibility: "always" | "if_set" | "hidden";
|
|
||||||
validationRegex: string;
|
|
||||||
description: string;
|
|
||||||
required: boolean;
|
|
||||||
defaultValue: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultFormState: FormState = {
|
|
||||||
fieldKey: "",
|
|
||||||
label: "",
|
|
||||||
fieldType: "text",
|
|
||||||
uiVisibility: "always",
|
|
||||||
validationRegex: "",
|
|
||||||
description: "",
|
|
||||||
required: false,
|
|
||||||
defaultValue: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRING_FIELD_TYPES = new Set([
|
|
||||||
"text",
|
|
||||||
"text_long",
|
|
||||||
"date",
|
|
||||||
"date_time",
|
|
||||||
"url",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parseDefaultValue = (
|
|
||||||
fieldType: FormState["fieldType"],
|
|
||||||
value: string,
|
|
||||||
): { value: unknown | null; error: string | null } => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return { value: null, error: null };
|
|
||||||
if (fieldType === "text" || fieldType === "text_long") {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "integer") {
|
|
||||||
if (!/^-?\d+$/.test(trimmed)) {
|
|
||||||
return { value: null, error: "Default value must be a valid integer." };
|
|
||||||
}
|
|
||||||
return { value: Number.parseInt(trimmed, 10), error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "decimal") {
|
|
||||||
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
||||||
return { value: null, error: "Default value must be a valid decimal." };
|
|
||||||
}
|
|
||||||
return { value: Number.parseFloat(trimmed), error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "boolean") {
|
|
||||||
if (trimmed.toLowerCase() === "true") return { value: true, error: null };
|
|
||||||
if (trimmed.toLowerCase() === "false") return { value: false, error: null };
|
|
||||||
return { value: null, error: "Default value must be true or false." };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
fieldType === "date" ||
|
|
||||||
fieldType === "date_time" ||
|
|
||||||
fieldType === "url"
|
|
||||||
) {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
if (fieldType === "json") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (
|
|
||||||
parsed === null ||
|
|
||||||
typeof parsed !== "object" ||
|
|
||||||
(!Array.isArray(parsed) && typeof parsed !== "object")
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
value: null,
|
|
||||||
error: "Default value must be valid JSON (object or array).",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { value: parsed, error: null };
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
value: null,
|
|
||||||
error: "Default value must be valid JSON (object or array).",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return { value: JSON.parse(trimmed), error: null };
|
|
||||||
} catch {
|
|
||||||
return { value: trimmed, error: null };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
|
||||||
if (error instanceof ApiError) return error.message || fallback;
|
|
||||||
if (error instanceof Error) return error.message || fallback;
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NewCustomFieldPage() {
|
export default function NewCustomFieldPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [formState, setFormState] = useState<FormState>(defaultFormState);
|
|
||||||
const [boardSearch, setBoardSearch] = useState("");
|
|
||||||
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const boardsQuery = useListBoardsApiV1BoardsGet<
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||||
listBoardsApiV1BoardsGetResponse,
|
listBoardsApiV1BoardsGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
@@ -164,6 +45,7 @@ export default function NewCustomFieldPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const boards = useMemo(
|
const boards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardsQuery.data?.status === 200
|
boardsQuery.data?.status === 200
|
||||||
@@ -171,79 +53,18 @@ export default function NewCustomFieldPage() {
|
|||||||
: [],
|
: [],
|
||||||
[boardsQuery.data],
|
[boardsQuery.data],
|
||||||
);
|
);
|
||||||
const filteredBoards = useMemo(() => {
|
|
||||||
const query = boardSearch.trim().toLowerCase();
|
|
||||||
if (!query) return boards;
|
|
||||||
return boards.filter(
|
|
||||||
(board) =>
|
|
||||||
board.name.toLowerCase().includes(query) ||
|
|
||||||
board.slug.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}, [boardSearch, boards]);
|
|
||||||
|
|
||||||
const createMutation =
|
const createMutation =
|
||||||
useCreateOrgCustomFieldApiV1OrganizationsMeCustomFieldsPost<ApiError>();
|
useCreateOrgCustomFieldApiV1OrganizationsMeCustomFieldsPost<ApiError>();
|
||||||
const saving = createMutation.isPending;
|
|
||||||
const customFieldsKey =
|
const customFieldsKey =
|
||||||
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey();
|
getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey();
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (values: NormalizedCustomFieldFormValues) => {
|
||||||
event.preventDefault();
|
await createMutation.mutateAsync({
|
||||||
if (!isSignedIn || saving) return;
|
data: createCustomFieldPayload(values),
|
||||||
setError(null);
|
});
|
||||||
|
|
||||||
const trimmedFieldKey = formState.fieldKey.trim();
|
|
||||||
const trimmedLabel = formState.label.trim();
|
|
||||||
const trimmedValidationRegex = formState.validationRegex.trim();
|
|
||||||
if (!trimmedFieldKey) {
|
|
||||||
setError("Field key is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trimmedLabel) {
|
|
||||||
setError("Label is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedBoardIds.size === 0) {
|
|
||||||
setError("Select at least one board.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
trimmedValidationRegex &&
|
|
||||||
!STRING_FIELD_TYPES.has(formState.fieldType)
|
|
||||||
) {
|
|
||||||
setError("Validation regex is only supported for string field types.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsedDefaultValue = parseDefaultValue(
|
|
||||||
formState.fieldType,
|
|
||||||
formState.defaultValue,
|
|
||||||
);
|
|
||||||
if (parsedDefaultValue.error) {
|
|
||||||
setError(parsedDefaultValue.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: TaskCustomFieldDefinitionCreate = {
|
|
||||||
field_key: trimmedFieldKey,
|
|
||||||
label: trimmedLabel,
|
|
||||||
field_type: formState.fieldType,
|
|
||||||
ui_visibility: formState.uiVisibility,
|
|
||||||
validation_regex: trimmedValidationRegex || null,
|
|
||||||
description: formState.description.trim() || null,
|
|
||||||
required: formState.required,
|
|
||||||
default_value: parsedDefaultValue.value,
|
|
||||||
board_ids: Array.from(selectedBoardIds),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync({ data: payload });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: customFieldsKey });
|
await queryClient.invalidateQueries({ queryKey: customFieldsKey });
|
||||||
router.push("/custom-fields");
|
router.push("/custom-fields");
|
||||||
} catch (submitError) {
|
|
||||||
setError(
|
|
||||||
extractErrorMessage(submitError, "Failed to create custom field."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,271 +80,18 @@ export default function NewCustomFieldPage() {
|
|||||||
adminOnlyMessage="Only organization owners and admins can manage custom fields."
|
adminOnlyMessage="Only organization owners and admins can manage custom fields."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<form
|
<CustomFieldForm
|
||||||
|
mode="create"
|
||||||
|
initialFormState={DEFAULT_CUSTOM_FIELD_FORM_STATE}
|
||||||
|
boards={boards}
|
||||||
|
boardsLoading={boardsQuery.isLoading}
|
||||||
|
boardsError={boardsQuery.error?.message ?? null}
|
||||||
|
isSubmitting={createMutation.isPending}
|
||||||
|
submitLabel="Create field"
|
||||||
|
submittingLabel="Creating..."
|
||||||
|
submitErrorFallback="Failed to create custom field."
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="max-w-3xl rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Basic configuration
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Field key
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={formState.fieldKey}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldKey: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="e.g. client_name"
|
|
||||||
disabled={saving}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Label
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={formState.label}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
label: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="e.g. Client name"
|
|
||||||
disabled={saving}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Field type
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={formState.fieldType}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldType: value as FormState["fieldType"],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select field type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="text">Text</SelectItem>
|
|
||||||
<SelectItem value="text_long">Text (long)</SelectItem>
|
|
||||||
<SelectItem value="integer">Integer</SelectItem>
|
|
||||||
<SelectItem value="decimal">Decimal</SelectItem>
|
|
||||||
<SelectItem value="boolean">Boolean (true/false)</SelectItem>
|
|
||||||
<SelectItem value="date">Date</SelectItem>
|
|
||||||
<SelectItem value="date_time">Date & time</SelectItem>
|
|
||||||
<SelectItem value="url">URL</SelectItem>
|
|
||||||
<SelectItem value="json">JSON</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
UI visible
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={formState.uiVisibility}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
uiVisibility: value as FormState["uiVisibility"],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select visibility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="always">Always</SelectItem>
|
|
||||||
<SelectItem value="if_set">If set</SelectItem>
|
|
||||||
<SelectItem value="hidden">Hidden</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="mt-4 flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formState.required}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
required: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
Required
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Validation and defaults
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Validation regex
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={formState.validationRegex}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
validationRegex: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional. Example: ^[A-Z]{3}$"
|
|
||||||
disabled={
|
|
||||||
saving || !STRING_FIELD_TYPES.has(formState.fieldType)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Supported for text/date/date-time/url fields.
|
|
||||||
</p>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Default value
|
|
||||||
</span>
|
|
||||||
<Textarea
|
|
||||||
value={formState.defaultValue}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
defaultValue: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder='Optional default value. For booleans use "true"/"false"; for JSON use an object or array.'
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
|
||||||
Description
|
|
||||||
</span>
|
|
||||||
<Textarea
|
|
||||||
value={formState.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
description: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Optional description used by agents and UI"
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Board bindings
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{selectedBoardIds.size} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<Input
|
|
||||||
value={boardSearch}
|
|
||||||
onChange={(event) => setBoardSearch(event.target.value)}
|
|
||||||
placeholder="Search boards..."
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
|
||||||
{boardsQuery.isLoading ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
Loading boards…
|
|
||||||
</div>
|
|
||||||
) : boardsQuery.error ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-rose-700">
|
|
||||||
{boardsQuery.error.message}
|
|
||||||
</div>
|
|
||||||
) : filteredBoards.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
No boards found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-slate-200">
|
|
||||||
{filteredBoards.map((board) => {
|
|
||||||
const checked = selectedBoardIds.has(board.id);
|
|
||||||
return (
|
|
||||||
<li key={board.id} className="px-4 py-3">
|
|
||||||
<label className="flex cursor-pointer items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setSelectedBoardIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(board.id)) {
|
|
||||||
next.delete(board.id);
|
|
||||||
} else {
|
|
||||||
next.add(board.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
|
||||||
{board.name}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
{board.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Required. The custom field appears on tasks in selected boards.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? <p className="text-sm text-rose-600">{error}</p> : null}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href="/custom-fields"
|
|
||||||
className={buttonVariants({ variant: "outline" })}
|
|
||||||
aria-disabled={saving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Link>
|
|
||||||
<Button type="submit" disabled={saving}>
|
|
||||||
{saving ? "Creating..." : "Create field"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
} from "@/api/generated/org-custom-fields/org-custom-fields";
|
||||||
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
import { CustomFieldsTable } from "@/components/custom-fields/CustomFieldsTable";
|
import { CustomFieldsTable } from "@/components/custom-fields/CustomFieldsTable";
|
||||||
|
import { extractApiErrorMessage } from "@/components/custom-fields/custom-field-form-utils";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
@@ -25,12 +26,6 @@ import { useUrlSorting } from "@/lib/use-url-sorting";
|
|||||||
|
|
||||||
const CUSTOM_FIELD_SORTABLE_COLUMNS = ["field_key", "required", "updated_at"];
|
const CUSTOM_FIELD_SORTABLE_COLUMNS = ["field_key", "required", "updated_at"];
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
|
||||||
if (error instanceof ApiError) return error.message || fallback;
|
|
||||||
if (error instanceof Error) return error.message || fallback;
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CustomFieldsPage() {
|
export default function CustomFieldsPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
@@ -150,7 +145,7 @@ export default function CustomFieldsPage() {
|
|||||||
}
|
}
|
||||||
errorMessage={
|
errorMessage={
|
||||||
deleteMutation.error
|
deleteMutation.error
|
||||||
? extractErrorMessage(
|
? extractApiErrorMessage(
|
||||||
deleteMutation.error,
|
deleteMutation.error,
|
||||||
"Unable to delete custom field.",
|
"Unable to delete custom field.",
|
||||||
)
|
)
|
||||||
|
|||||||
136
frontend/src/components/custom-fields/CustomFieldForm.test.tsx
Normal file
136
frontend/src/components/custom-fields/CustomFieldForm.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { BoardRead } from "@/api/generated/model";
|
||||||
|
import { CustomFieldForm } from "./CustomFieldForm";
|
||||||
|
import { DEFAULT_CUSTOM_FIELD_FORM_STATE } from "./custom-field-form-types";
|
||||||
|
|
||||||
|
vi.mock("next/link", () => {
|
||||||
|
type LinkProps = React.PropsWithChildren<{
|
||||||
|
href: string | { pathname?: string };
|
||||||
|
}> &
|
||||||
|
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: ({ href, children, ...props }: LinkProps) => (
|
||||||
|
<a href={typeof href === "string" ? href : "#"} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildBoard = (overrides: Partial<BoardRead> = {}): BoardRead => ({
|
||||||
|
id: "board-1",
|
||||||
|
name: "Operations",
|
||||||
|
slug: "operations",
|
||||||
|
description: "Operations board",
|
||||||
|
organization_id: "org-1",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CustomFieldForm", () => {
|
||||||
|
it("validates board selection on create", async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<CustomFieldForm
|
||||||
|
mode="create"
|
||||||
|
initialFormState={DEFAULT_CUSTOM_FIELD_FORM_STATE}
|
||||||
|
boards={[buildBoard()]}
|
||||||
|
boardsLoading={false}
|
||||||
|
boardsError={null}
|
||||||
|
isSubmitting={false}
|
||||||
|
submitLabel="Create field"
|
||||||
|
submittingLabel="Creating..."
|
||||||
|
submitErrorFallback="Failed to create custom field."
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Field key"), {
|
||||||
|
target: { value: "client_name" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), {
|
||||||
|
target: { value: "Client Name" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create field" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Select at least one board."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits normalized values on create", async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<CustomFieldForm
|
||||||
|
mode="create"
|
||||||
|
initialFormState={DEFAULT_CUSTOM_FIELD_FORM_STATE}
|
||||||
|
boards={[buildBoard()]}
|
||||||
|
boardsLoading={false}
|
||||||
|
boardsError={null}
|
||||||
|
isSubmitting={false}
|
||||||
|
submitLabel="Create field"
|
||||||
|
submittingLabel="Creating..."
|
||||||
|
submitErrorFallback="Failed to create custom field."
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Field key"), {
|
||||||
|
target: { value: " client_name " },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Label"), {
|
||||||
|
target: { value: " Client Name " },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: /operations/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create field" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
fieldKey: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
fieldType: "text",
|
||||||
|
uiVisibility: "always",
|
||||||
|
validationRegex: null,
|
||||||
|
description: null,
|
||||||
|
required: false,
|
||||||
|
defaultValue: null,
|
||||||
|
boardIds: ["board-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks field key in edit mode", () => {
|
||||||
|
render(
|
||||||
|
<CustomFieldForm
|
||||||
|
mode="edit"
|
||||||
|
initialFormState={{
|
||||||
|
...DEFAULT_CUSTOM_FIELD_FORM_STATE,
|
||||||
|
fieldKey: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
}}
|
||||||
|
initialBoardIds={["board-1"]}
|
||||||
|
boards={[buildBoard()]}
|
||||||
|
boardsLoading={false}
|
||||||
|
boardsError={null}
|
||||||
|
isSubmitting={false}
|
||||||
|
submitLabel="Save changes"
|
||||||
|
submittingLabel="Saving..."
|
||||||
|
submitErrorFallback="Failed to update custom field."
|
||||||
|
onSubmit={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue("client_name")).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Field key cannot be changed after creation."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
368
frontend/src/components/custom-fields/CustomFieldForm.tsx
Normal file
368
frontend/src/components/custom-fields/CustomFieldForm.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { type FormEvent, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { BoardRead } from "@/api/generated/model";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CUSTOM_FIELD_TYPE_OPTIONS,
|
||||||
|
CUSTOM_FIELD_VISIBILITY_OPTIONS,
|
||||||
|
type CustomFieldFormMode,
|
||||||
|
type CustomFieldFormState,
|
||||||
|
STRING_VALIDATION_FIELD_TYPES,
|
||||||
|
} from "./custom-field-form-types";
|
||||||
|
import {
|
||||||
|
extractApiErrorMessage,
|
||||||
|
filterBoardsBySearch,
|
||||||
|
normalizeCustomFieldFormInput,
|
||||||
|
type NormalizedCustomFieldFormValues,
|
||||||
|
} from "./custom-field-form-utils";
|
||||||
|
|
||||||
|
type CustomFieldFormProps = {
|
||||||
|
mode: CustomFieldFormMode;
|
||||||
|
initialFormState: CustomFieldFormState;
|
||||||
|
initialBoardIds?: string[];
|
||||||
|
boards: BoardRead[];
|
||||||
|
boardsLoading: boolean;
|
||||||
|
boardsError: string | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
submitLabel: string;
|
||||||
|
submittingLabel: string;
|
||||||
|
submitErrorFallback: string;
|
||||||
|
cancelHref?: string;
|
||||||
|
onSubmit: (values: NormalizedCustomFieldFormValues) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomFieldForm({
|
||||||
|
mode,
|
||||||
|
initialFormState,
|
||||||
|
initialBoardIds = [],
|
||||||
|
boards,
|
||||||
|
boardsLoading,
|
||||||
|
boardsError,
|
||||||
|
isSubmitting,
|
||||||
|
submitLabel,
|
||||||
|
submittingLabel,
|
||||||
|
submitErrorFallback,
|
||||||
|
cancelHref = "/custom-fields",
|
||||||
|
onSubmit,
|
||||||
|
}: CustomFieldFormProps) {
|
||||||
|
const [formState, setFormState] =
|
||||||
|
useState<CustomFieldFormState>(initialFormState);
|
||||||
|
const [boardSearch, setBoardSearch] = useState("");
|
||||||
|
const [selectedBoardIds, setSelectedBoardIds] = useState<Set<string>>(
|
||||||
|
() => new Set(initialBoardIds),
|
||||||
|
);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredBoards = useMemo(
|
||||||
|
() => filterBoardsBySearch(boards, boardSearch),
|
||||||
|
[boardSearch, boards],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
const normalized = normalizeCustomFieldFormInput({
|
||||||
|
mode,
|
||||||
|
formState,
|
||||||
|
selectedBoardIds,
|
||||||
|
});
|
||||||
|
if (normalized.value === null) {
|
||||||
|
setSubmitError(normalized.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(normalized.value);
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(extractApiErrorMessage(error, submitErrorFallback));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="max-w-3xl rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Basic configuration
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Field key
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={formState.fieldKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fieldKey: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="e.g. client_name"
|
||||||
|
readOnly={mode === "edit"}
|
||||||
|
disabled={isSubmitting || mode === "edit"}
|
||||||
|
required={mode === "create"}
|
||||||
|
/>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Field key cannot be changed after creation.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">Label</span>
|
||||||
|
<Input
|
||||||
|
value={formState.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({ ...prev, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Client name"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Field type
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={formState.fieldType}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fieldType: value as CustomFieldFormState["fieldType"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select field type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CUSTOM_FIELD_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
UI visible
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={formState.uiVisibility}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
uiVisibility: value as CustomFieldFormState["uiVisibility"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select visibility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CUSTOM_FIELD_VISIBILITY_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-4 flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formState.required}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
required: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Validation and defaults
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Validation regex
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={formState.validationRegex}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
validationRegex: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional. Example: ^[A-Z]{3}$"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
!STRING_VALIDATION_FIELD_TYPES.has(formState.fieldType)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Supported for text/date/date-time/url fields.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Default value
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
value={formState.defaultValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultValue: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
placeholder='Optional default value. For booleans use "true"/"false"; for JSON use an object or array.'
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Description
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Optional description used by agents and UI"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Board bindings
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{selectedBoardIds.size} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Input
|
||||||
|
value={boardSearch}
|
||||||
|
onChange={(event) => setBoardSearch(event.target.value)}
|
||||||
|
placeholder="Search boards..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
||||||
|
{boardsLoading ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
|
Loading boards…
|
||||||
|
</div>
|
||||||
|
) : boardsError ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-rose-700">
|
||||||
|
{boardsError}
|
||||||
|
</div>
|
||||||
|
) : filteredBoards.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
|
No boards found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-200">
|
||||||
|
{filteredBoards.map((board) => {
|
||||||
|
const checked = selectedBoardIds.has(board.id);
|
||||||
|
return (
|
||||||
|
<li key={board.id} className="px-4 py-3">
|
||||||
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedBoardIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(board.id)) {
|
||||||
|
next.delete(board.id);
|
||||||
|
} else {
|
||||||
|
next.add(board.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{board.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Required. The custom field appears on tasks in selected boards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p className="text-sm text-rose-600">{submitError}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={cancelHref}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
aria-disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? submittingLabel : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/components/tables/DataTable";
|
} from "@/components/tables/DataTable";
|
||||||
import { dateCell } from "@/components/tables/cell-formatters";
|
import { dateCell } from "@/components/tables/cell-formatters";
|
||||||
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import { formatCustomFieldDefaultValue } from "./custom-field-form-utils";
|
||||||
|
|
||||||
type CustomFieldsTableProps = {
|
type CustomFieldsTableProps = {
|
||||||
fields: TaskCustomFieldDefinitionRead[];
|
fields: TaskCustomFieldDefinitionRead[];
|
||||||
@@ -49,16 +50,6 @@ const DEFAULT_EMPTY_ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDefaultValue = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CustomFieldsTable({
|
export function CustomFieldsTable({
|
||||||
fields,
|
fields,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -131,7 +122,7 @@ export function CustomFieldsTable({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<p className="font-mono text-xs break-all text-slate-700">
|
<p className="font-mono text-xs break-all text-slate-700">
|
||||||
{formatDefaultValue(row.original.default_value) || "—"}
|
{formatCustomFieldDefaultValue(row.original.default_value) || "—"}
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
TaskCustomFieldDefinitionReadFieldType,
|
||||||
|
TaskCustomFieldDefinitionReadUiVisibility,
|
||||||
|
} from "@/api/generated/model";
|
||||||
|
|
||||||
|
export type CustomFieldType = TaskCustomFieldDefinitionReadFieldType;
|
||||||
|
export type CustomFieldVisibility = TaskCustomFieldDefinitionReadUiVisibility;
|
||||||
|
|
||||||
|
export type CustomFieldFormMode = "create" | "edit";
|
||||||
|
|
||||||
|
export type CustomFieldFormState = {
|
||||||
|
fieldKey: string;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
uiVisibility: CustomFieldVisibility;
|
||||||
|
validationRegex: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
defaultValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CUSTOM_FIELD_FORM_STATE: CustomFieldFormState = {
|
||||||
|
fieldKey: "",
|
||||||
|
label: "",
|
||||||
|
fieldType: "text",
|
||||||
|
uiVisibility: "always",
|
||||||
|
validationRegex: "",
|
||||||
|
description: "",
|
||||||
|
required: false,
|
||||||
|
defaultValue: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_TYPE_OPTIONS: ReadonlyArray<{
|
||||||
|
value: CustomFieldType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "text_long", label: "Text (long)" },
|
||||||
|
{ value: "integer", label: "Integer" },
|
||||||
|
{ value: "decimal", label: "Decimal" },
|
||||||
|
{ value: "boolean", label: "Boolean (true/false)" },
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "date_time", label: "Date & time" },
|
||||||
|
{ value: "url", label: "URL" },
|
||||||
|
{ value: "json", label: "JSON" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_VISIBILITY_OPTIONS: ReadonlyArray<{
|
||||||
|
value: CustomFieldVisibility;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "always", label: "Always" },
|
||||||
|
{ value: "if_set", label: "If set" },
|
||||||
|
{ value: "hidden", label: "Hidden" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STRING_VALIDATION_FIELD_TYPES = new Set<CustomFieldType>([
|
||||||
|
"text",
|
||||||
|
"text_long",
|
||||||
|
"date",
|
||||||
|
"date_time",
|
||||||
|
"url",
|
||||||
|
]);
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||||
|
import { DEFAULT_CUSTOM_FIELD_FORM_STATE } from "./custom-field-form-types";
|
||||||
|
import {
|
||||||
|
buildCustomFieldUpdatePayload,
|
||||||
|
createCustomFieldPayload,
|
||||||
|
formatCustomFieldDefaultValue,
|
||||||
|
normalizeCustomFieldFormInput,
|
||||||
|
parseCustomFieldDefaultValue,
|
||||||
|
} from "./custom-field-form-utils";
|
||||||
|
|
||||||
|
const buildField = (
|
||||||
|
overrides: Partial<TaskCustomFieldDefinitionRead> = {},
|
||||||
|
): TaskCustomFieldDefinitionRead => ({
|
||||||
|
id: "field-1",
|
||||||
|
organization_id: "org-1",
|
||||||
|
field_key: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
field_type: "text",
|
||||||
|
ui_visibility: "always",
|
||||||
|
board_ids: ["board-1"],
|
||||||
|
required: false,
|
||||||
|
description: "Client display name",
|
||||||
|
default_value: "Acme",
|
||||||
|
validation_regex: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseCustomFieldDefaultValue", () => {
|
||||||
|
it("parses primitive and json field types", () => {
|
||||||
|
expect(parseCustomFieldDefaultValue("integer", "42")).toEqual({
|
||||||
|
value: 42,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(parseCustomFieldDefaultValue("decimal", "-12.5")).toEqual({
|
||||||
|
value: -12.5,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(parseCustomFieldDefaultValue("boolean", "TRUE")).toEqual({
|
||||||
|
value: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(parseCustomFieldDefaultValue("json", '{"a":1}')).toEqual({
|
||||||
|
value: { a: 1 },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns validation errors for invalid defaults", () => {
|
||||||
|
expect(parseCustomFieldDefaultValue("integer", "42.5").error).toMatch(
|
||||||
|
/valid integer/i,
|
||||||
|
);
|
||||||
|
expect(parseCustomFieldDefaultValue("boolean", "yes").error).toMatch(
|
||||||
|
/true or false/i,
|
||||||
|
);
|
||||||
|
expect(parseCustomFieldDefaultValue("json", '"string"').error).toMatch(
|
||||||
|
/object or array/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeCustomFieldFormInput", () => {
|
||||||
|
it("validates create requirements", () => {
|
||||||
|
const result = normalizeCustomFieldFormInput({
|
||||||
|
mode: "create",
|
||||||
|
formState: {
|
||||||
|
...DEFAULT_CUSTOM_FIELD_FORM_STATE,
|
||||||
|
label: "Client Name",
|
||||||
|
},
|
||||||
|
selectedBoardIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.value).toBeNull();
|
||||||
|
expect(result.error).toBe("Field key is required.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and trims valid create input", () => {
|
||||||
|
const result = normalizeCustomFieldFormInput({
|
||||||
|
mode: "create",
|
||||||
|
formState: {
|
||||||
|
...DEFAULT_CUSTOM_FIELD_FORM_STATE,
|
||||||
|
fieldKey: " client_name ",
|
||||||
|
label: " Client Name ",
|
||||||
|
fieldType: "integer",
|
||||||
|
uiVisibility: "if_set",
|
||||||
|
validationRegex: "",
|
||||||
|
description: " Primary client ",
|
||||||
|
required: true,
|
||||||
|
defaultValue: " 7 ",
|
||||||
|
},
|
||||||
|
selectedBoardIds: ["board-1", "board-2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.value).toEqual({
|
||||||
|
fieldKey: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
fieldType: "integer",
|
||||||
|
uiVisibility: "if_set",
|
||||||
|
validationRegex: null,
|
||||||
|
description: "Primary client",
|
||||||
|
required: true,
|
||||||
|
defaultValue: 7,
|
||||||
|
boardIds: ["board-1", "board-2"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("payload helpers", () => {
|
||||||
|
it("builds create payload from normalized values", () => {
|
||||||
|
const payload = createCustomFieldPayload({
|
||||||
|
fieldKey: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
fieldType: "text",
|
||||||
|
uiVisibility: "always",
|
||||||
|
validationRegex: "^[A-Z]+$",
|
||||||
|
description: "Display name",
|
||||||
|
required: false,
|
||||||
|
defaultValue: "ACME",
|
||||||
|
boardIds: ["board-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toEqual({
|
||||||
|
field_key: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
field_type: "text",
|
||||||
|
ui_visibility: "always",
|
||||||
|
validation_regex: "^[A-Z]+$",
|
||||||
|
description: "Display name",
|
||||||
|
required: false,
|
||||||
|
default_value: "ACME",
|
||||||
|
board_ids: ["board-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only includes changed fields in update payload", () => {
|
||||||
|
const field = buildField();
|
||||||
|
const updates = buildCustomFieldUpdatePayload(field, {
|
||||||
|
fieldKey: "client_name",
|
||||||
|
label: "Client Name",
|
||||||
|
fieldType: "text",
|
||||||
|
uiVisibility: "always",
|
||||||
|
validationRegex: null,
|
||||||
|
description: "Client display name",
|
||||||
|
required: true,
|
||||||
|
defaultValue: "ACME",
|
||||||
|
boardIds: ["board-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updates).toEqual({
|
||||||
|
required: true,
|
||||||
|
default_value: "ACME",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCustomFieldDefaultValue", () => {
|
||||||
|
it("formats objects as minified or pretty json", () => {
|
||||||
|
expect(formatCustomFieldDefaultValue({ a: 1 })).toBe('{"a":1}');
|
||||||
|
expect(formatCustomFieldDefaultValue({ a: 1 }, { pretty: true })).toBe(
|
||||||
|
'{\n "a": 1\n}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
frontend/src/components/custom-fields/custom-field-form-utils.ts
Normal file
273
frontend/src/components/custom-fields/custom-field-form-utils.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import type {
|
||||||
|
BoardRead,
|
||||||
|
TaskCustomFieldDefinitionCreate,
|
||||||
|
TaskCustomFieldDefinitionRead,
|
||||||
|
TaskCustomFieldDefinitionUpdate,
|
||||||
|
} from "@/api/generated/model";
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CustomFieldFormMode,
|
||||||
|
type CustomFieldFormState,
|
||||||
|
type CustomFieldType,
|
||||||
|
STRING_VALIDATION_FIELD_TYPES,
|
||||||
|
} from "./custom-field-form-types";
|
||||||
|
|
||||||
|
export type ParsedDefaultValue = {
|
||||||
|
value: unknown | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedCustomFieldFormValues = {
|
||||||
|
fieldKey: string;
|
||||||
|
label: string;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
uiVisibility: CustomFieldFormState["uiVisibility"];
|
||||||
|
validationRegex: string | null;
|
||||||
|
description: string | null;
|
||||||
|
required: boolean;
|
||||||
|
defaultValue: unknown | null;
|
||||||
|
boardIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizeCustomFieldFormInputArgs = {
|
||||||
|
mode: CustomFieldFormMode;
|
||||||
|
formState: CustomFieldFormState;
|
||||||
|
selectedBoardIds: Iterable<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizeCustomFieldFormInputResult =
|
||||||
|
| { value: NormalizedCustomFieldFormValues; error: null }
|
||||||
|
| { value: null; error: string };
|
||||||
|
|
||||||
|
const canonicalJson = (value: unknown): string =>
|
||||||
|
JSON.stringify(value) ?? "undefined";
|
||||||
|
|
||||||
|
const areSortedStringArraysEqual = (
|
||||||
|
left: readonly string[],
|
||||||
|
right: readonly string[],
|
||||||
|
): boolean =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => value === right[index]);
|
||||||
|
|
||||||
|
export const parseCustomFieldDefaultValue = (
|
||||||
|
fieldType: CustomFieldType,
|
||||||
|
value: string,
|
||||||
|
): ParsedDefaultValue => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return { value: null, error: null };
|
||||||
|
|
||||||
|
if (fieldType === "text" || fieldType === "text_long") {
|
||||||
|
return { value: trimmed, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "integer") {
|
||||||
|
if (!/^-?\d+$/.test(trimmed)) {
|
||||||
|
return { value: null, error: "Default value must be a valid integer." };
|
||||||
|
}
|
||||||
|
return { value: Number.parseInt(trimmed, 10), error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "decimal") {
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||||
|
return { value: null, error: "Default value must be a valid decimal." };
|
||||||
|
}
|
||||||
|
return { value: Number.parseFloat(trimmed), error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "boolean") {
|
||||||
|
if (trimmed.toLowerCase() === "true") return { value: true, error: null };
|
||||||
|
if (trimmed.toLowerCase() === "false") return { value: false, error: null };
|
||||||
|
return { value: null, error: "Default value must be true or false." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldType === "date" ||
|
||||||
|
fieldType === "date_time" ||
|
||||||
|
fieldType === "url"
|
||||||
|
) {
|
||||||
|
return { value: trimmed, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "json") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: "Default value must be valid JSON (object or array).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { value: parsed, error: null };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: "Default value must be valid JSON (object or array).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { value: JSON.parse(trimmed), error: null };
|
||||||
|
} catch {
|
||||||
|
return { value: trimmed, error: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCustomFieldDefaultValue = (
|
||||||
|
value: unknown,
|
||||||
|
options: { pretty?: boolean } = {},
|
||||||
|
): string => {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return options.pretty
|
||||||
|
? JSON.stringify(value, null, 2)
|
||||||
|
: JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterBoardsBySearch = (
|
||||||
|
boards: BoardRead[],
|
||||||
|
query: string,
|
||||||
|
): BoardRead[] => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) return boards;
|
||||||
|
return boards.filter(
|
||||||
|
(board) =>
|
||||||
|
board.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
board.slug.toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCustomFieldFormInput = ({
|
||||||
|
mode,
|
||||||
|
formState,
|
||||||
|
selectedBoardIds,
|
||||||
|
}: NormalizeCustomFieldFormInputArgs): NormalizeCustomFieldFormInputResult => {
|
||||||
|
const trimmedFieldKey = formState.fieldKey.trim();
|
||||||
|
const trimmedLabel = formState.label.trim();
|
||||||
|
const trimmedValidationRegex = formState.validationRegex.trim();
|
||||||
|
const boardIds = Array.from(selectedBoardIds);
|
||||||
|
|
||||||
|
if (mode === "create" && !trimmedFieldKey) {
|
||||||
|
return { value: null, error: "Field key is required." };
|
||||||
|
}
|
||||||
|
if (!trimmedLabel) return { value: null, error: "Label is required." };
|
||||||
|
if (boardIds.length === 0) {
|
||||||
|
return { value: null, error: "Select at least one board." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
trimmedValidationRegex &&
|
||||||
|
!STRING_VALIDATION_FIELD_TYPES.has(formState.fieldType)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
error: "Validation regex is only supported for string field types.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDefaultValue = parseCustomFieldDefaultValue(
|
||||||
|
formState.fieldType,
|
||||||
|
formState.defaultValue,
|
||||||
|
);
|
||||||
|
if (parsedDefaultValue.error) {
|
||||||
|
return { value: null, error: parsedDefaultValue.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: {
|
||||||
|
fieldKey: trimmedFieldKey,
|
||||||
|
label: trimmedLabel,
|
||||||
|
fieldType: formState.fieldType,
|
||||||
|
uiVisibility: formState.uiVisibility,
|
||||||
|
validationRegex: trimmedValidationRegex || null,
|
||||||
|
description: formState.description.trim() || null,
|
||||||
|
required: formState.required,
|
||||||
|
defaultValue: parsedDefaultValue.value,
|
||||||
|
boardIds,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCustomFieldPayload = (
|
||||||
|
values: NormalizedCustomFieldFormValues,
|
||||||
|
): TaskCustomFieldDefinitionCreate => ({
|
||||||
|
field_key: values.fieldKey,
|
||||||
|
label: values.label,
|
||||||
|
field_type: values.fieldType,
|
||||||
|
ui_visibility: values.uiVisibility,
|
||||||
|
validation_regex: values.validationRegex,
|
||||||
|
description: values.description,
|
||||||
|
required: values.required,
|
||||||
|
default_value: values.defaultValue,
|
||||||
|
board_ids: values.boardIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildCustomFieldUpdatePayload = (
|
||||||
|
field: TaskCustomFieldDefinitionRead,
|
||||||
|
values: NormalizedCustomFieldFormValues,
|
||||||
|
): TaskCustomFieldDefinitionUpdate => {
|
||||||
|
const updates: TaskCustomFieldDefinitionUpdate = {};
|
||||||
|
|
||||||
|
if (values.label !== (field.label ?? field.field_key)) {
|
||||||
|
updates.label = values.label;
|
||||||
|
}
|
||||||
|
if (values.fieldType !== (field.field_type ?? "text")) {
|
||||||
|
updates.field_type = values.fieldType;
|
||||||
|
}
|
||||||
|
if (values.uiVisibility !== (field.ui_visibility ?? "always")) {
|
||||||
|
updates.ui_visibility = values.uiVisibility;
|
||||||
|
}
|
||||||
|
if (values.validationRegex !== (field.validation_regex ?? null)) {
|
||||||
|
updates.validation_regex = values.validationRegex;
|
||||||
|
}
|
||||||
|
if (values.description !== (field.description ?? null)) {
|
||||||
|
updates.description = values.description;
|
||||||
|
}
|
||||||
|
if (values.required !== (field.required === true)) {
|
||||||
|
updates.required = values.required;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
canonicalJson(values.defaultValue) !== canonicalJson(field.default_value)
|
||||||
|
) {
|
||||||
|
updates.default_value = values.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBoardIds = [...(field.board_ids ?? [])].sort();
|
||||||
|
const nextBoardIds = [...values.boardIds].sort();
|
||||||
|
if (!areSortedStringArraysEqual(currentBoardIds, nextBoardIds)) {
|
||||||
|
updates.board_ids = values.boardIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveFormStateFromCustomField = (
|
||||||
|
field: TaskCustomFieldDefinitionRead,
|
||||||
|
): CustomFieldFormState => ({
|
||||||
|
fieldKey: field.field_key,
|
||||||
|
label: field.label ?? field.field_key,
|
||||||
|
fieldType: field.field_type ?? "text",
|
||||||
|
uiVisibility: field.ui_visibility ?? "always",
|
||||||
|
validationRegex: field.validation_regex ?? "",
|
||||||
|
description: field.description ?? "",
|
||||||
|
required: field.required === true,
|
||||||
|
defaultValue: formatCustomFieldDefaultValue(field.default_value, {
|
||||||
|
pretty: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const extractApiErrorMessage = (
|
||||||
|
error: unknown,
|
||||||
|
fallback: string,
|
||||||
|
): string => {
|
||||||
|
if (error instanceof ApiError) return error.message || fallback;
|
||||||
|
if (error instanceof Error) return error.message || fallback;
|
||||||
|
const detail = (error as { detail?: unknown } | null | undefined)?.detail;
|
||||||
|
if (detail) return String(detail);
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user