diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80654d07..91b509d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ Lines marked **Upgrade step** need you to act after updating.
- 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. An outline next to the form jumps to any question, and on a phone a question's Settings open from the bottom of the screen. Move a question with Alt+Up/Down, with Move up and Move down in its menu, or by dragging. (#268, #272, #275)
- Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269)
+- 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
diff --git a/resources/js/Pages/Forms/Edit.test.ts b/resources/js/Pages/Forms/Edit.test.ts
index fba82d18..2424201c 100644
--- a/resources/js/Pages/Forms/Edit.test.ts
+++ b/resources/js/Pages/Forms/Edit.test.ts
@@ -12,7 +12,6 @@ const blocks = [
const stubs = {
AppLayout: { props: ["title"], template: " " },
DocumentList: { template: "
questions " },
- BlockLogicEditor: true,
};
let wrapper: VueWrapper | undefined;
diff --git a/resources/js/app.ts b/resources/js/app.ts
index 29da4271..bb2c4055 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/DocumentGroup.vue b/resources/js/components/Factory/Document/DocumentGroup.vue
index 36a11dd4..bb5ff4be 100644
--- a/resources/js/components/Factory/Document/DocumentGroup.vue
+++ b/resources/js/components/Factory/Document/DocumentGroup.vue
@@ -28,15 +28,15 @@
title="Use a group to show or hide several questions with one rule."
class="h-11 min-w-0 flex-1 rounded-md border-0 bg-transparent px-2 text-base font-semibold lg:h-9 lg:text-sm text-grey-700 placeholder:text-grey-600 hover:bg-grey-100 focus:bg-white focus:ring-2 focus:ring-blue-500"
/>
- Hidden
+ Always hidden
- Rules ({{ group.logics.length }})
+ Add rule
+
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";
import { useMoveBlock } from "./blockMove";
@@ -86,7 +89,6 @@ const props = defineProps<{
const store = useForm();
const workbench = useWorkbench();
-const logic = useLogic();
const { addBlock } = useAddBlock();
const { onMoveKey } = useMoveBlock();
diff --git a/resources/js/components/Factory/Document/DocumentList.vue b/resources/js/components/Factory/Document/DocumentList.vue
index 5c7f9ba9..5a08e47a 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/DocumentQuestion.test.ts b/resources/js/components/Factory/Document/DocumentQuestion.test.ts
index f1a53f17..19414d91 100644
--- a/resources/js/components/Factory/Document/DocumentQuestion.test.ts
+++ b/resources/js/components/Factory/Document/DocumentQuestion.test.ts
@@ -51,8 +51,9 @@ describe("a closed question in the document", () => {
expect(text).toContain("Which days can you help?");
expect(text).toContain("Checkboxes · 2 choices");
expect(text).toContain("Required");
- expect(text).toContain("1 rule");
- expect(text).toContain("Hidden");
+ // its rules show as sentences under it
+ expect(text).not.toContain("rule");
+ expect(text).toContain("Always hidden");
expect(text).toContain("days");
expect(text).not.toContain(question.uuid);
});
@@ -66,6 +67,24 @@ describe("a closed question in the document", () => {
expect(text).not.toContain(question.uuid);
});
+ it("marks its number while the open question jumps here", async () => {
+ const workbench = useWorkbench();
+ const wrapper = mountQuestion();
+ const number = () => wrapper.find("span[aria-hidden] > span");
+
+ expect(number().text()).toBe("3");
+ expect(number().classes()).not.toContain("bg-blue-50");
+
+ workbench.block = {
+ id: 1,
+ uuid: "q-1",
+ logics: [{ action: "goto", action_payload: question.uuid }],
+ } as unknown as FormBlockModel;
+ await wrapper.vm.$nextTick();
+
+ expect(number().classes()).toContain("bg-blue-50");
+ });
+
it("opens as a card when clicked", async () => {
const workbench = useWorkbench();
const wrapper = mountQuestion();
diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue
index df54b1a7..fa047b6f 100644
--- a/resources/js/components/Factory/Document/DocumentQuestion.vue
+++ b/resources/js/components/Factory/Document/DocumentQuestion.vue
@@ -8,7 +8,13 @@
{{ number }} {{ number }}
Required
- {{ ruleCount }} {{ ruleCount === 1 ? "rule" : "rules" }}
- Hidden
+ Always hidden
{{
block.title
}}
@@ -74,6 +77,7 @@ import { plainText } from "./questionSummary";
import QuestionCard from "./QuestionCard.vue";
import { cursorFor } from "./blockFocus";
import { useMoveBlock } from "./blockMove";
+import { jumpTargets } from "./Rules/ruleText";
const props = defineProps<{
block: FormBlockModel;
@@ -84,6 +88,10 @@ const workbench = useWorkbench();
const { types } = useBlockTypes();
const isOpen = computed(() => workbench.block?.id === props.block.id);
+// the open question's rules jump here
+const isJumpTarget = computed(() =>
+ jumpTargets(workbench.block).includes(props.block.uuid),
+);
const text = computed(() => plainText(props.block.message));
const blockType = computed(() =>
@@ -96,7 +104,6 @@ const choiceCount = computed(() =>
).length
: null,
);
-const ruleCount = computed(() => props.block.logics?.length ?? 0);
const { onMoveKey } = useMoveBlock();
const row = ref(null);
diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts
index 847160e0..6c84d953 100644
--- a/resources/js/components/Factory/Document/FormDocument.test.ts
+++ b/resources/js/components/Factory/Document/FormDocument.test.ts
@@ -13,7 +13,7 @@ const stubs = {
DocumentList: {
template: ``,
},
- BlockLogicEditor: true,
+ RuleGutter: true,
};
let wrapper: VueWrapper | undefined;
diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue
index fe991a6d..ab97a5b7 100644
--- a/resources/js/components/Factory/Document/FormDocument.vue
+++ b/resources/js/components/Factory/Document/FormDocument.vue
@@ -40,6 +40,7 @@
+
-
{{ moveNote }}
@@ -105,9 +105,9 @@ import { D9Button, D9Icon } from "@deck9/ui";
import { useForm, useWorkbench } from "@/stores";
import EmptyState from "@/components/EmptyState.vue";
import QuestionsLoadState from "@/components/Factory/Shared/QuestionsLoadState.vue";
-import BlockLogicEditor from "@/components/Factory/Sidebar/BlockLogicEditor.vue";
import DocumentList from "./DocumentList.vue";
import DocumentOutline from "./DocumentOutline.vue";
+import RuleGutter from "./Rules/RuleGutter.vue";
import { questionNumbers } from "./questionSummary";
import { moveNote } from "./blockMove";
import {
diff --git a/resources/js/components/Factory/Document/QuestionCard.vue b/resources/js/components/Factory/Document/QuestionCard.vue
index a81c38ef..f31444da 100644
--- a/resources/js/components/Factory/Document/QuestionCard.vue
+++ b/resources/js/components/Factory/Document/QuestionCard.vue
@@ -62,11 +62,9 @@
- Rules ({{ block.logics.length }})
+ Add rule
@@ -123,7 +121,7 @@ import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/vue";
import { useMediaQuery } from "@vueuse/core";
import { D9Icon, D9Select, D9Switch } from "@deck9/ui";
-import { useLogic, useWorkbench } from "@/stores";
+import { useWorkbench } from "@/stores";
import Editor from "@/components/Factory/Shared/Editor.vue";
import ConfigureClicks from "@/components/Factory/Main/ConfigureClicks.vue";
import ConfigureConsents from "@/components/Factory/Main/ConfigureConsents.vue";
@@ -131,6 +129,7 @@ import { useBlockTypes } from "@/components/Factory/utils/useBlockTypes";
import BlockMenu from "./BlockMenu.vue";
import QuestionSettings from "./QuestionSettings.vue";
import { cursorFor } from "./blockFocus";
+import { addRule } from "./Rules/ruleEditing";
const props = defineProps<{
block: FormBlockModel;
@@ -141,7 +140,6 @@ const props = defineProps<{
provide("block", props.block);
const workbench = useWorkbench();
-const logic = useLogic();
const { types } = useBlockTypes();
const id = computed(() => `question-${props.block.uuid}`);
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..8eaacab0
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/BlockRules.test.ts
@@ -0,0 +1,391 @@
+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) },
+ global: { stubs: { teleport: true } },
+ 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();
+ // an update answers only true
+ vi.mocked(logic.saveSingleRule).mockResolvedValue(true as never);
+ 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("closes on Escape, but not when an open list takes it", async () => {
+ const wrapper = await edit();
+ const editor = wrapper.find("[role=group]");
+ const listEscape = new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ cancelable: true,
+ });
+ listEscape.preventDefault();
+
+ editor.element.dispatchEvent(listEscape);
+ expect(editing.value).not.toBeNull();
+
+ await editor.trigger("keydown", { key: "Escape" });
+ expect(editing.value).toBeNull();
+ });
+
+ 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();
+ });
+
+ 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", () => {
+ 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("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);
+
+ 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..9e9dc503
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/BlockRules.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
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 @@
+
+
+ Deleted question
+
+
+
+
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..d7340144
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleEditor.vue
@@ -0,0 +1,418 @@
+
+
+
+
+
If
+
+ And or or, condition {{ i + 1 }}
+ (condition.chainOperator = option.id as 'and' | 'or')
+ "
+ />
+
+
+
+ Question for condition {{ i + 1 }}
+ setSource(condition, option.id as string)
+ "
+ />
+
+
+
+ Comparison for condition {{ i + 1 }}
+ (condition.operator = option.id as Operator)
+ "
+ />
+
+
+
+
+ Answer for condition {{ i + 1 }}
+ (condition.value = String(option.id))
+ "
+ />
+
+
+ Answer for condition {{ i + 1 }}
+ (condition.value = String(value ?? ''))
+ "
+ />
+
+
+
+
+
+
+
+
+
+ The question this rule used was deleted. Pick another one.
+
+
+ Upper and lower case must match.
+
+
+
+
+ Add condition
+
+
+
+
then
+
+ Action
+ (draft.action = option.id as FormBlockLogic['action'])
+ "
+ />
+
+
+ Go to question
+ (draft.action_payload = option.id as string)
+ "
+ />
+
+
+
+ The question this rule went to was deleted. Pick another one.
+
+
+
+
+
+
+ Remove rule
+
+
+
+
+
+
+
+
+
+
+
+
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..d62df7f9
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleGutter.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
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..c574f04b
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/RuleLine.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+ {{ "question " }} {{ part.number }}
+ “{{ part.value }}”
+ {{ part.text }}
+
+
+ {{ targetLabel }}
+
+
+
+ Edit
+
+
+
+
+
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..95bc4065
--- /dev/null
+++ b/resources/js/components/Factory/Document/Rules/ruleText.ts
@@ -0,0 +1,126 @@
+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 [];
+}
+
+// the questions a block's rules jump to
+export function jumpTargets(block?: FormBlockModel | null): string[] {
+ return (block?.logics ?? [])
+ .filter((rule) => rule.action === "goto" && rule.action_payload)
+ .map((rule) => rule.action_payload as string);
+}
diff --git a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
index ff604c5f..12ad76ff 100644
--- a/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
+++ b/resources/js/components/Factory/Shared/DeleteDialogs.test.ts
@@ -9,9 +9,8 @@ import DeleteChoiceDialog from "./DeleteChoiceDialog.vue";
import { checkDelete, useDeleteBlock } from "./useDeleteBlock";
import ClickInteraction from "@/components/Factory/Main/Interactions/ClickInteraction.vue";
import ConsentInteraction from "@/components/Factory/Main/Interactions/ConsentInteraction.vue";
-import BlockLogicRule from "@/components/Factory/Sidebar/BlockLogicRule.vue";
import { callGetFormBlocks } from "@/api/blocks";
-import { useForm, useLogic, useWorkbench } from "@/stores";
+import { useForm, useWorkbench } from "@/stores";
vi.mock("@/api/blocks", async (importOriginal) => ({
...(await importOriginal()),
@@ -428,38 +427,4 @@ describe("asking before a delete", () => {
wrapper.unmount();
});
});
-
- describe("deleting a rule", () => {
- it("asks in the app's dialog, not the browser's", async () => {
- const confirm = vi.spyOn(window, "confirm");
- const remove = vi
- .spyOn(useLogic(), "removeRule")
- .mockResolvedValue();
- const wrapper = mount(BlockLogicRule, {
- props: {
- index: 2,
- rule: {
- name: "Skip",
- evaluate: "after",
- action: "goto",
- action_payload: "q1",
- conditions: [],
- } as unknown as FormBlockLogic,
- },
- global: { plugins: [pinia], stubs },
- });
-
- await wrapper
- .find("button[aria-label='Delete rule']")
- .trigger("click");
-
- expect(confirm).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain("Delete rule “Skip”?");
- expect(remove).not.toHaveBeenCalled();
-
- await wrapper.find("button.bg-red-600").trigger("click");
-
- expect(remove).toHaveBeenCalledWith(2);
- });
- });
});
diff --git a/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue b/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue
deleted file mode 100644
index 39ba8949..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicEditor.vue
+++ /dev/null
@@ -1,140 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
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 9d3f0061..00000000
--- a/resources/js/components/Factory/Sidebar/BlockLogicRule.vue
+++ /dev/null
@@ -1,314 +0,0 @@
-
-
-
-
- {{ rule.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Source block
-
-
-
- {{ option.title }}
- {{ option.text }}
-
-
-
-
Operator
-
-
-
-
-
-
- Remove
-
-
-
-
-
-
-
You have no conditions set for this rule.
-
Click the button below to add a condition.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ option.title }}
- {{ option.text }}
-
-
-
-
-
-
-
-
-
-
-
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 572257be..d5c49c0f 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", {
await useNotice().send(`removeRule:${logic.id}`, request, block);
},
- 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 },
- );
- },
},
});