"use client"; export const dynamic = "force-dynamic"; import { type FormEvent, useMemo, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/auth/clerk"; import { useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/api/mutator"; import { type listBoardsApiV1BoardsGetResponse, useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; import { type listOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetResponse, getListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGetQueryKey, useListOrgCustomFieldsApiV1OrganizationsMeCustomFieldsGet, useUpdateOrgCustomFieldApiV1OrganizationsMeCustomFieldsTaskCustomFieldDefinitionIdPatch, } from "@/api/generated/org-custom-fields/org-custom-fields"; import type { BoardRead, TaskCustomFieldDefinitionRead, TaskCustomFieldDefinitionUpdate, } from "@/api/generated/model"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; 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 { 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; }; 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(() => ({ 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>( () => new Set(field.board_ids ?? []), ); const [saveError, setSaveError] = useState(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) => { 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 (

Basic configuration

Validation and defaults