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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +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. 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. (#268, #272)
- 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)

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ createInertiaApp({
autoHide: true,
placement: "auto",
html: true,
// a tooltip shown on hover must not take the focus, e.g. from the question text
noAutoFocus: true,
},
},
})
Expand Down
65 changes: 65 additions & 0 deletions resources/js/components/Factory/Document/BlockMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import BlockMenu from "./BlockMenu.vue";

const blocks = [
{ id: 1, uuid: "q1", type: "radio" },
{ id: 2, uuid: "q2", type: "radio" },
] as FormBlockModel[];

let wrapper: VueWrapper | undefined;

const openMenu = async (block: FormBlockModel) => {
wrapper = mount(BlockMenu, {
props: { block, label: "Options for question 1" },
attachTo: document.body,
});
await wrapper.find("button").trigger("click");
await flushPromises();
};

// the menu opens in a portal
const item = (label: string) =>
[...document.querySelectorAll<HTMLElement>("[role=menuitem]")].find(
(element) => element.textContent?.trim() === label,
);

describe("a block's menu", () => {
beforeEach(() => {
vi.clearAllMocks();
useWorkbench().$reset();
useForm().$reset();
useForm().blocks = blocks;
});

afterEach(() => {
wrapper?.unmount();
document.body.innerHTML = "";
});

it("moves a question down, for touch and pointer users", async () => {
await openMenu(blocks[0]);

item("Move down")!.click();
await flushPromises();

expect(useForm().changeBlockSequence).toHaveBeenCalledWith(
false,
1,
blocks[0],
);
});

it("can't move the first block up or the last one down", async () => {
await openMenu(blocks[0]);
expect(item("Move up")?.getAttribute("aria-disabled")).toBe("true");
expect(item("Move down")?.getAttribute("aria-disabled")).toBeNull();
wrapper!.unmount();
document.body.innerHTML = "";

await openMenu(blocks[1]);
expect(item("Move up")?.getAttribute("aria-disabled")).toBeNull();
expect(item("Move down")?.getAttribute("aria-disabled")).toBe("true");
});
});
16 changes: 16 additions & 0 deletions resources/js/components/Factory/Document/BlockMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@
:label="block.is_disabled ? 'Show group' : 'Hide group'"
@click="toggleHidden"
/>
<D9MenuLink
as="button"
:class="item"
label="Move up"
:disabled="!canMove(block, -1)"
@click="moveBlock(block, -1)"
/>
<D9MenuLink
as="button"
:class="item"
label="Move down"
:disabled="!canMove(block, 1)"
@click="moveBlock(block, 1)"
/>
<D9MenuLink
as="button"
:class="item"
Expand All @@ -55,6 +69,7 @@ import { D9Icon, D9Menu, D9MenuLink } from "@deck9/ui";
import copy from "copy-text-to-clipboard";
import { useForm, useWorkbench } from "@/stores";
import { useAddBlock } from "./blockFocus";
import { useMoveBlock } from "./blockMove";

