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")}
+