feat: implement custom field form and utility functions for managing custom fields
This commit is contained in:
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";
|
||||
import { dateCell } from "@/components/tables/cell-formatters";
|
||||
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||
import { formatCustomFieldDefaultValue } from "./custom-field-form-utils";
|
||||
|
||||
type CustomFieldsTableProps = {
|
||||
fields: TaskCustomFieldDefinitionRead[];
|
||||
@@ -49,16 +50,6 @@ const DEFAULT_EMPTY_ICON = (
|
||||
</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({
|
||||
fields,
|
||||
isLoading = false,
|
||||
@@ -131,7 +122,7 @@ export function CustomFieldsTable({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<p className="font-mono text-xs break-all text-slate-700">
|
||||
{formatDefaultValue(row.original.default_value) || "—"}
|
||||
{formatCustomFieldDefaultValue(row.original.default_value) || "—"}
|
||||
</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