Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e9005ee
feat: edit the whole form as one document
PhilReinking Oct 12, 2026
ef0663e
feat: put grip and delete inside each choice row
PhilReinking Oct 12, 2026
d32b49c
fix: name the text format buttons
PhilReinking Oct 12, 2026
c744234
Merge branch 'stack/bits-274-phase-1' into feat/bits-282-document-editor
PhilReinking Oct 12, 2026
4bcf889
fix: focus after Try again, and drop a late question load
PhilReinking Oct 12, 2026
c18216f
test: cover the closed question row and opening it
PhilReinking Oct 12, 2026
d32d1cd
docs: add changelog line for #268
PhilReinking Oct 12, 2026
efc4407
fix: make a question's answer field without opening Settings
PhilReinking Oct 12, 2026
c00f13e
fix: give the Settings panel the id its button points at
PhilReinking Oct 12, 2026
651590b
fix: let a group be dragged by a grip in its header
PhilReinking Oct 12, 2026
20608c4
fix: keep focus near a deleted choice or policy
PhilReinking Oct 12, 2026
7bdefa3
fix: leave the cursor alone when a new question can't be made
PhilReinking Oct 12, 2026
a7fa8d5
refactor: drop form store state the old storyboard used
PhilReinking Oct 12, 2026
e4fc5f5
Merge branch 'stack/bits-274-phase-1' into feat/bits-282-document-editor
PhilReinking Oct 12, 2026
e1fed98
fix: move between visible choices only, with a custom answer on
PhilReinking Oct 12, 2026
359284d
fix: show a question's text flat in its card, without boxes
PhilReinking Oct 12, 2026
6ae1bd8
fix: draw each choice as one row, and name its fields
PhilReinking Oct 12, 2026
a960f5c
fix: say "Add choice", as a dashed row like the concept
PhilReinking Oct 12, 2026
7930924
fix: hide Required on a No Input question
PhilReinking Oct 12, 2026
3938f6b
fix: make a choice row 36 px tall, like the concept
PhilReinking Oct 12, 2026
18c92f9
Merge branch 'stack/bits-274-phase-1' into feat/bits-282-document-editor
PhilReinking Oct 12, 2026
0f2f881
Merge branch 'stack/bits-274-phase-1' into feat/bits-282-document-editor
PhilReinking Oct 13, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Lines marked **Upgrade step** need you to act after updating.
- The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256)
- 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)
- 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)