const props = defineProps<{
block: FormBlockModel;
Expand All @@ -66,6 +81,7 @@ const emit = defineEmits<{ addToGroup: [] }>();
const store = useForm();
const workbench = useWorkbench();
const { addBlock } = useAddBlock();
const { canMove, moveBlock } = useMoveBlock();

const item = "w-full min-h-11 text-left text-sm lg:min-h-0";
const isGroup = computed(() => props.block.type === "group");
Expand Down
3 changes: 3 additions & 0 deletions resources/js/components/Factory/Document/DocumentGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
>
<div
class="group/header flex flex-wrap items-center gap-x-2 gap-y-1 pb-2 pl-3 sm:pl-1"
@keydown="!$event.defaultPrevented && onMoveKey($event, group)"
>
<span
aria-hidden="true"
Expand Down Expand Up @@ -76,6 +77,7 @@ import { useForm, useLogic, useWorkbench } from "@/stores";
import BlockMenu from "./BlockMenu.vue";
import DocumentList from "./DocumentList.vue";
import { useAddBlock } from "./blockFocus";
import { useMoveBlock } from "./blockMove";

const props = defineProps<{
group: FormBlockModel;
Expand All @@ -86,6 +88,7 @@ const store = useForm();
const workbench = useWorkbench();
const logic = useLogic();
const { addBlock } = useAddBlock();
const { onMoveKey } = useMoveBlock();

const id = computed(() => `group-${props.group.uuid}`);
const isOpen = computed(() => workbench.block?.id === props.group.id);
Expand Down
27 changes: 26 additions & 1 deletion resources/js/components/Factory/Document/DocumentOutline.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import DocumentOutline from "./DocumentOutline.vue";

Expand All @@ -12,8 +12,17 @@ const blocks = [

const numbers = { q1: 1, q2: 2, q3: 3 };

const scrolled = vi.fn();

describe("the outline", () => {
beforeEach(() => {
// jsdom has no scrollIntoView
Element.prototype.scrollIntoView = function (
this: Element,
options?: boolean | ScrollIntoViewOptions,
) {
scrolled(this.textContent, (options as ScrollIntoViewOptions)?.block);
};
useForm().$reset();
useWorkbench().$reset();
useForm().blocks = blocks;
Expand Down Expand Up @@ -41,4 +50,20 @@ describe("the outline", () => {
await wrapper.findAll("button")[0].trigger("click");
expect(wrapper.emitted("pick")?.[0]).toEqual([blocks[0]]);
});

it("keeps the open question in view, also when it opens as a panel", async () => {
useWorkbench().block = blocks[3];
const wrapper = mount(DocumentOutline, {
props: { numbers },
attachTo: document.body,
});
await wrapper.vm.$nextTick();
expect(scrolled).toHaveBeenLastCalledWith("3Name?", "nearest");

useWorkbench().block = blocks[0];
await wrapper.vm.$nextTick();
expect(scrolled).toHaveBeenLastCalledWith("1Welcome", "nearest");

wrapper.unmount();
});
});
15 changes: 13 additions & 2 deletions resources/js/components/Factory/Document/DocumentOutline.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<nav :aria-labelledby="`${id}-title`" class="px-2 py-3">
<nav ref="nav" :aria-labelledby="`${id}-title`" class="px-2 py-3">
<div class="flex h-11 items-center justify-between pl-2 lg:h-8">
<h2 :id="`${id}-title`" class="text-xs font-semibold text-grey-600">
Outline
Expand Down Expand Up @@ -43,7 +43,7 @@
</template>

<script setup lang="ts">
import { computed, useId } from "vue";
import { computed, onMounted, ref, useId, watch } from "vue";
import { D9Icon } from "@deck9/ui";
import { useForm, useWorkbench } from "@/stores";
import { plainText } from "./questionSummary";
Expand Down Expand Up @@ -75,4 +75,15 @@ const items = computed(() => {

const isCurrent = (block: FormBlockModel) =>
workbench.block?.uuid === block.uuid;

const nav = ref<HTMLElement | null>(null);

// keeps the open question in view, also when the outline opens as a panel
const showCurrent = () =>
nav.value
?.querySelector("[aria-current=true]")
?.scrollIntoView({ block: "nearest" });

onMounted(showCurrent);
watch(() => workbench.block?.uuid, showCurrent, { flush: "post" });
</script>
111 changes: 106 additions & 5 deletions resources/js/components/Factory/Document/DocumentQuestion.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it } from "vitest";
import { useWorkbench } from "@/stores";
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import DocumentQuestion from "./DocumentQuestion.vue";

const question = {
Expand All @@ -22,11 +22,27 @@ const question = {
const mountQuestion = (block = question) =>
mount(DocumentQuestion, {
props: { block, number: 3 },
global: { stubs: { QuestionCard: { template: "<div>card</div>" } } },
global: {
stubs: {
QuestionCard: {
template: `<div tabindex="-1" class="card">card<div contenteditable="true" class="text" /></div>`,
},
},
},
attachTo: document.body,
});

describe("a closed question in the document", () => {
beforeEach(() => useWorkbench().$reset());
beforeEach(() => {
// the shared test store keeps its spies' calls
vi.clearAllMocks();
useWorkbench().$reset();
useForm().$reset();
});

afterEach(() => {
document.body.innerHTML = "";
});

it("shows its number, text and a meta line, never its random id", () => {
const text = mountQuestion().find("button").text();
Expand Down Expand Up @@ -62,4 +78,89 @@ describe("a closed question in the document", () => {
expect(wrapper.text()).toContain("card");
expect(wrapper.find("button").exists()).toBe(false);
});

it("moves with Alt+Up/Down, closed or open", async () => {
const store = useForm();
const workbench = useWorkbench();
const next = { id: 8, uuid: "next", type: "radio" } as FormBlockModel;
store.blocks = [question, next];
const wrapper = mountQuestion();

await wrapper.find("button").trigger("keydown", {
key: "ArrowDown",
altKey: true,
});
expect(store.changeBlockSequence).toHaveBeenCalledWith(
false,
1,
question,
);

workbench.block = question;
await wrapper.vm.$nextTick();
await wrapper.find(".card").trigger("keydown", {
key: "ArrowUp",
altKey: true,
});
// already first: nothing moves
expect(store.changeBlockSequence).toHaveBeenCalledTimes(1);

// a plain arrow or one with Shift is the text field's
await wrapper.find(".card").trigger("keydown", { key: "ArrowDown" });
await wrapper.find(".card").trigger("keydown", {
key: "ArrowDown",
altKey: true,
shiftKey: true,
});
expect(store.changeBlockSequence).toHaveBeenCalledTimes(1);
});

it("closes with Escape and gives focus back to its row", async () => {
const workbench = useWorkbench();
vi.mocked(workbench.clearWorkbench).mockImplementation(() => {
workbench.block = null;
});
workbench.block = question;
const wrapper = mountQuestion();

await wrapper.find(".card").trigger("keydown", { key: "Escape" });
await flushPromises();

expect(workbench.clearWorkbench).toHaveBeenCalled();
expect(document.activeElement?.tagName).toBe("BUTTON");
expect(document.activeElement?.textContent).toContain(
"Which days can you help?",
);
});

it("closes with Escape from the question text, which cancels every Escape", async () => {
const workbench = useWorkbench();
workbench.block = question;
const wrapper = mountQuestion();
const text = wrapper.find(".text");
text.element.addEventListener("keydown", (event) =>
event.preventDefault(),
);

await text.trigger("keydown", { key: "Escape" });
await flushPromises();

expect(workbench.clearWorkbench).toHaveBeenCalled();
});

it("leaves Escape to a select or menu inside the card", async () => {
const workbench = useWorkbench();
workbench.block = question;
const wrapper = mountQuestion();
const card = wrapper.find(".card");
// like a listbox that closes on Escape
card.element.addEventListener("keydown", (event) =>
event.preventDefault(),
);

await card.trigger("keydown", { key: "Escape" });
await flushPromises();

expect(workbench.clearWorkbench).not.toHaveBeenCalled();
});
});
Loading