From c93b245298e2d1bb6e9a0874c1a4deaafceda728 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:18:02 +0200 Subject: [PATCH] overhaul hook menus --- web/app/assets/css/main.css | 36 +- .../components/editor/IconStack.nuxt.test.ts | 6 +- web/app/components/editor/IconStack.vue | 10 +- .../components/editor/NameEditor.nuxt.test.ts | 55 +- web/app/components/editor/NameEditor.vue | 40 +- .../drag-handle/BlockHandle.nuxt.test.ts | 47 +- .../editor/drag-handle/BlockHandle.vue | 14 +- .../drag-handle/menu-options/CoreMenu.vue | 3 +- .../menu-options/DefaultOptions.vue | 3 +- .../menu-options/MetricBlockMenu.vue | 3 +- .../drag-handle/menu-options/SplitDocMenu.vue | 3 +- .../SplitDocParameterListItemMenu.vue | 3 +- .../SplitDocParameterListMenu.vue | 3 +- .../menu-options/SplitDocRightSideMenu.vue | 3 +- .../drag-handle/menu-options/test-helpers.ts | 3 +- .../hooks/BlockHookDiffMarkers.nuxt.test.ts | 27 +- .../editor/hooks/BlockHookDiffMarkers.vue | 18 +- .../editor/hooks/HookConfigPanel.nuxt.test.ts | 276 ++++++ .../editor/hooks/HookConfigPanel.vue | 143 +++ .../editor/hooks/HookDiffMarker.nuxt.test.ts | 4 +- .../editor/hooks/HookDiffMarker.vue | 10 +- .../editor/hooks/HookExplanation.nuxt.test.ts | 60 -- .../editor/hooks/HookExplanation.vue | 29 - .../HookInputField.nuxt.test.ts | 10 +- .../{url-watcher => }/HookInputField.vue | 4 +- .../editor/hooks/HookMenuContent.nuxt.test.ts | 319 ++++-- .../editor/hooks/HookMenuContent.vue | 181 ++-- .../editor/hooks/HookMenuFooter.nuxt.test.ts | 131 +++ .../editor/hooks/HookMenuFooter.vue | 91 ++ .../editor/hooks/HookMenuHeader.nuxt.test.ts | 168 ++++ .../editor/hooks/HookMenuHeader.vue | 104 ++ .../editor/hooks/HookNotice.nuxt.test.ts | 150 +++ .../components/editor/hooks/HookNotice.vue | 82 ++ .../editor/hooks/HookNoticeValue.nuxt.test.ts | 30 + .../editor/hooks/HookNoticeValue.vue | 14 + .../hooks/HookReadonlyField.nuxt.test.ts | 86 +- .../editor/hooks/HookReadonlyField.vue | 32 +- .../editor/hooks/HookSubTrigger.nuxt.test.ts | 111 ++- .../editor/hooks/HookSubTrigger.vue | 67 +- .../ConfigMenu.nuxt.test.ts | 400 ++++---- .../container-image-watcher/ConfigMenu.vue | 395 +++----- .../HookInputField.nuxt.test.ts | 83 -- .../HookInputField.vue | 27 - .../github-tracking/ConfigMenu.nuxt.test.ts | 492 ++++++---- .../hooks/github-tracking/ConfigMenu.vue | 922 ++++++++---------- .../editor/hooks/hook-actions.nuxt.test.ts | 249 +++++ .../components/editor/hooks/hook-actions.ts | 131 +++ .../editor/hooks/hook-decorator.test.ts | 57 ++ .../components/editor/hooks/hook-decorator.ts | 66 +- .../components/editor/hooks/hook-menu.test.ts | 31 + web/app/components/editor/hooks/hook-menu.ts | 33 + .../editor/hooks/hook-status.test.ts | 127 +++ .../components/editor/hooks/hook-status.ts | 71 ++ .../ConfigMenu.nuxt.test.ts | 646 ++++++++---- .../hooks/scheduled-reminder/ConfigMenu.vue | 556 ++++++----- .../components/editor/hooks/test-helpers.ts | 60 +- .../hooks/url-watcher/ConfigMenu.nuxt.test.ts | 368 ++++--- .../editor/hooks/url-watcher/ConfigMenu.vue | 394 +++----- web/app/utils/url.test.ts | 23 + web/app/utils/url.ts | 4 + web/eslint.config.mjs | 4 +- web/i18n/locales/en/editor.json | 130 ++- 62 files changed, 5126 insertions(+), 2522 deletions(-) create mode 100644 web/app/components/editor/hooks/HookConfigPanel.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/HookConfigPanel.vue delete mode 100644 web/app/components/editor/hooks/HookExplanation.nuxt.test.ts delete mode 100644 web/app/components/editor/hooks/HookExplanation.vue rename web/app/components/editor/hooks/{url-watcher => }/HookInputField.nuxt.test.ts (88%) rename web/app/components/editor/hooks/{url-watcher => }/HookInputField.vue (91%) create mode 100644 web/app/components/editor/hooks/HookMenuFooter.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/HookMenuFooter.vue create mode 100644 web/app/components/editor/hooks/HookMenuHeader.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/HookMenuHeader.vue create mode 100644 web/app/components/editor/hooks/HookNotice.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/HookNotice.vue create mode 100644 web/app/components/editor/hooks/HookNoticeValue.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/HookNoticeValue.vue delete mode 100644 web/app/components/editor/hooks/container-image-watcher/HookInputField.nuxt.test.ts delete mode 100644 web/app/components/editor/hooks/container-image-watcher/HookInputField.vue create mode 100644 web/app/components/editor/hooks/hook-actions.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/hook-actions.ts create mode 100644 web/app/components/editor/hooks/hook-menu.test.ts create mode 100644 web/app/components/editor/hooks/hook-menu.ts create mode 100644 web/app/components/editor/hooks/hook-status.test.ts create mode 100644 web/app/components/editor/hooks/hook-status.ts diff --git a/web/app/assets/css/main.css b/web/app/assets/css/main.css index 77208997..687f555f 100644 --- a/web/app/assets/css/main.css +++ b/web/app/assets/css/main.css @@ -36,14 +36,17 @@ --radius: 0.35rem; --status-info: oklch(0.5309 0.1628 244.25); + --status-info-foreground: oklch(0.42 0.13 244.25); --status-success: oklch(0.6371 0.1749 146.7); + --status-success-foreground: oklch(0.42 0.13 146.7); --status-warning: oklch(0.7074 0.1455 95.22); + --status-warning-foreground: oklch(0.45 0.095 85); --status-error: oklch(0.577 0.245 27.325); --drag-target: oklch(0.8985 0.0419 244.63); - --hook-decoration: oklch(67.323% 0.14096 75.998); - --hook-status-fresh: oklch(0.6 0.118 184.704); - --hook-status-stale: oklch(67.323% 0.14096 75.998); + --hook-status-fresh: var(--status-success); + --hook-status-triggered: var(--status-info); + --hook-status-needs-attention: var(--status-warning); --comment-highlight: oklch(0.8633 0.1764 89.91); --http-method-get: oklch(0.5002 0.1962 259.56); @@ -213,9 +216,12 @@ --ring: oklch(0.439 0 0); --drag-target: oklch(0.6603 0.1406 244.63 / 35.99%); - --hook-decoration: oklch(68.662% 0.1396 91.469); - --hook-status-fresh: oklch(0.696 0.17 162.48); - --hook-status-stale: oklch(68.662% 0.1396 91.469); + --status-info-foreground: oklch(0.8 0.1 244.25); + --status-success-foreground: oklch(0.82 0.15 146.7); + --status-warning-foreground: oklch(0.85 0.13 95.22); + --hook-status-fresh: var(--status-success); + --hook-status-triggered: var(--status-info); + --hook-status-needs-attention: var(--status-warning); --http-method-get: oklch(0.6773 0.1637 247.59); --http-method-post: oklch(0.6613 0.1999 140.25); @@ -320,14 +326,17 @@ --radius-xl: calc(var(--radius) + 4px); --color-status-info: var(--status-info); + --color-status-info-foreground: var(--status-info-foreground); --color-status-success: var(--status-success); + --color-status-success-foreground: var(--status-success-foreground); --color-status-warning: var(--status-warning); + --color-status-warning-foreground: var(--status-warning-foreground); --color-status-error: var(--status-error); --color-drag-target: var(--drag-target); - --color-hook-decoration: var(--hook-decoration); --color-hook-status-fresh: var(--hook-status-fresh); - --color-hook-status-stale: var(--hook-status-stale); + --color-hook-status-triggered: var(--hook-status-triggered); + --color-hook-status-needs-attention: var(--hook-status-needs-attention); --color-comment-highlight: var(--comment-highlight); --color-http-method-get: var(--http-method-get); @@ -659,6 +668,17 @@ @apply pointer-events-none; } +/* the hook bar decoration and the hook icon of a block that has a triggered + hook and one that needs attention. They are striped in both status + colours. */ +@utility bg-hook-status-mixed { + background-image: repeating-linear-gradient( + -45deg, + var(--hook-status-triggered) 0 0.25rem, + var(--hook-status-needs-attention) 0.25rem 0.5rem + ); +} + .diff-added { @apply bg-diff-added; } diff --git a/web/app/components/editor/IconStack.nuxt.test.ts b/web/app/components/editor/IconStack.nuxt.test.ts index b7257b86..bb2fd666 100644 --- a/web/app/components/editor/IconStack.nuxt.test.ts +++ b/web/app/components/editor/IconStack.nuxt.test.ts @@ -105,16 +105,16 @@ describe("", { concurrent: false }, () => { expect(renderedIconNames(wrapper)).toEqual(["lucide:check"]) }) - it("puts stale hooks first", async ({ expect }) => { + it("puts triggered hooks first", async ({ expect }) => { const wrapper = await mountStack({ icons: [ person("Fresh", { icon: "lucide:bell" }), - person("Stale", { icon: "lucide:bell", staleHook: true }), + person("Triggered", { icon: "lucide:bell", triggeredHook: true }), ], }) expect(avatarLabels(wrapper).at(0)).toBe("") - expect(wrapper.findAll("[data-hook-stale]")).toHaveLength(1) + expect(wrapper.findAll("[data-hook-triggered]")).toHaveLength(1) }) it("orders hooks of equal freshness by when they last ran", async ({ diff --git a/web/app/components/editor/IconStack.vue b/web/app/components/editor/IconStack.vue index dd27c798..95689a0c 100644 --- a/web/app/components/editor/IconStack.vue +++ b/web/app/components/editor/IconStack.vue @@ -8,7 +8,7 @@ export interface IconMetadata { url?: string icon?: string approved?: boolean - staleHook?: boolean + triggeredHook?: boolean hookUpdatedAt?: Date | string } @@ -24,9 +24,9 @@ const visible = computed(() => { const icons = props.icons return icons .sort((a, b) => { - if (a.staleHook && !b.staleHook) { + if (a.triggeredHook && !b.triggeredHook) { return -1 - } else if (!a.staleHook && b.staleHook) { + } else if (!a.triggeredHook && b.triggeredHook) { return 1 } @@ -79,7 +79,9 @@ const extra = computed(() => Math.max(0, props.icons.length - 3)) diff --git a/web/app/components/editor/NameEditor.nuxt.test.ts b/web/app/components/editor/NameEditor.nuxt.test.ts index 65669f79..ad57f08a 100644 --- a/web/app/components/editor/NameEditor.nuxt.test.ts +++ b/web/app/components/editor/NameEditor.nuxt.test.ts @@ -14,6 +14,7 @@ import NameEditor from "./NameEditor.vue" import IconPicker from "./IconPicker.vue" import DiffTitle from "./diff/DiffTitle.vue" import HookDiffMarker from "./hooks/HookDiffMarker.vue" +import { HOOK_BAR_STATUS_CLASS } from "./hooks/hook-status" import ReviewerList from "./ReviewerList.vue" import { clearQueryCache, @@ -47,6 +48,8 @@ const TARGET_BRANCH_ID = makeXid("branchb") const ME = makeXid("usme") const REVIEWABLE_ACTION_DELAY_MS = 300 +// the stored state of a website hook whose last check failed +const FAILED_STATE: DocumentHookStateURLWatcher = { status: "unreachable_url" } interface Branch { ydoc: Y.Doc @@ -323,9 +326,11 @@ describe("", { concurrent: false }, () => { expect( wrapper.find("[data-hook-status]").attributes("data-hook-status"), ).toBe("fresh") - expect(wrapper.find(".bg-hook-decoration").attributes("style")).toContain( - "display: none", - ) + expect( + Object.values(HOOK_BAR_STATUS_CLASS).some((c) => + wrapper.find(`.${c}`).exists(), + ), + ).toBe(false) }) it("reaches into both page margins for the hook handle's hover", async ({ @@ -592,12 +597,52 @@ describe("", { concurrent: false }, () => { expect( wrapper.find("[data-hook-status]").attributes("data-hook-status"), - ).toBe("stale") + ).toBe("triggered") expect( - wrapper.find(".bg-hook-decoration").attributes("style"), + wrapper.find(".bg-hook-status-triggered").attributes("style"), ).toBeUndefined() }) + it.for([ + { + name: "colours the page's hook icon and bar as a warning when a hook's check failed", + input: () => [{ ...pageHook("a.test"), state: FAILED_STATE }], + expected: { + status: "needs-attention", + bar: "bg-hook-status-needs-attention", + }, + }, + { + name: "stripes the page's hook icon and bar when one hook triggered and another failed", + input: () => [ + { ...pageHook("a.test"), score: "0" }, + { ...pageHook("b.test"), state: FAILED_STATE }, + ], + expected: { status: "mixed", bar: "bg-hook-status-mixed" }, + }, + { + name: "ignores the hooks of blocks when it colours the page's hook icon and bar", + input: () => [ + { ...pageHook("a.test"), score: "0" }, + { ...pageHook("b.test"), blockId: "block-1", state: FAILED_STATE }, + ], + expected: { status: "triggered", bar: "bg-hook-status-triggered" }, + }, + ])("$name", async ({ input, expected }, { expect }) => { + const wrapper = await mountEditor({ activeBranchHooks: input() }) + const bar = wrapper.get(`.${expected.bar}`) + + expect( + wrapper.find("[data-hook-status]").attributes("data-hook-status"), + ).toBe(expected.status) + expect(bar.attributes("style")).toBeUndefined() + expect( + bar + .classes() + .filter((c) => Object.values(HOOK_BAR_STATUS_CLASS).includes(c)), + ).toEqual([expected.bar]) + }) + it("offers no review actions while the branch has none", async ({ expect, }) => { diff --git a/web/app/components/editor/NameEditor.vue b/web/app/components/editor/NameEditor.vue index ece2a139..1b479734 100644 --- a/web/app/components/editor/NameEditor.vue +++ b/web/app/components/editor/NameEditor.vue @@ -20,6 +20,11 @@ import DocumentTagList from "./DocumentTagList.vue" import ReviewerList from "./ReviewerList.vue" import HookDiffMarker from "./hooks/HookDiffMarker.vue" import { blockChangeCount, diffHooks } from "./hooks/hook-diff" +import { + HOOK_BAR_STATUS_CLASS, + HOOK_ICON_STATUS_CLASS, + hookGroupStatus, +} from "./hooks/hook-status" import { showToastMessage } from "../toast" import { DEFAULT_HIGHLIGHT_OVERLAY_PADDING, @@ -88,14 +93,15 @@ const showTitleDiff = computed( // a page with no hooks of its own has no status to show, which leaves the // handle its neutral colour rather than tinting it as healthy -const hookStatus = computed(() => { - const hooks = props.activeBranchHooks.filter((h) => h.blockId === null) - if (!hooks.length) { - return null - } - - return hooks.some((h) => Number(h.score) === 0) ? "stale" : "fresh" -}) +const hookStatus = computed(() => + hookGroupStatus(props.activeBranchHooks.filter((h) => h.blockId === null)), +) +// the bar beside the title takes the same status as the hook icon +const hookBarClass = computed(() => + hookStatus.value && hookStatus.value !== "fresh" + ? HOOK_BAR_STATUS_CLASS[hookStatus.value] + : null, +) const hookMenuOpen = ref(false) const rootElem = useTemplateRef("name-editor-root") const nameLineElem = useTemplateRef("name-line") @@ -408,8 +414,13 @@ async function executeReviewableAction() { class="relative mt-[0.8rem] mb-2 w-[calc(100%-2.5rem)] lg:mt-8 lg:mb-5 lg:w-[calc(100%-6.25rem)]" >
diff --git a/web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts b/web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts index 0621d629..8a5eb92d 100644 --- a/web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts +++ b/web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts @@ -18,7 +18,20 @@ import { const MENU_CLOSE_ANIMATION_MS = 150 function hook(blockId: string, score: number): DocumentHook { - return { blockId: blockId, score: score } as unknown as DocumentHook + return { + blockId: blockId, + score: score, + state: { status: "active" }, + } as unknown as DocumentHook +} + +// a hook whose last check failed +function failedHook(blockId: string): DocumentHook { + return { + blockId: blockId, + score: 0, + state: { status: "unreachable_url" }, + } as unknown as DocumentHook } // the editor the handle reads the hovered node through @@ -176,7 +189,7 @@ describe("", { concurrent: false }, () => { ).toBe("fresh") }) - it("marks the hook icon stale when a hook has run out", async ({ + it("marks the hook icon triggered when a hook has run out", async ({ expect, }) => { const wrapper = await mountHandle({ @@ -187,7 +200,35 @@ describe("", { concurrent: false }, () => { expect( wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"), - ).toBe("stale") + ).toBe("triggered") + }) + + it("marks the hook icon as needing attention when a hook's check failed", async ({ + expect, + }) => { + const wrapper = await mountHandle({ + activeBranchHooks: [hook("block-1", 1), failedHook("block-1")], + }) + hoverNode(wrapper, 10) + await nextTick() + + expect( + wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"), + ).toBe("needs-attention") + }) + + it("marks the hook icon mixed when one hook triggered and another failed", async ({ + expect, + }) => { + const wrapper = await mountHandle({ + activeBranchHooks: [hook("block-1", 0), failedHook("block-1")], + }) + hoverNode(wrapper, 10) + await nextTick() + + expect( + wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"), + ).toBe("mixed") }) it("ignores hooks belonging to other blocks", async ({ expect }) => { diff --git a/web/app/components/editor/drag-handle/BlockHandle.vue b/web/app/components/editor/drag-handle/BlockHandle.vue index f39fa80f..733cc164 100644 --- a/web/app/components/editor/drag-handle/BlockHandle.vue +++ b/web/app/components/editor/drag-handle/BlockHandle.vue @@ -6,6 +6,7 @@ import type { Node } from "@tiptap/pm/model" import type { HocuspocusProvider } from "@hocuspocus/provider" import { cn } from "~/lib/utils" import HookMenuContent from "../hooks/HookMenuContent.vue" +import { HOOK_ICON_STATUS_CLASS, hookGroupStatus } from "../hooks/hook-status" import CoreMenu from "./menu-options/CoreMenu.vue" import { useHighlightOverlay } from "../highlight-overlay" @@ -63,11 +64,7 @@ const hoveredMetadata = computed(() => { nodePos: hoveredNodePos.value, nodeId: nodeId, nodeHooks: hooks.length ? hooks : null, - nodeHookStatus: hooks.length - ? hooks.some((h) => Number(h.score) === 0) - ? ("stale" as const) - : ("fresh" as const) - : null, + nodeHookStatus: hookGroupStatus(hooks), } }) const compactHandles = computed(() => { @@ -229,12 +226,7 @@ function dragEnd() {
diff --git a/web/app/components/editor/drag-handle/menu-options/CoreMenu.vue b/web/app/components/editor/drag-handle/menu-options/CoreMenu.vue index 65ea84fc..925343e6 100644 --- a/web/app/components/editor/drag-handle/menu-options/CoreMenu.vue +++ b/web/app/components/editor/drag-handle/menu-options/CoreMenu.vue @@ -1,4 +1,5 @@