From 06bc84b1ee48d827b99e330674c2ac6dbc0438b7 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:25:14 +0200 Subject: [PATCH 01/12] feat: read and edit logic rules as sentences Each rule reads as a sentence: the question by number, the operator in words, the answer in quotes, the target as a chip. Edit turns it into pickers: answers come from the source question, operators fit its type, and a rule whose question was deleted shows in red and can't be saved. A thin gutter line runs from the open question to where it jumps. Co-Authored-By: Claude Opus 5.5 --- resources/js/app.ts | 2 + .../Factory/Document/Rules/BlockRules.test.ts | 344 ++++++++++++++++ .../Factory/Document/Rules/BlockRules.vue | 49 +++ .../Document/Rules/DeletedQuestion.vue | 11 + .../Factory/Document/Rules/RuleEditor.vue | 378 ++++++++++++++++++ .../Factory/Document/Rules/RuleGutter.test.ts | 58 +++ .../Factory/Document/Rules/RuleGutter.vue | 84 ++++ .../Factory/Document/Rules/RuleLine.vue | 138 +++++++ .../Factory/Document/Rules/ruleEditing.ts | 88 ++++ .../Factory/Document/Rules/ruleText.test.ts | 79 ++++ .../Factory/Document/Rules/ruleText.ts | 119 ++++++ 11 files changed, 1350 insertions(+) create mode 100644 resources/js/components/Factory/Document/Rules/BlockRules.test.ts create mode 100644 resources/js/components/Factory/Document/Rules/BlockRules.vue create mode 100644 resources/js/components/Factory/Document/Rules/DeletedQuestion.vue create mode 100644 resources/js/components/Factory/Document/Rules/RuleEditor.vue create mode 100644 resources/js/components/Factory/Document/Rules/RuleGutter.test.ts create mode 100644 resources/js/components/Factory/Document/Rules/RuleGutter.vue create mode 100644 resources/js/components/Factory/Document/Rules/RuleLine.vue create mode 100644 resources/js/components/Factory/Document/Rules/ruleEditing.ts create mode 100644 resources/js/components/Factory/Document/Rules/ruleText.test.ts create mode 100644 resources/js/components/Factory/Document/Rules/ruleText.ts diff --git a/resources/js/app.ts b/resources/js/app.ts index 4fcc4e6a..75ca8e12 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -65,6 +65,7 @@ import { faTextSlash, faTrash, faTree, + faTriangleExclamation, faTrophy, faUnderline, faUser, @@ -129,6 +130,7 @@ library.add( faTextSlash, faTrash, faTree, + faTriangleExclamation, faTrophy, faUnderline, faUser, diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts new file mode 100644 index 00000000..b5558eae --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts @@ -0,0 +1,344 @@ +import { mount, VueWrapper } from "@vue/test-utils"; +import { D9Select } from "@deck9/ui"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm, useLogic } from "@/stores"; +import { questionNumbers } from "../questionSummary"; +import BlockRules from "./BlockRules.vue"; +import { addRule, editing } from "./ruleEditing"; + +const choices = (...labels: string[]) => + labels.map((label, sequence) => ({ + type: "button", + is_editable: true, + label, + sequence, + })); + +const question = ( + uuid: string, + type: string, + text: string, + extra: Partial = {}, +) => + ({ + id: uuid.length, + uuid, + type, + message: `

${text}