### Fixed
Expand Down
52 changes: 42 additions & 10 deletions resources/js/Pages/Forms/Edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,25 @@ const blocks = [

const stubs = {
AppLayout: { props: ["title"], template: "<main><slot /></main>" },
BlockContainer: { template: "<ol><li>questions</li></ol>" },
DocumentList: { template: "<ol><li>questions</li></ol>" },
BlockLogicEditor: true,
Workbench: true,
};

let wrapper: VueWrapper | undefined;

// the test pinia stubs actions: getBlocks does what each test tells it to
const mountEditor = async (load: () => Promise<void>) => {
vi.mocked(useForm().getBlocks).mockImplementation(load);
wrapper = mount(Edit, { props: { form }, global: { stubs } });
wrapper = mount(Edit, {
props: { form },
global: { stubs },
attachTo: document.body,
});
await flushPromises();
return wrapper;
};
const focused = () =>
document.activeElement?.closest("button")?.textContent?.trim();
const loads = (result: FormBlockModel[]) => async () => {
useForm().blocks = result;
};
Expand All @@ -45,8 +50,7 @@ describe("editor loading", () => {
const editor = await mountEditor(() => new Promise(() => {}));

expect(editor.get("[role=status]").text()).toBe("Loading questions");
expect(editor.text()).not.toContain("Welcome to the Editor");
expect(editor.text()).not.toContain("No blocks found");
expect(editor.text()).not.toContain("No questions yet");
expect(editor.find("[role=alert]").exists()).toBe(false);
});

Expand All @@ -56,8 +60,7 @@ describe("editor loading", () => {

const alert = editor.get("[role=alert]");
expect(alert.text()).toContain("Couldn't load the questions.");
expect(editor.text()).not.toContain("Welcome to the Editor");
expect(editor.text()).not.toContain("No blocks found");
expect(editor.text()).not.toContain("No questions yet");

getBlocks.mockImplementation(loads(blocks));
await alert.get("button").trigger("click");
Expand All @@ -73,8 +76,7 @@ describe("editor loading", () => {
it("shows the empty-form text for a loaded form without questions", async () => {
const editor = await mountEditor(loads([]));

expect(editor.text()).toContain("Welcome to the Editor");
expect(editor.text()).toContain("No blocks found");
expect(editor.text()).toContain("No questions yet");
expect(editor.find("[role=status]").exists()).toBe(false);
});

Expand All @@ -83,6 +85,36 @@ describe("editor loading", () => {
const editor = await mountEditor(loads(blocks));

expect(editor.text()).toContain("questions");
expect(editor.text()).not.toContain("Welcome to the Editor");
expect(editor.text()).not.toContain("No questions yet");
});

it("puts focus on Add question after Try again loads, and back on Try again when it fails again", async () => {
const getBlocks = vi.mocked(useForm().getBlocks);
const editor = await mountEditor(() => Promise.reject(new Error("500")));

await editor.get("[role=alert] button").trigger("click");
await flushPromises();
expect(focused()).toBe("Try again");

// putOnWorkbench is a stub, so no question is open to take the focus
getBlocks.mockImplementation(loads(blocks));
await editor.get("[role=alert] button").trigger("click");
await flushPromises();
expect(focused()).toBe("Add question");
});

it("opens no question when the questions arrive after the editor closed", async () => {
let arrive!: () => void;
const editor = await mountEditor(
() => new Promise<void>((resolve) => (arrive = resolve)),
);

editor.unmount();
wrapper = undefined;
useForm().blocks = blocks;
arrive();
await flushPromises();

expect(useWorkbench().putOnWorkbench).not.toHaveBeenCalled();
});
});
39 changes: 13 additions & 26 deletions resources/js/Pages/Forms/Edit.vue
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
<template>
<app-layout title="Edit Form" limit-height>
<div class="flex w-full">
<Storyboard :status="status" @retry="loadBlocks" />
<div class="w-full overflow-y-auto">
<Workbench v-if="workbench.block" :key="workbench.block.id" />
<div
v-else-if="status === 'loading'"
aria-hidden="true"
class="mx-12 max-w-xl space-y-4 px-4 pt-8 xl:mx-24"
>
<D9Skeleton
v-for="bar in ['h-8 w-3/5', 'h-24', 'h-11 w-3/4']"
:key="bar"
:title="false"
:lines="0"
class="rounded-md bg-grey-200 motion-reduce:animate-none"
:class="bar"
/>
</div>
<WorkbenchEmpty v-else-if="status === 'loaded' && !store.hasBlocks" />
</div>
</div>
<FormDocument :status="status" @retry="loadBlocks" />
</app-layout>
</template>

<script setup lang="ts">
import AppLayout from "@/Layouts/AppLayout.vue";
import Storyboard from "@/components/Factory/Sidebar/Storyboard.vue";
import Workbench from "@/components/Factory/Main/Workbench.vue";
import WorkbenchEmpty from "@/components/Factory/Main/WorkbenchEmpty.vue";
import FormDocument from "@/components/Factory/Document/FormDocument.vue";
import { useForm, useWorkbench } from "@/stores";
import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave";
import { D9Skeleton } from "@deck9/ui";
import { onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps<{
Expand All @@ -48,7 +25,14 @@ const loadBlocks = async () => {
try {
await store.getBlocks();
} catch {
status.value = "failed";
if (!left) {
status.value = "failed";
}
return;
}

// a late reply must not open a question, and so change the address, on the next page
if (left) {
return;
}

Expand All @@ -67,9 +51,12 @@ const loadBlocks = async () => {
status.value = "loaded";
};

let left = false;

useSaveOnLeave();

onBeforeUnmount(() => {
left = true;
store.clearForm();
});

Expand Down
23 changes: 0 additions & 23 deletions resources/js/components/AdvancedSettings.vue

This file was deleted.

99 changes: 99 additions & 0 deletions resources/js/components/Factory/Document/BlockMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<template>
<D9Menu
position="left"
:use-portal="true"
class="[&>button:focus-visible]:ring-2 [&>button:focus-visible]:ring-blue-500 [&>button]:rounded-md"
>
<template #button>
<span
class="grid h-9 w-9 place-items-center rounded-md border border-grey-300 bg-white text-grey-700 hover:bg-grey-100"
>
<D9Icon name="ellipsis-h" />
<span class="sr-only">{{ label }}</span>
</span>
</template>

<D9MenuLink
v-if="isGroup"
as="button"
:class="item"
label="Add question to this group"
@click="emit('addToGroup')"
/>
<D9MenuLink
v-else
as="button"
:class="item"
label="Add question below"
@click="addBlock(block)"
/>
<D9MenuLink
v-if="isGroup"
as="button"
:class="item"
:label="block.is_disabled ? 'Show group' : 'Hide group'"
@click="toggleHidden"
/>
<D9MenuLink
as="button"
:class="item"
:label="isGroup ? 'Copy group ID' : 'Copy question ID'"
@click="copy(block.uuid)"
/>
<D9MenuLink
as="button"
:class="[item, 'text-red-700']"
:label="isGroup ? 'Delete group…' : 'Delete…'"
@click="deleteBlock"
/>
</D9Menu>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { D9Icon, D9Menu, D9MenuLink } from "@deck9/ui";
import copy from "copy-text-to-clipboard";
import { useForm, useWorkbench } from "@/stores";
import { useAddBlock } from "./blockFocus";

const props = defineProps<{
block: FormBlockModel;
label: string;
}>();

const emit = defineEmits<{ addToGroup: [] }>();

const store = useForm();
const workbench = useWorkbench();
const { addBlock } = useAddBlock();

const item = "w-full text-left text-sm";
const isGroup = computed(() => props.block.type === "group");

// the open block saves through the workbench: a store save swaps its object, and its next save would send the old value
const toggleHidden = () => {
const hidden = !props.block.is_disabled;

if (workbench.block?.id === props.block.id) {
workbench.updateBlock({ is_disabled: hidden });
} else {
store.updateFormBlockProperty(props.block, "is_disabled", hidden);
}
};

const deleteBlock = () => {
const question = isGroup.value
? "Do you really want to delete this group? All blocks inside this group will be deleted as well."
: "Do you really want to delete this block?";

if (!window.confirm(question)) {
return;
}

store.deleteFormBlock(props.block);

if (workbench.block?.id === props.block.id) {
workbench.clearWorkbench();
}
};
</script>
Loading