Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/tests/test_role_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 26 additions & 3 deletions frontend/src/__tests__/CustomFieldInput.test.jsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(<CustomFieldsForm definitions={definitions} values={{}} onChange={onChange} />)
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(<CustomFieldsForm definitions={definitions} values={{}} onChange={onChange} />)
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(<CustomFieldsForm definitions={definitions} values={{}} onChange={vi.fn()} />)
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()]
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/__tests__/customFieldDefaults.test.js
Original file line number Diff line number Diff line change
@@ -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")
})
})
38 changes: 30 additions & 8 deletions frontend/src/components/customField/CustomFieldInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,8 @@ function FieldControl({ definition, value, onChange }) {
/>
)
case "bool":
return (
<input
type="checkbox"
checked={!!value}
onChange={event => onChange(event.target.checked)}
className="h-4 w-4"
/>
)
return <BoolControl definition={definition} value={value} onChange={onChange} />

case "date":
return (
<Input
Expand All @@ -98,6 +92,34 @@ function FieldControl({ definition, value, onChange }) {
}
}

const BOOL_TRUE = "__true__"
const BOOL_FALSE = "__false__"

function BoolControl({ definition, value, onChange }) {
const { t } = useTranslation("customFields")
const selected = value === true ? BOOL_TRUE : value === false ? BOOL_FALSE : NONE_VALUE
const hasDefault = definition.default_value === true || definition.default_value === false
const unsetLabel = hasDefault
? t("bool.unsetWithDefault", { value: definition.default_value ? t("bool.yes") : t("bool.no") })
: t("bool.unset")
const pickValue = next => {
if (next === BOOL_TRUE) onChange(true)
else if (next === BOOL_FALSE) onChange(false)
else onChange(undefined)
}
return (
<Select value={selected} onValueChange={pickValue}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_VALUE}>{unsetLabel}</SelectItem>
<SelectItem value={BOOL_TRUE}>{t("bool.yes")}</SelectItem>
<SelectItem value={BOOL_FALSE}>{t("bool.no")}</SelectItem>
</SelectContent>
</Select>
)
}


function SingleSelectControl({ definition, value, onChange }) {
const { t } = useTranslation("customFields")
const selected = value ?? NONE_VALUE
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/components/customField/CustomFieldsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -332,9 +333,15 @@ function CreateFieldDialog({ open, onOpenChange, projectId, defaultEntityType, o
<Input
value={defaultValue}
onChange={event => setDefaultValue(event.target.value)}
placeholder={isSelect ? "value" : ""}
placeholder={defaultValuePlaceholder(type)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("fields.defaultHint")}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{type === "multi_select"
? t("fields.defaultHintMultiSelect")
: type === "bool"
? t("fields.defaultHintBool")
: t("fields.defaultHint")}
</p>
</div>
<label className="flex items-center gap-2 text-sm">
<input
Expand All @@ -358,6 +365,14 @@ function CreateFieldDialog({ open, onOpenChange, projectId, defaultEntityType, o
)
}

function defaultValuePlaceholder(type) {
if (type === "multi_select") return "p0|p1|p2"
if (type === "bool") return "true / false"
if (type === "single_select") return "value"
return ""
}


function parseOptions(text) {
return text
.split("\n")
Expand All @@ -369,9 +384,3 @@ function parseOptions(text) {
})
}

function coerceDefault(type, raw) {
if (type === "number") return Number(raw)
if (type === "bool") return raw === "true" || raw === "1"
if (type === "multi_select") return raw.split(",").map(item => item.trim()).filter(Boolean)
return raw
}
10 changes: 9 additions & 1 deletion frontend/src/i18n/locales/en/customFields.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"optionsHint": "One per line — value | label",
"required": "Required on create",
"default": "Default value",
"defaultHint": "Pre-fills new records. Required for executions with required=true."
"defaultHint": "Pre-fills new records. Required for executions with required=true.",
"defaultHintBool": "Pre-fills new records. Accepts true/false/yes/no/1/0 (case-insensitive).",
"defaultHintMultiSelect": "Pre-fills new records. Use | to separate values when they contain commas."
},
"actions": {
"save": "Save",
Expand Down Expand Up @@ -70,5 +72,11 @@
"columns": {
"title": "Columns",
"subtitle": "Show custom fields"
},
"bool": {
"yes": "Yes",
"no": "No",
"unset": "Not set",
"unsetWithDefault": "Default ({{value}})"
}
}
10 changes: 9 additions & 1 deletion frontend/src/i18n/locales/es/customFields.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"optionsHint": "Una por línea — valor | etiqueta",
"required": "Obligatorio al crear",
"default": "Valor por defecto",
"defaultHint": "Pre-rellena los nuevos registros. Obligatorio para ejecuciones con required=true."
"defaultHint": "Pre-rellena los nuevos registros. Obligatorio para ejecuciones con required=true.",
"defaultHintBool": "Pre-rellena los nuevos registros. Acepta true/false/sí/no/1/0 (sin distinguir mayúsculas).",
"defaultHintMultiSelect": "Pre-rellena los nuevos registros. Usa | para separar valores cuando contienen comas."
},
"actions": {
"save": "Guardar",
Expand Down Expand Up @@ -70,5 +72,11 @@
"columns": {
"title": "Columnas",
"subtitle": "Mostrar campos personalizados"
},
"bool": {
"yes": "Sí",
"no": "No",
"unset": "Sin establecer",
"unsetWithDefault": "Por defecto ({{value}})"
}
}
28 changes: 28 additions & 0 deletions frontend/src/lib/customFieldDefaults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Coerce admin-typed "default value" strings from the custom-field
// dialog into the typed payload the API expects. Lives apart from
// `CustomFieldsPanel.jsx` so the rules are unit-testable and a single
// fix covers every entry point.

const TRUE_STRINGS = new Set(["true", "1", "yes", "y", "on", "sí", "si", "s"])
const FALSE_STRINGS = new Set(["false", "0", "no", "n", "off"])
const MULTI_SELECT_SEPARATOR = "|"


export function coerceDefault(type, raw) {
if (type === "number") return Number(raw)
if (type === "bool") return coerceBool(raw)
if (type === "multi_select") {
return String(raw)
.split(MULTI_SELECT_SEPARATOR)
.map(item => item.trim())
.filter(Boolean)
}
return raw
}


function coerceBool(raw) {
const normalized = String(raw).toLowerCase().trim()
if (FALSE_STRINGS.has(normalized)) return false
return TRUE_STRINGS.has(normalized)
}
Loading