diff --git a/backend/tests/test_role_matrix.py b/backend/tests/test_role_matrix.py index 622a63a..8ec7eb3 100644 --- a/backend/tests/test_role_matrix.py +++ b/backend/tests/test_role_matrix.py @@ -2,8 +2,7 @@ Catches regressions where a writer-required endpoint silently accepts viewer or outsider credentials. Admin is excluded on purpose: admins -bypass every check, so admin coverage hides role bugs (see TG2 in -PRE-RELEASE_V1.0.md). +bypass every check, so admin coverage hides role bugs. """ import pytest diff --git a/frontend/src/__tests__/CustomFieldInput.test.jsx b/frontend/src/__tests__/CustomFieldInput.test.jsx index 0685185..00b3cf9 100644 --- a/frontend/src/__tests__/CustomFieldInput.test.jsx +++ b/frontend/src/__tests__/CustomFieldInput.test.jsx @@ -1,6 +1,6 @@ import { useState } from "react" import { describe, it, expect, vi } from "vitest" -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import "../i18n" @@ -50,16 +50,39 @@ describe("CustomFieldsForm", () => { expect(onChange).toHaveBeenLastCalledWith({ priority: "P0" }) }) - it("toggles boolean fields via checkbox", async () => { + it("emits true when the user picks Yes from the tri-state bool select", async () => { const onChange = vi.fn() const definitions = [buildDefinition({ key: "is_blocker", label: "Blocker", type: "bool" })] render() - await userEvent.click(screen.getByRole("checkbox")) + fireEvent.click(screen.getByRole("combobox")) + fireEvent.click(screen.getByRole("option", { name: "Yes" })) expect(onChange).toHaveBeenLastCalledWith({ is_blocker: true }) }) + it("emits false explicitly when the user picks No (distinguishing it from unset)", async () => { + const onChange = vi.fn() + const definitions = [buildDefinition({ key: "is_blocker", label: "Blocker", type: "bool" })] + + render() + fireEvent.click(screen.getByRole("combobox")) + fireEvent.click(screen.getByRole("option", { name: "No" })) + + expect(onChange).toHaveBeenLastCalledWith({ is_blocker: false }) + }) + + it("surfaces the declared default value inside the unset option label", async () => { + const definitions = [buildDefinition({ + key: "is_blocker", label: "Blocker", type: "bool", default_value: false, + })] + + render() + fireEvent.click(screen.getByRole("combobox")) + + expect(screen.getByRole("option", { name: /Default \(No\)/i })).toBeInTheDocument() + }) + it("removes the key when the value becomes empty", async () => { const onChange = vi.fn() const definitions = [buildDefinition()] diff --git a/frontend/src/__tests__/customFieldDefaults.test.js b/frontend/src/__tests__/customFieldDefaults.test.js new file mode 100644 index 0000000..b1758ac --- /dev/null +++ b/frontend/src/__tests__/customFieldDefaults.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest" +import { coerceDefault } from "../lib/customFieldDefaults" + +describe("coerceDefault bool", () => { + it.each(["true", "True", "TRUE", " true ", "1", "yes", "y", "on", "sí", "Si", "S"])( + "treats %p as true", input => { + expect(coerceDefault("bool", input)).toBe(true) + }, + ) + + it.each(["false", "False", "FALSE", "0", "no", "N", "off", " no "])( + "treats %p as false", input => { + expect(coerceDefault("bool", input)).toBe(false) + }, + ) + + it("treats anything unrecognized as false", () => { + expect(coerceDefault("bool", "maybe")).toBe(false) + expect(coerceDefault("bool", "")).toBe(false) + }) +}) + +describe("coerceDefault multi_select", () => { + it("splits on the pipe character so commas inside values survive", () => { + expect(coerceDefault("multi_select", "web, mobile|api|desktop")) + .toEqual(["web, mobile", "api", "desktop"]) + }) + + it("trims whitespace and drops empty segments", () => { + expect(coerceDefault("multi_select", " p0 | | p1 ")) + .toEqual(["p0", "p1"]) + }) + + it("returns a single-element array for a value without separators", () => { + expect(coerceDefault("multi_select", "p0")).toEqual(["p0"]) + }) +}) + +describe("coerceDefault other types", () => { + it("parses number defaults", () => { + expect(coerceDefault("number", "42")).toBe(42) + }) + + it("returns text defaults verbatim", () => { + expect(coerceDefault("text", "anything")) + .toBe("anything") + }) +}) diff --git a/frontend/src/components/customField/CustomFieldInput.jsx b/frontend/src/components/customField/CustomFieldInput.jsx index d39327b..4a902f0 100644 --- a/frontend/src/components/customField/CustomFieldInput.jsx +++ b/frontend/src/components/customField/CustomFieldInput.jsx @@ -73,14 +73,8 @@ function FieldControl({ definition, value, onChange }) { /> ) case "bool": - return ( - onChange(event.target.checked)} - className="h-4 w-4" - /> - ) + return + case "date": return ( { + if (next === BOOL_TRUE) onChange(true) + else if (next === BOOL_FALSE) onChange(false) + else onChange(undefined) + } + return ( + + ) +} + + function SingleSelectControl({ definition, value, onChange }) { const { t } = useTranslation("customFields") const selected = value ?? NONE_VALUE diff --git a/frontend/src/components/customField/CustomFieldsPanel.jsx b/frontend/src/components/customField/CustomFieldsPanel.jsx index 33495c4..57675ec 100644 --- a/frontend/src/components/customField/CustomFieldsPanel.jsx +++ b/frontend/src/components/customField/CustomFieldsPanel.jsx @@ -14,6 +14,7 @@ import { useReorderCustomFields, useUnarchiveCustomField, } from "../../hooks/useCustomFields" +import { coerceDefault } from "../../lib/customFieldDefaults" import { Button } from "../ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog" import { EmptyState } from "../ui/empty-state" @@ -332,9 +333,15 @@ function CreateFieldDialog({ open, onOpenChange, projectId, defaultEntityType, o setDefaultValue(event.target.value)} - placeholder={isSelect ? "value" : ""} + placeholder={defaultValuePlaceholder(type)} /> -

{t("fields.defaultHint")}

+

+ {type === "multi_select" + ? t("fields.defaultHintMultiSelect") + : type === "bool" + ? t("fields.defaultHintBool") + : t("fields.defaultHint")} +