`, + parent_block: null, + interactions: [], + logics: [], + ...extra, + }) as unknown as FormBlockModel; + +const rule = ( + action: FormBlockLogic["action"], + conditions: Partial[], + target: string | null = null, +): FormBlockLogic => ({ + id: 1, + uuid: "rule-1", + form_block_id: 1, + name: "Rule #1", + action, + action_payload: target, + evaluate: action === "goto" ? "after" : "before", + conditions: conditions.map((condition) => ({ + operator: "equals", + value: "", + chainOperator: "and", + ...condition, + })) as FormBlockLogicCondition[], +}); + +let blocks: FormBlockModel[]; +const find = (uuid: string) => + useForm().blocks!.find((block) => block.uuid === uuid)!; + +beforeEach(() => { + blocks = [ + question("q-come", "radio", "Will you come?", { + interactions: choices("Yes", "No, not this time") as never, + }), + question("q-days", "checkbox", "Which days?", { + interactions: choices("Mon", "Tue") as never, + }), + question("q-rate", "rating", "How was it?"), + question("q-date", "date", "When?"), + question("q-num", "input-number", "How many?"), + question("q-name", "input-short", "Your name?"), + question("g-1", "group", "", { title: "Feedback" }), + question("q-diff", "input-long", "What should we do differently?", { + parent_block: "g-1", + }), + ]; + useForm().blocks = blocks; + useLogic().$reset(); + editing.value = null; +}); + +const mountRules = (uuid: string) => + mount(BlockRules, { + props: { block: find(uuid), numbers: questionNumbers(blocks) }, + attachTo: document.body, + }); + +const text = (wrapper: VueWrapper) => wrapper.text().replace(/\s+/g, " "); + +const select = (wrapper: VueWrapper, name: string) => + wrapper + .findAllComponents(D9Select) + .find((field) => String(field.props("id")).endsWith(name))!; + +const optionLabels = (wrapper: VueWrapper, name: string) => + (select(wrapper, name).props("options") as { label: string }[]).map( + (option) => option.label, + ); + +const pick = async (wrapper: VueWrapper, name: string, label: string) => { + const field = select(wrapper, name); + const option = (field.props("options") as { label: string }[]).find( + (item) => item.label === label, + ); + field.vm.$emit("update:modelValue", option); + await wrapper.vm.$nextTick(); +}; + +const button = (wrapper: VueWrapper, label: string) => + wrapper.findAll("button").find((item) => item.text() === label)!; + +describe("a rule under its question", () => { + it("reads a jump on the question's own answer as a short sentence", () => { + find("q-come").logics = [ + rule( + "goto", + [{ source: "q-come", value: "No, not this time" }], + "q-diff", + ), + ]; + + expect(text(mountRules("q-come"))).toContain( + "If “No, not this time”, go to 7 · What should we do differently?", + ); + }); + + it("names another question by its number, never its id", () => { + find("q-name").logics = [ + rule( + "goto", + [ + { source: "q-come", operator: "equalsNot", value: "Yes" }, + { source: "q-days", operator: "contains", value: "Mon" }, + ], + "q-diff", + ), + ]; + + const sentence = text(mountRules("q-name")); + + expect(sentence).toContain( + "If question 1 is not “Yes” and question 2 includes “Mon”, go to 7 · What should", + ); + expect(sentence).not.toContain("q-come"); + }); + + it("says when a group is hidden", () => { + find("g-1").logics = [ + rule("hide", [{ source: "q-come", value: "No, not this time" }]), + ]; + + expect(text(mountRules("g-1"))).toContain( + "Hidden if question 1 is “No, not this time”", + ); + }); + + it("ignores an old target on a hide rule", () => { + find("g-1").logics = [ + rule("show", [{ source: "q-come", value: "Yes" }], "q-name"), + ]; + + const sentence = text(mountRules("g-1")); + + expect(sentence).toContain("Shown only if question 1 is “Yes”"); + expect(sentence).not.toContain("go to"); + }); + + it("shows a rule whose question was deleted in red", () => { + find("q-name").logics = [ + rule("goto", [{ source: "gone", value: "Yes" }], "gone-too"), + ]; + + const wrapper = mountRules("q-name"); + const broken = wrapper.findAll(".text-red-700"); + + expect(broken).toHaveLength(2); + expect(broken[0].text()).toContain("Deleted question"); + }); +}); + +describe("editing a rule in place", () => { + beforeEach(() => { + find("q-name").logics = [ + rule("goto", [{ source: "q-come", value: "Yes" }], "q-diff"), + ]; + }); + + const edit = async () => { + const wrapper = mountRules("q-name"); + await button(wrapper, "Edit").trigger("click"); + return wrapper; + }; + + it("turns the sentence into pickers", async () => { + const wrapper = await edit(); + + expect(text(wrapper)).not.toContain("If question 1 is “Yes”"); + expect(optionLabels(wrapper, "-0-source")).toEqual([ + "1 · Will you come?", + "2 · Which days?", + "3 · How was it?", + "4 · When?", + "5 · How many?", + "6 · Your name?", + ]); + expect(optionLabels(wrapper, "-target")).toEqual([ + "7 · What should we do differently?", + ]); + }); + + it("offers the choices of a single choice question", async () => { + const wrapper = await edit(); + + expect(optionLabels(wrapper, "-0-operator")).toEqual(["is", "is not"]); + expect(optionLabels(wrapper, "-0-answer")).toEqual([ + "Yes", + "No, not this time", + ]); + }); + + it("offers the range of a rating", async () => { + const wrapper = await edit(); + await pick(wrapper, "-0-source", "3 · How was it?"); + + expect(optionLabels(wrapper, "-0-operator")).toContain("is more than"); + expect(optionLabels(wrapper, "-0-answer")).toEqual([ + "1", + "2", + "3", + "4", + "5", + ]); + }); + + it.each([ + ["4 · When?", "date"], + ["5 · How many?", "number"], + ])("gives %s a %s field", async (source, type) => { + const wrapper = await edit(); + await pick(wrapper, "-0-source", source); + + expect(wrapper.find(`input[type=${type}]`).exists()).toBe(true); + }); + + it("keeps a text field for text, with a case hint", async () => { + const wrapper = await edit(); + await pick(wrapper, "-0-source", "6 · Your name?"); + + expect(optionLabels(wrapper, "-0-operator")).toHaveLength(6); + expect(wrapper.find("input[type=text]").exists()).toBe(true); + expect(text(wrapper)).toContain("Upper and lower case must match."); + }); + + it("can't save a rule whose question was deleted until it's fixed", async () => { + find("q-name").logics = [ + rule("goto", [{ source: "gone", value: "Yes" }], "q-diff"), + ]; + const wrapper = await edit(); + const save = () => button(wrapper, "Save rule"); + + expect(text(wrapper)).toContain("Deleted question"); + expect(text(wrapper)).toContain( + "The question this rule used was deleted. Pick another one.", + ); + expect(save().attributes("disabled")).toBeDefined(); + + await save().trigger("click"); + expect(useLogic().saveSingleRule).not.toHaveBeenCalled(); + + await pick(wrapper, "-0-source", "1 · Will you come?"); + expect(save().attributes("disabled")).toBeUndefined(); + }); + + it("saves the picked answer and shows the sentence again", async () => { + const logic = useLogic(); + vi.mocked(logic.saveSingleRule).mockImplementation( + async (saved) => saved, + ); + const wrapper = await edit(); + + await pick(wrapper, "-0-answer", "No, not this time"); + await button(wrapper, "Save rule").trigger("click"); + await vi.waitFor(() => expect(editing.value).toBeNull()); + + expect(logic.saveSingleRule).toHaveBeenCalledWith( + expect.objectContaining({ + action: "goto", + action_payload: "q-diff", + evaluate: "after", + conditions: [ + expect.objectContaining({ + source: "q-come", + operator: "equals", + value: "No, not this time", + }), + ], + }), + ); + expect(text(wrapper)).toContain("If question 1 is “No, not this time”"); + }); + + it("shows why the server refused a rule", async () => { + vi.mocked(useLogic().saveSingleRule).mockRejectedValue({ + response: { + status: 422, + data: { errors: { action_payload: ["Pick a question."] } }, + }, + }); + const wrapper = await edit(); + + await button(wrapper, "Save rule").trigger("click"); + + await vi.waitFor(() => + expect(wrapper.find("[role=alert]").text()).toContain( + "Pick a question.", + ), + ); + expect(editing.value).not.toBeNull(); + }); +}); + +describe("adding a rule", () => { + it("starts a jump on the question's own answer, and Cancel drops it", async () => { + const owner = find("q-come"); + addRule(owner); + const wrapper = mountRules("q-come"); + await wrapper.vm.$nextTick(); + + expect(optionLabels(wrapper, "-0-source")).toEqual([ + "1 · Will you come?", + ]); + expect(select(wrapper, "-0-source").props("modelValue")).toMatchObject({ + id: "q-come", + }); + + await button(wrapper, "Cancel").trigger("click"); + expect(owner.logics).toHaveLength(0); + }); + + it("hides a group by default", () => { + const group = find("g-1"); + addRule(group); + + expect(group.logics?.[0].action).toBe("hide"); + }); +}); diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.vue b/resources/js/components/Factory/Document/Rules/BlockRules.vue new file mode 100644 index 00000000..e97be7b6 --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/BlockRules.vue @@ -0,0 +1,49 @@ + + + diff --git a/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue b/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue new file mode 100644 index 00000000..40da58ec --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/DeletedQuestion.vue @@ -0,0 +1,11 @@ + + + diff --git a/resources/js/components/Factory/Document/Rules/RuleEditor.vue b/resources/js/components/Factory/Document/Rules/RuleEditor.vue new file mode 100644 index 00000000..74201355 --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/RuleEditor.vue @@ -0,0 +1,378 @@ + + + diff --git a/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts b/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts new file mode 100644 index 00000000..4aa8b31d --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/RuleGutter.test.ts @@ -0,0 +1,58 @@ +import { mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm, useWorkbench } from "@/stores"; +import RuleGutter from "./RuleGutter.vue"; + +const block = (uuid: string, logics: Partial[] = []) => + ({ uuid, type: "radio", logics }) as unknown as FormBlockModel; + +const row = (uuid: string, top: number) => { + const element = document.createElement("div"); + element.id = `row-${uuid}`; + element.getBoundingClientRect = () => ({ top }) as DOMRect; + document.body.append(element); +}; + +const lines = () => document.querySelectorAll("[data-rule-line]"); + +describe("the gutter line of the open question", () => { + beforeEach(() => { + row("q-1", 100); + row("q-2", 200); + row("q-3", 300); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + const open = (source: FormBlockModel) => { + useForm().blocks = [source, block("q-2"), block("q-3")]; + useWorkbench().block = source; + return mount(RuleGutter, { attachTo: document.body }); + }; + + it("runs from the question down to where its rule jumps", async () => { + open(block("q-1", [{ action: "goto", action_payload: "q-3" }])); + + await vi.waitFor(() => expect(lines()).toHaveLength(1)); + const line = lines()[0] as HTMLElement; + + expect(line.style.top).toBe("124px"); + expect(line.style.height).toBe("200px"); + }); + + it("draws nothing for hide rules, deleted targets or jumps back up", async () => { + const wrapper = open( + block("q-2", [ + { action: "hide", action_payload: "q-3" }, + { action: "goto", action_payload: "gone" }, + { action: "goto", action_payload: "q-1" }, + ]), + ); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + expect(lines()).toHaveLength(0); + }); +}); diff --git a/resources/js/components/Factory/Document/Rules/RuleGutter.vue b/resources/js/components/Factory/Document/Rules/RuleGutter.vue new file mode 100644 index 00000000..7a3ac17f --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/RuleGutter.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/js/components/Factory/Document/Rules/RuleLine.vue b/resources/js/components/Factory/Document/Rules/RuleLine.vue new file mode 100644 index 00000000..59b84a45 --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/RuleLine.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/js/components/Factory/Document/Rules/ruleEditing.ts b/resources/js/components/Factory/Document/Rules/ruleEditing.ts new file mode 100644 index 00000000..df019ead --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/ruleEditing.ts @@ -0,0 +1,88 @@ +import { ref } from "vue"; +import { useForm, useWorkbench } from "@/stores"; +import { answerKind, operatorsFor } from "./ruleText"; + +// the one rule that shows as pickers: its block and its place in the block's rules +export const editing = ref<{ block: string; index: number } | null>(null); + +// where the cursor goes back to when a new rule is dropped +let opener: HTMLElement | null = null; + +// ends editing; a new rule that was never saved goes with it +export function stopEditing() { + const current = editing.value; + editing.value = null; + + const block = useForm().blocks?.find( + (item) => item.uuid === current?.block, + ); + const rule = current && block?.logics?.[current.index]; + + if (rule && rule.id === undefined) { + block.logics?.splice(current.index, 1); + } +} + +function start(block: FormBlockModel, index: number) { + const workbench = useWorkbench(); + + opener = document.activeElement as HTMLElement | null; + editing.value = { block: block.uuid, index }; + + if (workbench.block?.id !== block.id) { + workbench.putOnWorkbench(block); + } +} + +export function editRule(block: FormBlockModel, index: number) { + stopEditing(); + start(block, index); +} + +// a question jumps on its own answer; a group hides +export function addRule(block: FormBlockModel) { + stopEditing(); + + const isGroup = block.type === "group"; + const source = !isGroup && answerKind(block) ? block : null; + const logics = block.logics ?? []; + + block.logics = [ + ...logics, + { + form_block_id: block.id, + name: `Rule #${logics.length + 1}`, + action: isGroup ? "hide" : "goto", + action_payload: null, + evaluate: isGroup ? "before" : "after", + conditions: [ + { + source: source?.uuid, + operator: operatorsFor(source)[0].id, + value: "", + chainOperator: "and", + }, + ], + }, + ]; + + start(block, logics.length); +} + +// the saved rule takes the place of the one being edited +export function keepSaved(rule: FormBlockLogic) { + const current = editing.value; + const block = useForm().blocks?.find( + (item) => item.uuid === current?.block, + ); + + if (current && block?.logics) { + block.logics[current.index] = rule; + } + + editing.value = null; +} + +export function returnFocus(fallback: HTMLElement | null) { + (fallback ?? (opener?.isConnected ? opener : null))?.focus(); +} diff --git a/resources/js/components/Factory/Document/Rules/ruleText.test.ts b/resources/js/components/Factory/Document/Rules/ruleText.test.ts new file mode 100644 index 00000000..23bcbc5b --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/ruleText.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { answerOptions, operatorLabel, operatorsFor } from "./ruleText"; + +const block = (type: string, interactions: unknown[] = []) => + ({ uuid: type, type, interactions }) as unknown as FormBlockModel; + +const labels = (type: string) => operatorsFor(block(type)).map((o) => o.label); + +describe("rule operators", () => { + it("offer is and is not for a single choice", () => { + expect(labels("radio")).toEqual(["is", "is not"]); + }); + + it("offer includes and doesn't include for checkboxes", () => { + expect(labels("checkbox")).toEqual(["includes", "doesn't include"]); + }); + + it.each(["input-number", "rating", "scale", "date"])( + "offer comparisons for %s", + (type) => { + expect(labels(type)).toEqual([ + "is", + "is not", + "is less than", + "is more than", + ]); + }, + ); + + it("offer all six for text", () => { + expect(labels("input-short")).toEqual([ + "is", + "is not", + "contains", + "doesn't contain", + "is less than", + "is more than", + ]); + }); + + it("name a stored operator in words", () => { + expect(operatorLabel("equals", block("radio"))).toBe("is"); + expect(operatorLabel("contains", block("checkbox"))).toBe("includes"); + expect(operatorLabel("isGreaterThan", undefined)).toBe("is more than"); + }); +}); + +describe("rule answers", () => { + it("are the choices of a choice question, in order", () => { + const question = block("radio", [ + { type: "button", is_editable: true, label: "No", sequence: 2 }, + { type: "button", is_editable: true, label: "Yes", sequence: 1 }, + { type: "button", is_editable: false, label: "Other", sequence: 3 }, + { type: "button", is_editable: true, label: "", sequence: 4 }, + ]); + + expect(answerOptions(question)).toEqual(["Yes", "No"]); + }); + + it("are the range of a rating or scale", () => { + const scale = block("scale", [ + { type: "range", options: { start: 0, end: 3 } }, + ]); + + expect(answerOptions(scale)).toEqual(["0", "1", "2", "3"]); + expect(answerOptions(block("rating"))).toEqual([ + "1", + "2", + "3", + "4", + "5", + ]); + }); + + it("are free for text, number and date", () => { + expect(answerOptions(block("input-short"))).toEqual([]); + expect(answerOptions(block("date"))).toEqual([]); + }); +}); diff --git a/resources/js/components/Factory/Document/Rules/ruleText.ts b/resources/js/components/Factory/Document/Rules/ruleText.ts new file mode 100644 index 00000000..184ac556 --- /dev/null +++ b/resources/js/components/Factory/Document/Rules/ruleText.ts @@ -0,0 +1,119 @@ +import sortBy from "lodash/sortBy"; + +// what a rule can compare a question's answer with +export type AnswerKind = + | "choice" + | "multi" + | "range" + | "number" + | "date" + | "text"; + +const kinds: Partial> = { + radio: "choice", + checkbox: "multi", + rating: "range", + scale: "range", + "input-number": "number", + date: "date", + "input-short": "text", + "input-long": "text", + "input-email": "text", + "input-link": "text", + "input-phone": "text", + "input-secret": "text", +}; + +const comparisons: Operator[] = [ + "equals", + "equalsNot", + "isLowerThan", + "isGreaterThan", +]; + +const operatorsByKind: Record = { + choice: ["equals", "equalsNot"], + // checkbox answers are joined into one text, so "includes" is a contains + multi: ["contains", "containsNot"], + range: comparisons, + number: comparisons, + date: comparisons, + text: [ + "equals", + "equalsNot", + "contains", + "containsNot", + "isLowerThan", + "isGreaterThan", + ], +}; + +const words: Record = { + equals: "is", + equalsNot: "is not", + contains: "contains", + containsNot: "doesn't contain", + isLowerThan: "is less than", + isGreaterThan: "is more than", +}; + +const checkboxWords: Partial> = { + contains: "includes", + containsNot: "doesn't include", +}; + +export function answerKind(block?: FormBlockModel | null): AnswerKind | null { + return (block && kinds[block.type]) ?? null; +} + +export function operatorLabel( + operator: Operator | undefined, + block?: FormBlockModel | null, +): string { + if (!operator) { + return "is"; + } + + return ( + (answerKind(block) === "multi" && checkboxWords[operator]) || + words[operator] + ); +} + +export function operatorsFor(block?: FormBlockModel | null) { + const kind = answerKind(block); + + return (kind ? operatorsByKind[kind] : operatorsByKind.text).map((id) => ({ + id, + label: operatorLabel(id, block), + })); +} + +// the answers a choice, rating or scale question can give; free fields give none +export function answerOptions(block?: FormBlockModel | null): string[] { + const interactions = block?.interactions ?? []; + const kind = answerKind(block); + + if (kind === "choice" || kind === "multi") { + const labels = sortBy(interactions, "sequence") + .filter( + (item) => + item.type === "button" && item.is_editable && item.label, + ) + .map((item) => item.label as string); + + return [...new Set(labels)]; + } + + if (kind === "range") { + const range = interactions.find((item) => item.type === "range"); + const start = Number(range?.options?.start ?? 1); + const end = Number(range?.options?.end ?? 5); + + return Array.from({ length: Math.max(end - start + 1, 0) }, (_, i) => + String(start + i), + ); + } + + return []; +} From 2d8f4ba243907e263c8821379e5334a40c460d21 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:25:17 +0200 Subject: [PATCH 02/12] feat: show rules where they act in the document Rules sit under their question, a group's rules under its header, and Add rule starts one in place instead of opening the logic panel. Co-Authored-By: Claude Opus 5.5 --- .../js/components/Factory/Document/DocumentGroup.vue | 12 ++++++------ .../js/components/Factory/Document/DocumentList.vue | 7 +++++++ .../js/components/Factory/Document/FormDocument.vue | 4 ++-- .../js/components/Factory/Document/QuestionCard.vue | 10 ++++------ 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/resources/js/components/Factory/Document/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue index d6462098..8eedc8ca 100644 --- a/resources/js/components/Factory/Document/DocumentGroup.vue +++ b/resources/js/components/Factory/Document/DocumentGroup.vue @@ -30,11 +30,9 @@ +
import { computed, ref, watch } from "vue"; import { D9Icon } from "@deck9/ui"; -import { useForm, useLogic, useWorkbench } from "@/stores"; +import { useForm, useWorkbench } from "@/stores"; import BlockMenu from "./BlockMenu.vue"; import DocumentList from "./DocumentList.vue"; +import BlockRules from "./Rules/BlockRules.vue"; +import { addRule } from "./Rules/ruleEditing"; import { useAddBlock } from "./blockFocus"; const props = defineProps<{ @@ -83,7 +84,6 @@ const props = defineProps<{ const store = useForm(); const workbench = useWorkbench(); -const logic = useLogic(); const { addBlock } = useAddBlock(); const id = computed(() => `group-${props.group.uuid}`); diff --git a/resources/js/components/Factory/Document/DocumentList.vue b/resources/js/components/Factory/Document/DocumentList.vue index 5c7f9ba9..f2829595 100644 --- a/resources/js/components/Factory/Document/DocumentList.vue +++ b/resources/js/components/Factory/Document/DocumentList.vue @@ -22,6 +22,12 @@ :numbers="numbers" /> +
@@ -33,6 +39,7 @@ import { Container, Draggable } from "vue3-smooth-dnd"; import { useForm } from "@/stores"; import DocumentGroup from "./DocumentGroup.vue"; import DocumentQuestion from "./DocumentQuestion.vue"; +import BlockRules from "./Rules/BlockRules.vue"; const props = defineProps<{ groupId?: string; diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 58ac1ce4..37decf5d 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -15,6 +15,7 @@ {{ countLine }}

+ - diff --git a/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts b/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts deleted file mode 100644 index b52636e8..00000000 --- a/resources/js/components/Factory/Sidebar/BlockLogicRule.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mount } from "@vue/test-utils"; -import { createPinia, setActivePinia } from "pinia"; -import { describe, expect, it } from "vitest"; -import BlockLogicRule from "./BlockLogicRule.vue"; -import { useForm } from "@/stores"; - -describe("BlockLogicRule", () => { - it("shows the Identifier and question text as plain text", () => { - const pinia = createPinia(); - setActivePinia(pinia); - - useForm().blocks = [ - { - uuid: "block1", - type: "input-short", - title: "age", - message: "

<i>How old are you?</i>

", - } as FormBlockModel, - ]; - - const wrapper = mount(BlockLogicRule, { - props: { - index: 0, - rule: { - name: "Rule", - evaluate: "after", - action: "goto", - action_payload: "block1", - conditions: [], - } as unknown as FormBlockLogic, - }, - global: { plugins: [pinia] }, - }); - - expect(wrapper.find("b").exists()).toBe(false); - expect(wrapper.find("i").exists()).toBe(false); - expect(wrapper.find("span.bg-grey-700").text()).toBe("age"); - expect(wrapper.text()).toContain("How old are you?"); - }); -}); diff --git a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue b/resources/js/components/Factory/Sidebar/BlockLogicRule.vue deleted file mode 100644 index dbc94627..00000000 --- a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - diff --git a/resources/js/components/FieldLabels.test.ts b/resources/js/components/FieldLabels.test.ts index 8d541b71..ca3e2991 100644 --- a/resources/js/components/FieldLabels.test.ts +++ b/resources/js/components/FieldLabels.test.ts @@ -18,9 +18,6 @@ const groupLabels = [ "Factory/Main/ConfigureFile.vue: Allowed File Types", "Factory/Settings/TemplateExport.vue: Template Files", "Factory/Settings/partials/ImageUpload.vue: (bound)", - "Factory/Sidebar/BlockLogicRule.vue: Action", - "Factory/Sidebar/BlockLogicRule.vue: Conditions", - "Factory/Sidebar/BlockLogicRule.vue: Rule Execution", "Pages/Teams/Create.vue: Team Owner", ]; diff --git a/resources/js/stores/helpers/logic.ts b/resources/js/stores/helpers/logic.ts index bfeeb738..07ae4866 100644 --- a/resources/js/stores/helpers/logic.ts +++ b/resources/js/stores/helpers/logic.ts @@ -1,14 +1,3 @@ -export const operators: Array< - EditableFormBlockBlockLogicCondition["operator"] -> = [ - { id: "equals", key: "equals", label: "is equal to" }, - { id: "equalsNot", key: "equalsNot", label: "is not equal to" }, - { id: "contains", key: "contains", label: "contains" }, - { id: "containsNot", key: "containsNot", label: "does not contain" }, - { id: "isLowerThan", key: "isLowerThan", label: "is lower than" }, - { id: "isGreaterThan", key: "isGreaterThan", label: "is greater than" }, -]; - // rule values are text, while rating, scale and number answers are numbers const asText = (value: unknown): string => String(value ?? ""); @@ -160,13 +149,3 @@ export function evaluateGotoLogic( return null; } - -export function transformConditionForBackend( - condition: EditableFormBlockBlockLogicCondition, -): FormBlockLogicCondition { - return { - ...condition, - source: condition.source?.key, - operator: condition.operator.key, - }; -} diff --git a/resources/js/stores/logic.ts b/resources/js/stores/logic.ts index d41e1387..6e3671a7 100644 --- a/resources/js/stores/logic.ts +++ b/resources/js/stores/logic.ts @@ -1,6 +1,4 @@ -import { useForm } from "@/stores"; import { defineStore } from "pinia"; -import { findAllBlocksBeforeTarget } from "./helpers/queue"; import { useNotice } from "./notice"; import { @@ -9,70 +7,19 @@ import { callDeleteFormBlockLogic, } from "@/api/logics"; -import { transformConditionForBackend } from "./helpers/logic"; - +// the block whose rules are saved or removed interface LogicStore { block: FormBlockModel | null; - validation: ValidationError[]; - isShowingLogicEditor: boolean; - hideRule: FormBlockLogic | null; - backup: FormBlockLogic[] | null; } export const useLogic = defineStore("logic", { state: (): LogicStore => { return { block: null, - isShowingLogicEditor: false, - hideRule: null, - validation: [], - backup: null, }; }, actions: { - showLogicEditor(block: FormBlockModel) { - this.block = block; - this.backupLogic(); - this.isShowingLogicEditor = true; - }, - - hideLogicEditor() { - this.block = null; - this.isShowingLogicEditor = false; - }, - - backupLogic() { - this.backup = this.block?.logics ?? null; - }, - - restoreLogic() { - if (!this.block) { - return; - } - - this.block.logics = this.backup ?? undefined; - }, - - addRule() { - if (!this.block) { - return; - } - - if (!this.block.logics) { - this.block.logics = []; - } - - this.block.logics.push({ - form_block_id: this.block.id, - name: "Rule #" + (this.block.logics.length + 1), - action: "hide", - action_payload: null, - evaluate: "before", - conditions: [], - }); - }, - async removeRule(index: number) { if (!this.block || !this.block.logics) { return; @@ -99,46 +46,6 @@ export const useLogic = defineStore("logic", { } }, - async saveBlockLogic() { - if (!this.block || !this.block.logics) { - return; - } - - // reset validation before each request - this.validation = []; - - const results = await Promise.allSettled( - this.block.logics.map((logic, index) => - this.saveSingleRule(logic).then((result) => ({ - result, - index, - })), - ), - ); - - const failedSaves = results - .map((result, index) => ({ ...result, originalIndex: index })) - .filter( - ( - result, - ): result is PromiseRejectedResult & { - originalIndex: number; - } => result.status === "rejected", - ); - - if (failedSaves.length > 0) { - failedSaves.forEach((failure) => { - this.validation[failure.originalIndex] = - failure.reason.response.data; - }); - } - - return { - failed: failedSaves.length, - totalRules: this.block.logics.length, - }; - }, - async saveSingleRule(logic: FormBlockLogic) { if (!this.block) { throw new Error("Block is not defined"); @@ -173,59 +80,5 @@ export const useLogic = defineStore("logic", { throw error; } }, - - updateBlockLogic(logic: FormBlockLogic, index: number) { - if (!this.block) { - return; - } - - if (!this.block.logics) { - this.block.logics = []; - } - - const transformedConditions = logic.conditions.map((c) => - transformConditionForBackend(c), - ); - - logic.conditions = transformedConditions; - - this.block.logics[index] = logic; - }, - }, - - getters: { - allBlocks(): Array | null { - const formStore = useForm(); - - return formStore.blocks; - }, - - availableSourceBlocksWithTarget(state): Array { - const formStore = useForm(); - - if (!state.block?.uuid) { - return []; - } - - return findAllBlocksBeforeTarget( - formStore.blocksTree, - state.block.uuid, - { filterGroups: true, includeTarget: true }, - ); - }, - - availableSourceBlocks(state): Array { - const formStore = useForm(); - - if (!state.block?.uuid) { - return []; - } - - return findAllBlocksBeforeTarget( - formStore.blocksTree, - state.block.uuid, - { filterGroups: true, includeTarget: false }, - ); - }, }, }); From 8bef14d5e9071614160f87de02d36581c9ddbfae Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:40:12 +0200 Subject: [PATCH 05/12] docs: add changelog line for #274 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bb6e06..f149ec3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Lines marked **Upgrade step** need you to act after updating. - On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257) - A form's pages have one top bar instead of two: the form's name with its menu, a Draft or Live label, the pages, Preview, Publish and your account. Publish first checks for questions without text, rules that point to a deleted question and a missing privacy link. Once the form is live, Share has its link, Open form and Unpublish. On a phone the pages sit in a tab bar at the bottom. (#265) - The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. (#268) +- Each rule now shows as a sentence under its question, and a group says when it's hidden. Edit changes a rule right there, and for choices, ratings and scales you pick the answer instead of typing it. A rule whose question was deleted shows in red and can't be saved until you fix it. (#274) ### Fixed From 4088f2f22efb0ac95d17624f5de81ce014979a90 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:56:24 +0200 Subject: [PATCH 06/12] fix: ask in the app's dialog before removing a rule Part a now asks before every delete; the old panel's test moves here. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/Rules/BlockRules.test.ts | 19 +++++++++++++++++++ .../Factory/Document/Rules/RuleEditor.vue | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts index 223f1be2..921c881c 100644 --- a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts +++ b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts @@ -80,6 +80,7 @@ beforeEach(() => { const mountRules = (uuid: string) => mount(BlockRules, { props: { block: find(uuid), numbers: questionNumbers(blocks) }, + global: { stubs: { teleport: true } }, attachTo: document.body, }); @@ -331,6 +332,24 @@ describe("editing a rule in place", () => { ); expect(editing.value).not.toBeNull(); }); + + it("asks in the app's dialog before it removes a rule", async () => { + const logic = useLogic(); + const wrapper = await edit(); + + await button(wrapper, "Remove rule").trigger("click"); + + expect(text(wrapper)).toContain("Remove this rule?"); + expect(logic.removeRule).not.toHaveBeenCalled(); + + const confirm = wrapper + .findAll("button") + .filter((item) => item.text() === "Remove rule") + .at(-1)!; + await confirm.trigger("click"); + + expect(logic.removeRule).toHaveBeenCalledWith(0); + }); }); describe("adding a rule", () => { diff --git a/resources/js/components/Factory/Document/Rules/RuleEditor.vue b/resources/js/components/Factory/Document/Rules/RuleEditor.vue index 6d94daaa..b596c8dc 100644 --- a/resources/js/components/Factory/Document/Rules/RuleEditor.vue +++ b/resources/js/components/Factory/Document/Rules/RuleEditor.vue @@ -185,7 +185,7 @@ v-if="rule.id !== undefined" type="button" class="flex h-11 items-center rounded-md px-2 font-semibold sm:h-8 text-red-700 hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" - @click="remove" + @click="isAsking = true" > Remove rule @@ -200,6 +200,15 @@ @click="save" /> + + @@ -209,6 +218,7 @@ import truncate from "lodash/truncate"; import { D9Button, D9Icon, D9Input, D9Select } from "@deck9/ui"; import { useForm, useLogic } from "@/stores"; import { findAllBlocksBeforeTarget } from "@/stores/helpers/queue"; +import DeleteDialog from "@/components/Factory/Shared/DeleteDialog.vue"; import { plainText } from "../questionSummary"; import { answerKind, answerOptions, operatorsFor } from "./ruleText"; import { editing, keepSaved, stopEditing } from "./ruleEditing"; @@ -380,7 +390,10 @@ const save = async () => { } }; +const isAsking = ref(false); + const remove = async () => { + isAsking.value = false; logic.block = props.block; const count = props.block.logics?.length ?? 0; From c82d065947bb81a883fdf3c7b535e07858a6b87a Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:56:45 +0200 Subject: [PATCH 07/12] fix: end a rule's edit when you leave the editor Coming back showed the pickers again and moved the cursor into them. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/Rules/BlockRules.test.ts | 12 ++++++++++++ .../components/Factory/Document/Rules/BlockRules.vue | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts index 921c881c..8eaacab0 100644 --- a/resources/js/components/Factory/Document/Rules/BlockRules.test.ts +++ b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts @@ -370,6 +370,18 @@ describe("adding a rule", () => { expect(owner.logics).toHaveLength(0); }); + it("ends when the editor is left, and an unsaved rule goes with it", async () => { + const owner = find("q-come"); + addRule(owner); + const wrapper = mountRules("q-come"); + await wrapper.vm.$nextTick(); + + wrapper.unmount(); + + expect(editing.value).toBeNull(); + expect(owner.logics).toHaveLength(0); + }); + it("hides a group by default", () => { const group = find("g-1"); addRule(group); diff --git a/resources/js/components/Factory/Document/Rules/BlockRules.vue b/resources/js/components/Factory/Document/Rules/BlockRules.vue index e97be7b6..9e9dc503 100644 --- a/resources/js/components/Factory/Document/Rules/BlockRules.vue +++ b/resources/js/components/Factory/Document/Rules/BlockRules.vue @@ -21,10 +21,10 @@