overhaul hook menus

This commit is contained in:
swithek 2026-10-03 19:18:02 +02:00
parent 5a0f75c093
commit c93b245298
62 changed files with 5126 additions and 2522 deletions

View File

@ -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;
}

View File

@ -105,16 +105,16 @@ describe("<IconStack>", { 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 ({

View File

@ -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))
<ShadcnUiAvatarFallback v-if="im.icon">
<Icon
:name="im.icon"
:data-hook-stale="im.staleHook ? 'stale' : undefined"
:data-hook-triggered="
im.triggeredHook ? 'triggered' : undefined
"
class="size-3.5"
/>
</ShadcnUiAvatarFallback>

View File

@ -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("<NameEditor>", { 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("<NameEditor>", { 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,
}) => {

View File

@ -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<HTMLElement>("name-editor-root")
const nameLineElem = useTemplateRef<HTMLElement>("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)]"
>
<div
v-show="hookStatus === 'stale'"
class="pointer-events-none absolute top-1/2 -left-5 h-full w-1.25 -translate-y-1/2 rounded-r-lg bg-hook-decoration in-data-compact-view:rounded-full lg:-left-12.5 lg:h-[calc(100%+2rem)]"
v-show="hookBarClass"
:class="
cn(
'pointer-events-none absolute top-1/2 -left-5 h-full w-1.25 -translate-y-1/2 rounded-r-lg in-data-compact-view:rounded-full lg:-left-12.5 lg:h-[calc(100%+2rem)]',
hookBarClass,
)
"
/>
<!--
the line reaches into the page margins on both sides, so hovering
@ -460,7 +471,7 @@ async function executeReviewableAction() {
title takes the same handle at the same width there and
keeps the hook button where the block ones do. The
dots take the block handles' backdrop too, since they
sit over the page's stale hook bar
sit over the page's triggered hook bar
-->
<span
class="relative h-5 w-3 overflow-hidden rounded-sm bg-background-translucent lg:hidden"
@ -473,7 +484,12 @@ async function executeReviewableAction() {
<Icon
name="mingcute:leaf-line"
:data-hook-status="hookStatus"
class="mt-0.25 hidden size-4.5 data-[hook-status=fresh]:text-hook-status-fresh data-[hook-status=stale]:text-hook-status-stale lg:block"
:class="
cn(
'mt-0.25 hidden size-4.5 lg:block',
HOOK_ICON_STATUS_CLASS,
)
"
/>
</span>
<span class="sr-only">

View File

@ -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("<BlockHandle>", { 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("<BlockHandle>", { 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 }) => {

View File

@ -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() {
<Icon
:data-hook-status="hoveredMetadata?.nodeHookStatus"
name="mingcute:leaf-line"
:class="
cn(
'mt-0.25 size-4.5',
'data-[hook-status=fresh]:text-hook-status-fresh data-[hook-status=stale]:text-hook-status-stale',
)
"
:class="cn('mt-0.25 size-4.5', HOOK_ICON_STATUS_CLASS)"
/>
</div>
<span class="sr-only">

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
import type { JSONContent } from "@tiptap/core"
@ -31,7 +32,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()
const emit = defineEmits<{

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { JSONContent } from "@tiptap/core"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
@ -12,7 +13,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
import { MetricBlockWidth } from "../../blocks/metrics/utils"
@ -10,7 +11,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
import { SplitDocumentationLeftSide } from "../../blocks/split-documentation"
@ -11,7 +12,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
@ -9,7 +10,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
@ -9,7 +10,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { HookGroupStatus } from "../../hooks/hook-status"
import type { Node } from "@tiptap/pm/model"
import type { Editor } from "@tiptap/vue-3"
import type { RightSideBlockType } from "../../blocks/split-documentation"
@ -11,7 +12,7 @@ const props = defineProps<{
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
} | null
}>()

View File

@ -1,3 +1,4 @@
import type { HookGroupStatus } from "../../hooks/hook-status"
// shared helpers for the drag-handle menu option suites. Test-only: the
// app/**/test-helpers.ts coverage exclude keeps this out of the
// denominator, and nothing here is imported by app code.
@ -18,7 +19,7 @@ export interface HoveredBlock {
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
nodeHookStatus: HookGroupStatus | null
}
// the block the drag handle is pointing at, reduced to what the option

View File

@ -97,7 +97,11 @@ describe("<BlockHookDiffMarkers>", { concurrent: false }, () => {
})
it.for([
{ name: "stale for a triggered hook", score: "0", expected: "stale" },
{
name: "triggered for a triggered hook",
score: "0",
expected: "triggered",
},
{ name: "neutral for removed hooks alone", score: null, expected: null },
])("colours the hook icon $name", async ({ score, expected }, { expect }) => {
const { editor } = fakeEditor([
@ -117,6 +121,27 @@ describe("<BlockHookDiffMarkers>", { concurrent: false }, () => {
)
})
it("colours the hook icon as the hook button would be for a failed check", async ({
expect,
}) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [
{
...urlHook("block-1", "https://new.test"),
state: { status: "unreachable_url" },
},
],
})
expect(wrapper.getComponent(HookDiffMarker).props("hookStatus")).toBe(
"needs-attention",
)
})
it("centres the marker on a block whose handle is centred", async ({
expect,
}) => {

View File

@ -2,6 +2,7 @@
import type { Editor } from "@tiptap/core"
import HookDiffMarker from "./HookDiffMarker.vue"
import { blockChangeCount, diffHooks, type HookChangeCount } from "./hook-diff"
import { hookGroupStatus, type HookGroupStatus } from "./hook-status"
import { handlePositionByNodeType } from "../drag-handle/config"
// the height of the hook button on a wide screen. A block whose handle is
@ -11,7 +12,7 @@ const HANDLE_HEIGHT_REM = 1.375
interface MarkerPlacement {
blockId: string
count: HookChangeCount
hookStatus: "fresh" | "stale" | null
hookStatus: HookGroupStatus | null
top: number
right: number
}
@ -104,23 +105,16 @@ function placeMarkers() {
{
blockId: block.id,
count: block.count,
hookStatus: hookStatus(block.id),
// the marker's hook icon is coloured as the hook button would be
hookStatus: hookGroupStatus(
props.activeBranchHooks.filter((h) => h.blockId === block.id),
),
top: Math.round(top - rootRect.top),
right: Math.round(rootRect.width - anchor),
},
]
})
}
// hookStatus colours the marker's hook icon as the hook button would be
function hookStatus(blockId: string): "fresh" | "stale" | null {
const hooks = props.activeBranchHooks.filter((h) => h.blockId === blockId)
if (!hooks.length) {
return null
}
return hooks.some((h) => Number(h.score) === 0) ? "stale" : "fresh"
}
</script>
<template>

View File

@ -0,0 +1,276 @@
import { beforeEach, describe, it, vi } from "vitest"
import HookConfigPanel from "./HookConfigPanel.vue"
import {
hookNotice,
iconNames,
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
} from "./test-helpers"
import { DiffStatus } from "../diff/position-map"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
const TITLE = "Website Changes"
const NOTICE = "Maintainers are notified"
const FIELD = "the field"
const SUBTITLE = "oxynote.test"
const DETAIL = "unreachable"
// the editor store and the teleported menu bodies are shared, so these
// tests cannot interleave
describe("<HookConfigPanel>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
it("opens its notice, fields and confirm button from its row", async ({
expect,
}) => {
await mountPanel()
await openHookSubMenu(TITLE)
expect(hookNotice().textContent).toContain(NOTICE)
expect(
document.body.querySelector(`input[placeholder='${FIELD}']`),
).not.toBeNull()
expect(menuButton(t("editor.hooks.create")).disabled).toBe(false)
})
it("passes its subtitle's detail on to its row", async ({ expect }) => {
await mountPanel()
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", {
subtext: SUBTITLE,
detail: DETAIL,
}),
)
})
it("confirms a new hook", async ({ expect }) => {
const onSubmit = vi.fn()
await mountPanel({ onSubmit: onSubmit })
await openHookSubMenu(TITLE)
menuButton(t("editor.hooks.create")).click()
expect(onSubmit).toHaveBeenCalledTimes(1)
})
it("updates an existing hook", async ({ expect }) => {
const onSubmit = vi.fn()
const onDelete = vi.fn()
await mountPanel({
hook: makeHook(),
onSubmit: onSubmit,
onDelete: onDelete,
})
await openHookSubMenu(TITLE)
menuButton(t("editor.hooks.update")).click()
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onDelete).toHaveBeenCalledTimes(0)
})
it("deletes an existing hook", async ({ expect }) => {
const onSubmit = vi.fn()
const onDelete = vi.fn()
await mountPanel({
hook: makeHook(),
onSubmit: onSubmit,
onDelete: onDelete,
})
await openHookSubMenu(TITLE)
menuButton(t("editor.hooks.delete")).click()
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledTimes(0)
})
it("tells a reader in reading mode how to change the hook", async ({
expect,
}) => {
useEditorMeta().setEditable(false)
await mountPanel({ hook: makeHook() })
await openHookSubMenu(TITLE)
expect(menuText()).toContain(t("editor.hooks.read-only-mode"))
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
})
it("sends a reader on a protected branch to the draft branch", async ({
expect,
}) => {
useEditorStore().setActiveBranchProtected(true)
await mountPanel({ hook: makeHook() })
await openHookSubMenu(TITLE)
expect(menuText()).toContain(t("editor.hooks.read-only-protected"))
expect(menuText()).not.toContain(t("editor.hooks.update"))
})
it("says how to edit while the diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
await mountPanel({ hook: makeHook() })
await openHookSubMenu(TITLE)
expect(collapsed(menuText())).toContain(
t("editor.hooks.showing-changes", {
toggle: t("editor.name-editor.review-workflow.show-diff"),
}),
)
expect(menuText()).not.toContain(t("editor.hooks.update"))
})
it.for<{
name: string
input: Partial<DocumentHook>
expected: { status: string; icon: string }
}>([
{
name: "a fresh hook plainly",
input: { score: "100", state: { status: "active" } },
expected: { status: "fresh", icon: "mingcute:earth-2-line" },
},
{
name: "a triggered hook in its colour",
input: { score: "0", state: { status: "active" } },
expected: { status: "triggered", icon: "mingcute:earth-2-line" },
},
{
name: "a failed check as a warning",
input: { score: "100", state: { status: "unreachable_url" } },
expected: { status: "needs-attention", icon: "mingcute:alert-fill" },
},
])("shows $name", async ({ input, expected }, { expect }) => {
await mountPanel({ hook: makeHook(input) })
await openHookSubMenu(TITLE)
expect(hookNotice().dataset.hookStatus).toBe(expected.status)
expect(iconNames(hookNotice())).toEqual([expected.icon])
})
it("shows a setup notice plainly over a triggered hook", async ({
expect,
}) => {
await mountPanel({
hook: makeHook({ score: "0" }),
noticeStatus: "fresh",
noticeIcon: "mingcute:information-fill",
})
await openHookSubMenu(TITLE)
expect(hookNotice().dataset.hookStatus).toBe("fresh")
expect(iconNames(hookNotice())).toEqual(["mingcute:information-fill"])
})
it("dismisses a triggered hook from its notice", async ({ expect }) => {
const onAcknowledge = vi.fn()
await mountPanel({
hook: makeHook({ score: "0" }),
acknowledgeLabel: t("editor.hooks.reset"),
onAcknowledge: onAcknowledge,
})
await openHookSubMenu(TITLE)
const button = menuButton(t("editor.hooks.reset"))
button.click()
expect(onAcknowledge).toHaveBeenCalledTimes(1)
expect(hookNotice().contains(button)).toBe(true)
})
it("lets a read-only reader dismiss a triggered hook", async ({ expect }) => {
useEditorMeta().setEditable(false)
const onAcknowledge = vi.fn()
await mountPanel({
hook: makeHook({ score: "0" }),
acknowledgeLabel: t("editor.hooks.reset"),
onAcknowledge: onAcknowledge,
})
await openHookSubMenu(TITLE)
menuButton(t("editor.hooks.reset")).click()
expect(onAcknowledge).toHaveBeenCalledTimes(1)
})
it("leaves the dismissal out of the diff and says how to get it back", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
await mountPanel({
hook: makeHook({ score: "0" }),
diff: {
status: DiffStatus.Unchanged,
targetHook: null,
signColumn: false,
},
acknowledgeLabel: t("editor.hooks.reset"),
})
await openHookSubMenu(TITLE)
expect(menuText()).not.toContain(t("editor.hooks.reset"))
expect(collapsed(hookNotice().textContent)).toContain(
t("editor.hooks.dismiss-in-diff", {
toggle: t("editor.name-editor.review-workflow.show-diff"),
}),
)
})
it("says a removed hook was removed instead of what it does", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
await mountPanel({
hook: makeHook({ score: "0" }),
diff: { status: DiffStatus.Removed, targetHook: null, signColumn: false },
})
await openHookSubMenu(TITLE)
expect(hookNotice().textContent.trim()).toBe(t("editor.hooks.removed"))
expect(hookNotice().dataset.hookStatus).toBe("fresh")
})
})
function mountPanel(props: Record<string, unknown> = {}) {
return mountHookMenu(
defineComponent(
() => () =>
h(
HookConfigPanel,
{ icon: "mingcute:earth-2-line", ...props },
{
title: () => TITLE,
subtitle: () => SUBTITLE,
"subtitle-detail": () => DETAIL,
notice: () => NOTICE,
default: () => h("input", { placeholder: FIELD }),
},
),
),
{},
)
}
// a bold part sits on lines of its own in the template, which leaves
// spaces around it in the text
function collapsed(text: string): string {
return text.replace(/\s+/g, " ")
}

View File

@ -0,0 +1,143 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import HookMenuFooter from "./HookMenuFooter.vue"
import HookNotice from "./HookNotice.vue"
import HookSubTrigger from "./HookSubTrigger.vue"
import { DiffStatus } from "../diff/position-map"
import type { HookDiffContext } from "./hook-diff"
import { HOOK_SUBMENU_WIDTH_CLASS } from "./hook-menu"
import { hookStatus, type HookStatus } from "./hook-status"
const props = defineProps<{
hook?: DocumentHook | null // null/undefined means creating new
// set only while the diff is shown
diff?: HookDiffContext | null
// the type's icon, on the row and on the notice
icon: string
// set when the notice tells about setup, like a missing github
// connection, rather than about the hook
noticeStatus?: HookStatus
noticeIcon?: string
// the button that dismisses a triggered hook, left out when the type
// has no way to
acknowledgeLabel?: string
submitLabel?: string
submitIcon?: string
submitDisabled?: boolean
}>()
const emit = defineEmits<{
(e: "submit" | "delete" | "acknowledge"): void
}>()
const open = defineModel<boolean>("open", { default: false })
const editorStore = useEditorStore()
const { isEditable } = useEditorMeta()
const status = computed(() => (props.hook ? hookStatus(props.hook) : null))
const isRemoved = computed(() => props.diff?.status === DiffStatus.Removed)
const mode = computed(() => {
if (editorStore.reviewableDiffActive) {
return "diff"
}
if (!isEditable.value) {
return "read-only"
}
return props.hook ? "edit" : "create"
})
const shownNoticeStatus = computed<HookStatus>(() => {
if (isRemoved.value) {
return "fresh"
}
return props.noticeStatus ?? status.value ?? "fresh"
})
const shownNoticeIcon = computed(() =>
shownNoticeStatus.value === "needs-attention"
? "mingcute:alert-fill"
: (props.noticeIcon ?? props.icon),
)
// the diff shows two branches at once, so a hook is dismissed only once it
// is off
const canAcknowledge = computed(
() => !!props.acknowledgeLabel && mode.value !== "diff",
)
const showsDismissHint = computed(
() =>
mode.value === "diff" && status.value === "triggered" && !isRemoved.value,
)
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="open">
<HookSubTrigger
:hook="props.hook"
:diff="props.diff"
:status="status"
:icon="props.icon"
>
<template #title>
<slot name="title" />
</template>
<template v-if="$slots.subtitle" #subtitle>
<slot name="subtitle" />
</template>
<template v-if="$slots['subtitle-detail']" #subtitle-detail>
<slot name="subtitle-detail" />
</template>
</HookSubTrigger>
<!--
a select inside turns pointer events off, and the sub menu then
closes along with the select. So the content keeps them on
-->
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:collision-padding="8"
class="pointer-events-auto!"
>
<div :class="cn('flex flex-col', HOOK_SUBMENU_WIDTH_CLASS)">
<div class="flex flex-col gap-1.5 px-2 py-1.5">
<HookNotice
:status="shownNoticeStatus"
:icon="shownNoticeIcon"
:action-label="canAcknowledge ? props.acknowledgeLabel : undefined"
@action="emit('acknowledge')"
>
<template v-if="isRemoved">
{{ $t("editor.hooks.removed") }}
</template>
<template v-else>
<slot name="notice" />
<i18n-t
v-if="showsDismissHint"
scope="global"
keypath="editor.hooks.dismiss-in-diff"
tag="p"
class="mt-1"
>
<template #toggle>
<span class="font-semibold">
{{ $t("editor.name-editor.review-workflow.show-diff") }}
</span>
</template>
</i18n-t>
</template>
</HookNotice>
<slot />
</div>
<ShadcnUiDropdownMenuSeparator />
<HookMenuFooter
:mode="mode"
:submit-label="props.submitLabel"
:submit-icon="props.submitIcon"
:submit-disabled="props.submitDisabled"
@submit="emit('submit')"
@delete="emit('delete')"
/>
</div>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
</template>

View File

@ -22,7 +22,7 @@ describe("<HookDiffMarker>", { concurrent: false }, () => {
})
it("shows the hook icon in the hook's colour at rest", async ({ expect }) => {
const wrapper = await mountMarker({ hookStatus: "stale" })
const wrapper = await mountMarker({ hookStatus: "triggered" })
expect(hookIconSlot(wrapper).classes()).toContain("h-3.75")
expect(hookIconSlot(wrapper).classes()).not.toContain("h-0")
@ -30,7 +30,7 @@ describe("<HookDiffMarker>", { concurrent: false }, () => {
hookIconSlot(wrapper)
.get(".i-mingcute\\:leaf-line")
.attributes("data-hook-status"),
).toBe("stale")
).toBe("triggered")
expect(wrapper.classes()).not.toContain("translate-y-5.5")
})

View File

@ -2,11 +2,12 @@
import { cn } from "~/lib/utils"
import DiffCountPill from "../diff/DiffCountPill.vue"
import type { HookChangeCount } from "./hook-diff"
import { HOOK_ICON_STATUS_CLASS, type HookGroupStatus } from "./hook-status"
const props = defineProps<{
count: HookChangeCount
// the hook icon takes the colour the hook button would have
hookStatus: "fresh" | "stale" | null
hookStatus: HookGroupStatus | null
// the handle shows in the marker's spot, so the marker moves below it
lifted: boolean
}>()
@ -55,7 +56,12 @@ const showHookIcon = computed(() => !props.lifted || !isMinWidth1024px.value)
<Icon
name="mingcute:leaf-line"
:data-hook-status="props.hookStatus"
class="mt-0.75 size-2.5 shrink-0 text-foreground/50 data-[hook-status=fresh]:text-hook-status-fresh data-[hook-status=stale]:text-hook-status-stale lg:size-3"
:class="
cn(
'mt-0.75 size-2.5 shrink-0 text-foreground/50 lg:size-3',
HOOK_ICON_STATUS_CLASS,
)
"
/>
</span>
</DiffCountPill>

View File

@ -1,60 +0,0 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookExplanation from "./HookExplanation.vue"
import { t } from "~/components/test-helpers"
describe("<HookExplanation>", () => {
it.for([
{
name: "what a waiting block hook will do",
triggered: false,
nodeId: "block-1",
key: "editor.hooks.url-watcher.existing-item-block-explanation",
},
{
name: "what a waiting page hook will do",
triggered: false,
nodeId: null,
key: "editor.hooks.url-watcher.existing-item-full-document-explanation",
},
{
name: "what a triggered block hook did",
triggered: true,
nodeId: "block-1",
key: "editor.hooks.url-watcher.triggered-item-block-explanation",
},
{
name: "what a triggered page hook did",
triggered: true,
nodeId: null,
key: "editor.hooks.url-watcher.triggered-item-full-document-explanation",
},
])("says $name", async ({ triggered, nodeId, key }, { expect }) => {
const wrapper = await mountSuspended(HookExplanation, {
props: {
type: DocumentHookType.URLWatcher,
triggered: triggered,
nodeId: nodeId,
},
})
expect(wrapper.text()).toBe(t(key))
})
it("fills the message's value from its slot", async ({ expect }) => {
const wrapper = await mountSuspended(HookExplanation, {
props: {
type: DocumentHookType.ScheduledReminder,
triggered: false,
nodeId: "block-1",
},
slots: { value: () => "Oct 15, 2026" },
})
expect(wrapper.text()).toBe(
t("editor.hooks.scheduled-reminder.existing-item-block-explanation", {
value: "Oct 15, 2026",
}),
)
})
})

View File

@ -1,29 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{
// the hook's messages sit under its type in editor.hooks
type: DocumentHookType
// a triggered hook says what it did, one still waiting what it will do
triggered: boolean
nodeId: string | null // null means global
}>()
const keypath = computed(
() =>
`editor.hooks.${props.type}.${props.triggered ? "triggered" : "existing"}-item-${props.nodeId ? "block" : "full-document"}-explanation`,
)
</script>
<template>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="keypath"
tag="div"
class="w-full text-center text-xs break-words text-muted-foreground"
>
<template #value>
<slot name="value" />
</template>
</i18n-t>
</div>
</template>

View File

@ -13,7 +13,7 @@ function mountField(props: Record<string, unknown> = {}) {
})
}
describe("<URLWatcherHookInputField>", () => {
describe("<HookInputField>", () => {
it("labels the field", async ({ expect }) => {
const wrapper = await mountField()
@ -71,9 +71,15 @@ describe("<URLWatcherHookInputField>", () => {
expect(wrapper.get("input").classes()).not.toContain("text-2sm")
})
it("asks the browser to validate the url", async ({ expect }) => {
it("is a plain text field by default", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("type")).toBe("text")
})
it("takes the input type the host asks for", async ({ expect }) => {
const wrapper = await mountField({ type: "url" })
expect(wrapper.get("input").attributes("type")).toBe("url")
})
})

View File

@ -4,9 +4,11 @@ const props = withDefaults(
disabled?: boolean
placeholder: string
inputClass?: string
type?: string
}>(),
{
inputClass: "text-2sm md:text-2sm px-2 h-[1.775rem]",
type: "text",
},
)
const value = defineModel<string | undefined>()
@ -19,7 +21,7 @@ const value = defineModel<string | undefined>()
<ShadcnUiInput
v-model="value"
:class="props.inputClass"
type="url"
:type="props.type"
:placeholder="props.placeholder"
:disabled="props.disabled"
disable-focus-effect

View File

@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"
import HookMenuContent from "./HookMenuContent.vue"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
typeInMenu,
} from "./test-helpers"
import {
clearQueryCache,
@ -15,6 +17,7 @@ import {
import {
clearTeleportedOverlays,
seedCapabilities,
settleMutations,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
@ -48,6 +51,16 @@ function urlWatcher(overrides: Partial<DocumentHook> = {}) {
})
}
function githubHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
id: "github-1",
type: DocumentHookType.GitHubTracking,
settings: { repository: "runbooks", branch: "main", paths: ["a.md"] },
state: { pathsChecksums: {}, status: "active" },
...overrides,
})
}
function mockGitHub(configured: boolean) {
seedCapabilities({ github: configured })
mockEndpoint("GET", "/api/github", () => ({
@ -74,20 +87,24 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
it("offers to add a hook", async ({ expect }) => {
it("titles the menu and offers to add a hook", async ({ expect }) => {
mockGitHub(true)
await mountContent()
expect(headerText()).toContain(t("editor.hooks.title"))
expect(menuText()).toContain(t(ADD_NEW))
})
it("offers every hook type once github is available", async ({ expect }) => {
it("offers every hook type and what it watches once github is available", async ({
expect,
}) => {
mockGitHub(true)
await mountContent()
@ -96,11 +113,15 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
await vi.waitFor(() => {
expect(menuText()).toContain(t("editor.hooks.github-tracking.title"))
}, WAIT_FOR_OPTIONS)
expect(menuText()).toContain(t("editor.hooks.scheduled-reminder.title"))
expect(menuText()).toContain(t("editor.hooks.url-watcher.title"))
expect(menuText()).toContain(
t("editor.hooks.container-image-watcher.title"),
)
for (const type of [
"scheduled-reminder",
"github-tracking",
"url-watcher",
"container-image-watcher",
]) {
expect(menuText()).toContain(t(`editor.hooks.${type}.title`))
expect(menuText()).toContain(t(`editor.hooks.${type}.description`))
}
})
it("leaves github out when the server has no integration", async ({
@ -133,13 +154,77 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
expect(menuText()).toContain(t("editor.hooks.scheduled-reminder.title"))
})
it("closes the type picker once a hook is created", async ({ expect }) => {
mockGitHub(true)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: makeXid("hook") }),
)
await mountContent()
await openHookSubMenu(t(ADD_NEW))
await openHookSubMenu(t("editor.hooks.url-watcher.title"))
await typeInMenu("oxynote.test")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
expect(calls).toHaveLength(1)
expect(menuText()).not.toContain(t("editor.hooks.url-watcher.description"))
})
it("passes on the type picker's call for the github settings", async ({
expect,
}) => {
mockGitHub(true)
const wrapper = await mountContent()
await openHookSubMenu(t(ADD_NEW))
await openHookSubMenu(t("editor.hooks.github-tracking.title"))
await vi.waitFor(() => {
expect(menuText()).toContain(
t("editor.hooks.github-tracking.not-connected.placeholder"),
)
}, WAIT_FOR_OPTIONS)
menuButton(
t("editor.hooks.github-tracking.not-connected.placeholder"),
).click()
await nextTick()
expect(
wrapper.findComponent(HookMenuContent).emitted("open-settings"),
).toEqual([["github"]])
})
it("passes on a github hook's call for the settings", async ({ expect }) => {
mockGitHub(true)
const wrapper = await mountContent({ activeBranchHooks: [githubHook()] })
await openHookSubMenu(t("editor.hooks.github-tracking.title"))
await vi.waitFor(() => {
expect(menuText()).toContain(
t("editor.hooks.github-tracking.not-connected.placeholder"),
)
}, WAIT_FOR_OPTIONS)
menuButton(
t("editor.hooks.github-tracking.not-connected.placeholder"),
).click()
await nextTick()
expect(
wrapper.findComponent(HookMenuContent).emitted("open-settings"),
).toEqual([["github"]])
})
it("lists the hooks already on the block", async ({ expect }) => {
mockGitHub(true)
await mountContent({ activeBranchHooks: [reminder(), urlWatcher()] })
expect(menuText()).toContain("Remind on")
expect(menuText()).toContain("Watching oxynote.test")
const rows = hookRows().map((row) => row.textContent)
expect(rows[0]).toContain(t("editor.hooks.scheduled-reminder.title"))
expect(rows[1]).toContain(t("editor.hooks.url-watcher.title"))
expect(rows[1]).toContain("oxynote.test")
})
it("leaves out hooks belonging to another block", async ({ expect }) => {
@ -149,92 +234,152 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
activeBranchHooks: [urlWatcher({ blockId: "block-2" })],
})
expect(menuText()).not.toContain("Watching oxynote.test")
expect(menuText()).not.toContain(t("editor.hooks.url-watcher.title"))
})
it("puts the hooks that have fired above the active ones", async ({
expect,
}) => {
it("lists the hooks in the order they were made", async ({ expect }) => {
mockGitHub(true)
await mountContent({
activeBranchHooks: [
urlWatcher({
id: "url-second",
settings: { url: "https://second.test" },
createdAt: new Date("2026-02-01T00:00:00Z"),
}),
urlWatcher({
id: "url-first",
settings: { url: "https://first.test" },
createdAt: new Date("2026-01-01T00:00:00Z"),
}),
],
})
const rows = hookRows().map((row) => row.textContent)
expect(rows[0]).toContain("first.test")
expect(rows[1]).toContain("second.test")
})
it("lists hooks made at the same moment by their id", async ({ expect }) => {
mockGitHub(true)
await mountContent({
activeBranchHooks: [
urlWatcher({ id: "url-b", settings: { url: "https://b.test" } }),
urlWatcher({ id: "url-a", settings: { url: "https://a.test" } }),
],
})
const rows = hookRows().map((row) => row.textContent)
expect(rows[0]).toContain("a.test")
expect(rows[1]).toContain("b.test")
})
it.for([
{
name: "a hook that was updated last",
input: { updatedAt: new Date("2026-03-01T00:00:00Z") },
},
{ name: "a triggered hook", input: { score: "0" } },
{
name: "a hook that needs attention",
input: { score: "0", state: { status: "unreachable_url" as const } },
},
])("keeps $name in its place", async ({ input }, { expect }) => {
mockGitHub(true)
await mountContent({
activeBranchHooks: [
urlWatcher({
id: "url-first",
settings: { url: "https://first.test" },
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-02-01T00:00:00Z"),
...input,
}),
urlWatcher({
id: "url-second",
settings: { url: "https://second.test" },
createdAt: new Date("2026-01-02T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
}),
],
})
const rows = hookRows().map((row) => row.textContent)
expect(rows[0]).toContain("first.test")
expect(rows[1]).toContain("second.test")
})
it("says all is fresh while no hook needs a look", async ({ expect }) => {
mockGitHub(true)
await mountContent({ activeBranchHooks: [reminder(), urlWatcher()] })
expect(headerText()).toContain(t("editor.hooks.status.fresh"))
})
it("counts the triggered hooks in its header", async ({ expect }) => {
mockGitHub(true)
await mountContent({
activeBranchHooks: [reminder(), urlWatcher({ score: "0" })],
})
const labels = Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((item) => item.textContent)
expect(labels[0]).toContain("Changes in oxynote.test")
expect(labels[1]).toContain("Remind on")
expect(headerText()).toContain(
t("editor.hooks.status.triggered", { count: 1 }),
)
})
it("orders hooks of the same kind by when they last ran", async ({
expect,
}) => {
mockGitHub(true)
await mountContent({
activeBranchHooks: [
urlWatcher({
id: "url-newer",
settings: { url: "https://newer.test" },
updatedAt: new Date("2026-02-01T00:00:00Z"),
}),
urlWatcher({
id: "url-older",
settings: { url: "https://older.test" },
updatedAt: new Date("2026-01-01T00:00:00Z"),
}),
],
})
const labels = Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((item) => item.textContent)
expect(labels[0]).toContain("older.test")
expect(labels[1]).toContain("newer.test")
})
it("shows only the add action for a block with no hooks", async ({
expect,
}) => {
it("explains an empty block and offers to add a hook", async ({ expect }) => {
mockGitHub(true)
await mountContent()
expect(document.body.querySelectorAll("[role^='menuitem']")).toHaveLength(1)
expect(menuText()).toContain(t("editor.hooks.empty-block"))
expect(menuText()).toContain(t("editor.hooks.empty-block-description"))
expect(hookRows()).toHaveLength(1)
expect(hookRows()[0]?.textContent).toContain(t(ADD_NEW))
})
it.for([
{
name: "block",
nodeId: "block-1",
title: "editor.hooks.empty-block",
description: "editor.hooks.empty-block-description",
},
{
name: "page",
nodeId: null,
title: "editor.hooks.empty-document",
description: "editor.hooks.empty-document-description",
},
])(
"explains a $name without hooks on a read only page",
async ({ nodeId, title, description }, { expect }) => {
mockGitHub(true)
useEditorMeta().setEditable(false)
await mountContent({ nodeId: nodeId })
expect(menuText()).toContain(t(title))
expect(menuText()).toContain(t(description))
expect(hookRows()).toHaveLength(0)
},
)
it("offers no add action on a read only page", async ({ expect }) => {
mockGitHub(true)
useEditorMeta().setEditable(false)
await mountContent({ activeBranchHooks: [urlWatcher()] })
expect(menuText()).toContain("Watching oxynote.test")
expect(menuText()).toContain(t("editor.hooks.url-watcher.title"))
expect(menuText()).not.toContain(t(ADD_NEW))
expect(menuText()).not.toContain(t("editor.hooks.empty-block"))
})
it.for([
{ name: "block", nodeId: "block-1", key: "editor.hooks.empty-block" },
{ name: "page", nodeId: null, key: "editor.hooks.empty-document" },
])(
"says a $name has no hooks on a read only page",
async ({ nodeId, key }, { expect }) => {
mockGitHub(true)
useEditorMeta().setEditable(false)
await mountContent({ nodeId: nodeId })
expect(menuText()).toContain(t(key))
expect(document.body.querySelectorAll("[role^='menuitem']")).toHaveLength(
0,
)
},
)
it("lists the active hooks alone while the diff is off", async ({
expect,
}) => {
@ -242,7 +387,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
await mountContent({ targetBranchHooks: [urlWatcher()] })
expect(menuText()).not.toContain("Watching oxynote.test")
expect(menuText()).not.toContain(t("editor.hooks.url-watcher.title"))
expect(menuText()).toContain(t(ADD_NEW))
})
@ -252,10 +397,15 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
useEditorStore().setReviewableDiffActive(true)
})
it("offers no add action", async ({ expect }) => {
it("offers no add action and says how to edit", async ({ expect }) => {
await mountContent({ activeBranchHooks: [urlWatcher()] })
expect(menuText()).not.toContain(t(ADD_NEW))
expect(menuText().replace(/\s+/g, " ")).toContain(
t("editor.hooks.showing-changes", {
toggle: t("editor.name-editor.review-workflow.show-diff"),
}),
)
})
it("lists a hook the active branch added", async ({ expect }) => {
@ -265,7 +415,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
})
const row = hookRows()[0]
expect(row?.textContent).toContain("Watching oxynote.test")
expect(row?.textContent).toContain(t("editor.hooks.url-watcher.title"))
expect(row?.textContent).toContain(t("editor.hooks.diff.added"))
expect(row?.classList).toContain("bg-diff-added/30")
})
@ -277,11 +427,25 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
})
const row = hookRows()[0]
expect(row?.textContent).toContain("Watching oxynote.test")
expect(row?.textContent).toContain(t("editor.hooks.url-watcher.title"))
expect(row?.textContent).toContain(t("editor.hooks.diff.removed"))
expect(row?.classList).toContain("bg-diff-removed/30")
})
it("leaves a removed hook out of the header's count", async ({
expect,
}) => {
await mountContent({
activeBranchHooks: [reminder()],
targetBranchHooks: [reminder(), urlWatcher({ score: "0" })],
})
expect(headerText()).toContain(t("editor.hooks.status.fresh"))
expect(headerText()).not.toContain(
t("editor.hooks.status.triggered", { count: 1 }),
)
})
it("counts the changed settings of a modified hook", async ({ expect }) => {
await mountContent({
activeBranchHooks: [urlWatcher()],
@ -349,3 +513,10 @@ function hookRows(): HTMLElement[] {
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
)
}
function headerText(): string {
return (
document.body.querySelector("[data-slot='dropdown-menu-label']")
?.textContent ?? ""
)
}

View File

@ -4,9 +4,13 @@ import GitHubTrackingConfigMenu from "./github-tracking/ConfigMenu.vue"
import ScheduledReminderConfigMenu from "./scheduled-reminder/ConfigMenu.vue"
import URLWatcherConfigMenu from "./url-watcher/ConfigMenu.vue"
import ContainerImageWatcherConfigMenu from "./container-image-watcher/ConfigMenu.vue"
import HookMenuFooter from "./HookMenuFooter.vue"
import HookMenuHeader from "./HookMenuHeader.vue"
import { compareAsc } from "date-fns"
import { DiffStatus } from "../diff/position-map"
import { diffHooks, type HookDiffEntry } from "./hook-diff"
import { HOOK_MENU_WIDTH_CLASS, HOOK_SUBMENU_WIDTH_CLASS } from "./hook-menu"
import { hookStatus } from "./hook-status"
const props = defineProps<{
activeBranchHooks: DocumentHook[]
@ -55,30 +59,28 @@ const entries = computed<HookDiffEntry[]>(() => {
targetHooks.filter((h) => h.blockId === props.nodeId),
)
})
const matchingActiveHooks = computed(() =>
listedHooks(entries.value.filter((e) => Number(e.hook.score) !== 0)),
)
const matchingTriggeredHooks = computed(() =>
listedHooks(entries.value.filter((e) => Number(e.hook.score) === 0)),
// a removed hook is gone from the branch, so it adds nothing to its status
const statuses = computed(() =>
entries.value
.filter((e) => e.status !== DiffStatus.Removed)
.map((e) => hookStatus(e.hook)),
)
function listedHooks(matching: HookDiffEntry[]) {
return matching
.sort((a, b) =>
compareAsc(
a.hook.updatedAt ?? new Date(0),
b.hook.updatedAt ?? new Date(0),
),
// rows keep the order their hooks were made in, and hooks made at the same
// moment go by id. Neither changes later, so a row never moves.
const listed = computed(() =>
[...entries.value]
.sort(
(a, b) =>
compareAsc(a.hook.createdAt, b.hook.createdAt) ||
a.hook.id.localeCompare(b.hook.id),
)
.map((e) => {
return {
id: e.hook.id,
comp: hookComponent(e.hook.type),
props: hookProps(e),
}
})
}
.map((e) => ({
id: e.hook.id,
comp: hookComponent(e.hook.type),
props: hookProps(e),
})),
)
function hookComponent(type: DocumentHookType): Component | null {
switch (type) {
@ -120,65 +122,86 @@ function hookProps(entry: HookDiffEntry): ComputedRef<Record<string, unknown>> {
}
</script>
<template>
<div class="max-w-[18rem]">
<template v-if="matchingTriggeredHooks.length">
<component
:is="h.comp"
v-for="h in matchingTriggeredHooks"
:key="h.id"
v-bind="h.props.value"
/>
<ShadcnUiDropdownMenuSeparator
v-if="matchingActiveHooks.length || !isReadOnlyOrDiff"
/>
<div :class="HOOK_MENU_WIDTH_CLASS">
<HookMenuHeader :statuses="statuses" />
<ShadcnUiDropdownMenuSeparator />
<component
:is="h.comp"
v-for="h in listed"
:key="h.id"
v-bind="h.props.value"
/>
<ShadcnUiEmpty v-if="!entries.length" class="gap-0 p-3 md:p-3">
<ShadcnUiEmptyHeader class="gap-1">
<ShadcnUiEmptyMedia class="mb-1">
<Icon
name="mingcute:leaf-line"
class="size-5 text-muted-foreground"
/>
</ShadcnUiEmptyMedia>
<ShadcnUiEmptyTitle class="text-2sm">
{{
props.nodeId
? $t("editor.hooks.empty-block")
: $t("editor.hooks.empty-document")
}}
</ShadcnUiEmptyTitle>
<!--
the description ignores a class passed to it. So the text
takes its size from a child
-->
<ShadcnUiEmptyDescription>
<span class="block text-xs leading-4">
{{
props.nodeId
? $t("editor.hooks.empty-block-description")
: $t("editor.hooks.empty-document-description")
}}
</span>
</ShadcnUiEmptyDescription>
</ShadcnUiEmptyHeader>
</ShadcnUiEmpty>
<template v-if="!isReadOnlyOrDiff">
<ShadcnUiDropdownMenuSeparator />
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<Icon name="mingcute:leaf-line" class="shrink-0" />
<span>
{{ $t("editor.hooks.add-new") }}
</span>
</ShadcnUiDropdownMenuSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:collision-padding="8"
:class="HOOK_SUBMENU_WIDTH_CLASS"
>
<ScheduledReminderConfigMenu
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
<GitHubTrackingConfigMenu
v-if="gitHubConfigured"
:node-id="nodeId"
@force-close="isSubOpen = false"
@open-settings="(target: 'github') => emit('open-settings', target)"
/>
<URLWatcherConfigMenu
v-if="isChangeDetectionEnabled"
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
<ContainerImageWatcherConfigMenu
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
</template>
<template v-if="matchingActiveHooks.length">
<component
:is="h.comp"
v-for="h in matchingActiveHooks"
:key="h.id"
v-bind="h.props.value"
/>
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
<template v-else-if="editorStore.reviewableDiffActive">
<ShadcnUiDropdownMenuSeparator />
<HookMenuFooter mode="diff" />
</template>
<div
v-if="isReadOnlyOrDiff && !entries.length"
class="px-2 py-1.25 text-xs text-muted-foreground"
>
{{
props.nodeId
? $t("editor.hooks.empty-block")
: $t("editor.hooks.empty-document")
}}
</div>
<ShadcnUiDropdownMenuSub v-if="!isReadOnlyOrDiff" v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<Icon name="mingcute:leaf-line" class="shrink-0" />
<span>
{{ $t("editor.hooks.add-new") }}
</span>
</ShadcnUiDropdownMenuSubTrigger>
<ShadcnUiDropdownMenuSubContent side="right" align="start" loop>
<ScheduledReminderConfigMenu
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
<GitHubTrackingConfigMenu
v-if="gitHubConfigured"
:node-id="nodeId"
@force-close="isSubOpen = false"
@open-settings="(target: 'github') => emit('open-settings', target)"
/>
<URLWatcherConfigMenu
v-if="isChangeDetectionEnabled"
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
<ContainerImageWatcherConfigMenu
:node-id="nodeId"
@force-close="isSubOpen = false"
/>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
</div>
</template>

View File

@ -0,0 +1,131 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it } from "vitest"
import HookMenuFooter from "./HookMenuFooter.vue"
import {
findButtonByText,
renderedIconNames,
t,
} from "~/components/test-helpers"
// the editor store is shared, so these tests cannot interleave
describe("<HookMenuFooter>", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setActiveBranchProtected(false)
})
it("confirms a new hook", async ({ expect }) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "create" },
})
await findButtonByText(wrapper, t("editor.hooks.create")).trigger("click")
expect(wrapper.emitted("submit")).toEqual([[]])
expect(wrapper.emitted("delete")).toBeUndefined()
expect(wrapper.findAll("button")).toHaveLength(1)
})
it("keeps the confirm button disabled while the host says so", async ({
expect,
}) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "create", submitDisabled: true },
})
expect(
findButtonByText(wrapper, t("editor.hooks.create")).attributes(
"disabled",
),
).toBeDefined()
})
it("updates an existing hook", async ({ expect }) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "edit" },
})
await findButtonByText(wrapper, t("editor.hooks.update")).trigger("click")
expect(wrapper.emitted("submit")).toEqual([[]])
expect(wrapper.emitted("delete")).toBeUndefined()
expect(renderedIconNames(wrapper)).toEqual([
"mingcute:save-2-line",
"mingcute:delete-2-line",
])
})
it("deletes an existing hook", async ({ expect }) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "edit" },
})
await findButtonByText(wrapper, t("editor.hooks.delete")).trigger("click")
expect(wrapper.emitted("delete")).toEqual([[]])
expect(wrapper.emitted("submit")).toBeUndefined()
})
it("saves under the label and icon it is given", async ({ expect }) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: {
mode: "edit",
submitLabel: t("editor.hooks.renew"),
submitIcon: "mingcute:check-fill",
},
})
expect(findButtonByText(wrapper, t("editor.hooks.renew")).exists()).toBe(
true,
)
expect(renderedIconNames(wrapper)).toEqual([
"mingcute:check-fill",
"mingcute:delete-2-line",
])
expect(wrapper.text()).not.toContain(t("editor.hooks.update"))
})
it("tells a reader in reading mode how to change the hook", async ({
expect,
}) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "read-only" },
})
expect(wrapper.text()).toBe(t("editor.hooks.read-only-mode"))
expect(renderedIconNames(wrapper)).toEqual(["mingcute:lock-line"])
expect(wrapper.findAll("button")).toHaveLength(0)
})
it("sends a reader on a protected branch to the draft branch", async ({
expect,
}) => {
useEditorStore().setActiveBranchProtected(true)
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "read-only" },
})
expect(wrapper.text()).toBe(t("editor.hooks.read-only-protected"))
expect(wrapper.findAll("button")).toHaveLength(0)
})
it("tells how to leave the diff to edit", async ({ expect }) => {
const wrapper = await mountSuspended(HookMenuFooter, {
props: { mode: "diff" },
})
const toggle = t("editor.name-editor.review-workflow.show-diff")
expect(collapsed(wrapper.text())).toBe(
t("editor.hooks.showing-changes", { toggle: toggle }),
)
expect(wrapper.get(".font-semibold").text()).toBe(toggle)
expect(renderedIconNames(wrapper)).toEqual(["mingcute:git-compare-line"])
expect(wrapper.findAll("button")).toHaveLength(0)
})
})
// the bold part sits on lines of its own in the template, which leaves
// spaces around it in the text
function collapsed(text: string): string {
return text.replace(/\s+/g, " ")
}

View File

@ -0,0 +1,91 @@
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
mode: "create" | "edit" | "read-only" | "diff"
// replaces the default label of the button that saves an existing hook
submitLabel?: string
submitIcon?: string
submitDisabled?: boolean
}>(),
{
submitLabel: undefined,
submitIcon: "mingcute:save-2-line",
},
)
const emit = defineEmits<{
(e: "submit" | "delete"): void
}>()
const { isBranchProtected } = useEditorMeta()
</script>
<template>
<div v-if="props.mode === 'create'" class="px-2 py-1.5">
<ShadcnUiButton
class="w-full gap-1"
size="2sm"
:disabled="props.submitDisabled"
@click.stop="emit('submit')"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.create") }}
</ShadcnUiButton>
</div>
<div v-else-if="props.mode === 'edit'" class="flex gap-1.5 px-2 py-1.5">
<ShadcnUiButton
class="flex-1 gap-1"
size="2sm"
:disabled="props.submitDisabled"
@click.stop="emit('submit')"
>
<Icon :name="props.submitIcon" />
{{ props.submitLabel ?? $t("editor.hooks.update") }}
</ShadcnUiButton>
<ShadcnUiButton
class="flex-1 gap-1"
variant="secondary"
size="2sm"
@click.stop="emit('delete')"
>
<Icon name="mingcute:delete-2-line" />
{{ $t("editor.hooks.delete") }}
</ShadcnUiButton>
</div>
<div
v-else
class="flex items-start gap-1.5 px-2 py-1.5 text-xs leading-4.5 text-muted-foreground"
>
<Icon
:name="
props.mode === 'read-only'
? 'mingcute:lock-line'
: 'mingcute:git-compare-line'
"
class="mt-0.5 size-3.5 shrink-0"
/>
<!--
the line wraps to the menu's width. Without a width of its own, it
would stretch the menu instead
-->
<span v-if="props.mode === 'read-only'" class="w-0 flex-1">
{{
isBranchProtected
? $t("editor.hooks.read-only-protected")
: $t("editor.hooks.read-only-mode")
}}
</span>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.showing-changes"
tag="span"
class="w-0 flex-1"
>
<template #toggle>
<span class="font-semibold">
{{ $t("editor.name-editor.review-workflow.show-diff") }}
</span>
</template>
</i18n-t>
</div>
</template>

View File

@ -0,0 +1,168 @@
import { beforeEach, describe, it } from "vitest"
import HookMenuHeader from "./HookMenuHeader.vue"
import type { HookStatus } from "./hook-status"
import { mountHookMenu } from "./test-helpers"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
// the teleported menu bodies are shared, so these tests cannot interleave
describe("<HookMenuHeader>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
})
it("shows only the title while there are no hooks", async ({ expect }) => {
await mountHookMenu(HookMenuHeader, { statuses: [] })
expect(header().textContent.trim()).toBe(t("editor.hooks.title"))
expect(pills()).toHaveLength(0)
})
it("says all is quiet in the fresh colour when no hook needs a look", async ({
expect,
}) => {
await mountHookMenu(HookMenuHeader, { statuses: ["fresh", "fresh"] })
expect(pills().map((pill) => pill.textContent.trim())).toEqual([
t("editor.hooks.status.fresh"),
])
expect(pillColours()).toEqual([
["bg-hook-status-fresh/15", "text-status-success-foreground"],
])
})
it.for<{
name: string
input: HookStatus[]
expected: { label: () => string; colours: string[] }
}>([
{
name: "triggered hooks",
input: ["triggered", "fresh", "triggered"],
expected: {
label: () => t("editor.hooks.status.triggered", { count: 2 }),
colours: ["bg-hook-status-triggered/15", "text-status-info-foreground"],
},
},
{
name: "one hook that needs attention",
input: ["needs-attention", "fresh"],
expected: {
label: () => t("editor.hooks.status.needs-attention.one", { count: 1 }),
colours: [
"bg-hook-status-needs-attention/25",
"text-status-warning-foreground",
],
},
},
{
name: "several hooks that need attention",
input: ["needs-attention", "needs-attention"],
expected: {
label: () =>
t("editor.hooks.status.needs-attention.other", { count: 2 }),
colours: [
"bg-hook-status-needs-attention/25",
"text-status-warning-foreground",
],
},
},
])(
"counts $name in words on its colour",
async ({ input, expected }, { expect }) => {
await mountHookMenu(HookMenuHeader, { statuses: input })
expect(pills().map((pill) => pill.textContent.trim())).toEqual([
expected.label(),
])
expect(pillColours()).toEqual([expected.colours])
},
)
it("shows both counts without words when both kinds are there", async ({
expect,
}) => {
await mountHookMenu(HookMenuHeader, {
statuses: ["triggered", "needs-attention", "triggered"],
})
const shown = pills()
expect(shown).toHaveLength(2)
expect(
shown.map(
(pill) =>
pill.querySelector("[aria-hidden='true']:not(.rounded-full)")
?.textContent,
),
).toEqual(["1", "2"])
expect(
shown.map((pill) => pill.querySelector(".sr-only")?.textContent),
).toEqual([
t("editor.hooks.status.needs-attention.one", { count: 1 }),
t("editor.hooks.status.triggered", { count: 2 }),
])
expect(
shown.map((pill) => pill.getAttribute("data-slot") === "tooltip-trigger"),
).toEqual([true, true])
expect(pillColours()).toEqual([
["bg-hook-status-needs-attention/25", "text-status-warning-foreground"],
["bg-hook-status-triggered/15", "text-status-info-foreground"],
])
expect(dotColours()).toEqual([
["bg-hook-status-needs-attention"],
["bg-hook-status-triggered"],
])
})
it("names a count in words in its tooltip", async ({ expect }) => {
await mountHookMenu(HookMenuHeader, {
statuses: ["triggered", "needs-attention"],
})
const [pill] = pills()
pill?.dispatchEvent(new FocusEvent("focus"))
await nextTick()
const id = pill?.getAttribute("aria-describedby")
expect(id && document.getElementById(id)?.textContent.trim()).toBe(
t("editor.hooks.status.needs-attention.one", { count: 1 }),
)
})
})
// the header is teleported with the menu, out of the wrapper's reach
function header(): HTMLElement {
const label = document.body.querySelector<HTMLElement>(
"[data-slot='dropdown-menu-label']",
)
if (!label) {
throw new Error("no menu header is rendered")
}
return label
}
// the pills sit beside the title, which is the one child that is not
// rounded
function pills(): HTMLElement[] {
return Array.from(header().children).filter((child): child is HTMLElement =>
child.classList.contains("rounded-full"),
)
}
// the ground and text colour each pill takes from its status
function pillColours(): string[][] {
return pills().map((pill) =>
Array.from(pill.classList).filter(
(c) => c.startsWith("bg-") || c.startsWith("text-status"),
),
)
}
// the colour of the dot a pill shows in place of its words
function dotColours(): string[][] {
return pills().map((pill) =>
Array.from(pill.querySelector(".size-1\\.5")?.classList ?? []).filter((c) =>
c.startsWith("bg-"),
),
)
}

View File

@ -0,0 +1,104 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import {
HOOK_STATUS_DOT_CLASS,
HOOK_STATUS_PILL_CLASS,
type HookStatus,
} from "./hook-status"
const PILL_CLASS =
"flex shrink-0 items-center rounded-full px-2 py-px text-xs font-semibold select-none"
const props = defineProps<{
// one for each hook the menu lists
statuses: HookStatus[]
}>()
const { t } = useI18n({ useScope: "global" })
// a pill for each status some hook is in, apart from fresh
const pills = computed(() => {
const needsAttention = props.statuses.filter(
(status) => status === "needs-attention",
).length
const triggered = props.statuses.filter(
(status) => status === "triggered",
).length
return [
{
status: "needs-attention" as const,
count: needsAttention,
label:
needsAttention === 1
? t("editor.hooks.status.needs-attention.one", {
count: needsAttention,
})
: t("editor.hooks.status.needs-attention.other", {
count: needsAttention,
}),
},
{
status: "triggered" as const,
count: triggered,
label: t("editor.hooks.status.triggered", { count: triggered }),
},
].filter((pill) => pill.count > 0)
})
</script>
<template>
<ShadcnUiDropdownMenuLabel
class="flex items-center gap-1.5 px-2 py-1.5 text-2sm font-semibold text-muted-foreground"
>
<span class="min-w-0 flex-1 truncate">
{{ $t("editor.hooks.title") }}
</span>
<!--
two pills only fit beside the title without their words, so each
shows a dot and its count, and its tooltip gives the words back
-->
<template v-if="pills.length > 1">
<ShadcnUiTooltip v-for="pill in pills" :key="pill.status">
<ShadcnUiTooltipTrigger as-child>
<span
:class="
cn(
PILL_CLASS,
'gap-1.25 pl-1.75',
HOOK_STATUS_PILL_CLASS[pill.status],
)
"
>
<span
aria-hidden="true"
:class="
cn(
'size-1.5 shrink-0 rounded-full',
HOOK_STATUS_DOT_CLASS[pill.status],
)
"
/>
<span aria-hidden="true">{{ pill.count }}</span>
<span class="sr-only">{{ pill.label }}</span>
</span>
</ShadcnUiTooltipTrigger>
<ShadcnUiTooltipContent>
{{ pill.label }}
</ShadcnUiTooltipContent>
</ShadcnUiTooltip>
</template>
<span
v-else-if="pills[0]"
:class="cn(PILL_CLASS, HOOK_STATUS_PILL_CLASS[pills[0].status])"
>
{{ pills[0].label }}
</span>
<span
v-else-if="props.statuses.length"
:class="cn(PILL_CLASS, HOOK_STATUS_PILL_CLASS.fresh)"
>
{{ $t("editor.hooks.status.fresh") }}
</span>
</ShadcnUiDropdownMenuLabel>
</template>

View File

@ -0,0 +1,150 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookNotice from "./HookNotice.vue"
import type { HookStatus } from "./hook-status"
import { renderedIconNames } from "~/components/test-helpers"
const ACTION = "Dismiss"
describe("<HookNotice>", () => {
it.for<{
name: string
input: HookStatus
expected: { box: string[]; icon: string }
}>([
{
name: "fresh",
input: "fresh",
expected: {
box: ["bg-muted", "text-muted-foreground"],
icon: "text-muted-foreground",
},
},
{
name: "triggered",
input: "triggered",
expected: {
box: ["bg-hook-status-triggered/10", "text-status-info-foreground"],
icon: "text-hook-status-triggered",
},
},
{
name: "needs-attention",
input: "needs-attention",
expected: {
box: [
"bg-hook-status-needs-attention/15",
"text-status-warning-foreground",
],
icon: "text-hook-status-needs-attention",
},
},
])(
"colours a $name notice, its text and its icon",
async ({ input, expected }, { expect }) => {
const wrapper = await mountSuspended(HookNotice, {
props: { status: input, icon: "mingcute:earth-2-line" },
slots: { default: () => "Maintainers are notified" },
})
expect(wrapper.text()).toBe("Maintainers are notified")
expect(wrapper.classes()).toEqual(expect.arrayContaining(expected.box))
expect(renderedIconNames(wrapper)).toEqual(["mingcute:earth-2-line"])
expect(wrapper.get(".iconify").classes()).toContain(expected.icon)
expect(wrapper.find("button").exists()).toBe(false)
},
)
it("puts its action along the bottom edge, in the notice's colour", async ({
expect,
}) => {
const wrapper = await mountSuspended(HookNotice, {
props: {
status: "triggered",
icon: "mingcute:earth-2-line",
actionLabel: ACTION,
},
slots: { default: () => "The page changed" },
})
const [message, action] = Array.from(
(wrapper.element as HTMLElement).children,
)
expect(message?.textContent).toContain("The page changed")
expect(message?.querySelector("button")).toBeNull()
expect(action?.tagName).toBe("BUTTON")
expect(action?.textContent.trim()).toBe(ACTION)
expect(Array.from(action?.classList ?? [])).toEqual(
expect.arrayContaining([
"w-full",
"border-t",
"rounded-none",
"text-status-info-foreground",
]),
)
})
it.for<{ name: string; input: HookStatus; expected: string[] }>([
{
name: "fresh",
input: "fresh",
expected: [
"bg-accent/40",
"[&:not(:disabled):hover:not(:active)]:bg-accent/70",
"[&:not(:disabled):active]:bg-accent",
],
},
{
name: "triggered",
input: "triggered",
expected: [
"bg-hook-status-triggered/5",
"[&:not(:disabled):hover:not(:active)]:bg-hook-status-triggered/15",
"[&:not(:disabled):active]:bg-hook-status-triggered/25",
],
},
{
name: "needs-attention",
input: "needs-attention",
expected: [
"bg-hook-status-needs-attention/5",
"[&:not(:disabled):hover:not(:active)]:bg-hook-status-needs-attention/15",
"[&:not(:disabled):active]:bg-hook-status-needs-attention/30",
],
},
])(
"deepens the action of a $name notice on hover, and again while pressed",
async ({ input, expected }, { expect }) => {
const wrapper = await mountSuspended(HookNotice, {
props: {
status: input,
icon: "mingcute:earth-2-line",
actionLabel: ACTION,
},
slots: { default: () => "The page changed" },
})
expect(
wrapper
.get("button")
.classes()
.filter((c) => c.includes("bg-")),
).toEqual(expected)
},
)
it("reports a click on its action", async ({ expect }) => {
const wrapper = await mountSuspended(HookNotice, {
props: {
status: "triggered",
icon: "mingcute:earth-2-line",
actionLabel: ACTION,
},
slots: { default: () => "The page changed" },
})
await wrapper.get("button").trigger("click")
expect(wrapper.emitted("action")).toEqual([[]])
})
})

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import type { HookStatus } from "./hook-status"
const props = defineProps<{
status: HookStatus
icon: string
// the label of the button along the notice's bottom edge, which is left
// out without one
actionLabel?: string
}>()
const emit = defineEmits<{
(e: "action"): void
}>()
const STATUS_CLASSES: Record<
HookStatus,
{ box: string; icon: string; action: string }
> = {
fresh: {
box: "bg-muted text-muted-foreground",
icon: "text-muted-foreground",
action:
"border-border bg-accent/40 text-foreground [&:not(:disabled):hover:not(:active)]:bg-accent/70 [&:not(:disabled):active]:bg-accent",
},
triggered: {
box: "bg-hook-status-triggered/10 text-status-info-foreground",
icon: "text-hook-status-triggered",
action:
"border-hook-status-triggered/20 bg-hook-status-triggered/5 text-status-info-foreground [&:not(:disabled):hover:not(:active)]:bg-hook-status-triggered/15 [&:not(:disabled):active]:bg-hook-status-triggered/25",
},
"needs-attention": {
box: "bg-hook-status-needs-attention/15 text-status-warning-foreground",
icon: "text-hook-status-needs-attention",
action:
"border-hook-status-needs-attention/30 bg-hook-status-needs-attention/5 text-status-warning-foreground [&:not(:disabled):hover:not(:active)]:bg-hook-status-needs-attention/15 [&:not(:disabled):active]:bg-hook-status-needs-attention/30",
},
}
</script>
<template>
<div
:data-hook-status="props.status"
:class="
cn(
'flex flex-col overflow-hidden rounded-md text-xs leading-4.5',
STATUS_CLASSES[props.status].box,
)
"
>
<div class="flex items-start gap-2 px-2 py-1.5">
<Icon
:name="props.icon"
:class="
cn('mt-0.5 size-3.5 shrink-0', STATUS_CLASSES[props.status].icon)
"
/>
<!--
the message wraps to the menu's width. Without a width of its
own, a long sentence would stretch the menu instead
-->
<div class="w-0 flex-1 break-words">
<slot />
</div>
</div>
<ShadcnUiButton
v-if="props.actionLabel"
variant="outline-no-effect"
size="custom"
:class="
cn(
'h-7.5 w-full gap-1.5 rounded-none border-0 border-t text-xs font-bold shadow-none',
STATUS_CLASSES[props.status].action,
)
"
@click.stop="emit('action')"
>
<Icon name="mingcute:check-fill" class="size-3" />
{{ props.actionLabel }}
</ShadcnUiButton>
</div>
</template>

View File

@ -0,0 +1,30 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookNoticeValue from "./HookNoticeValue.vue"
describe("<HookNoticeValue>", () => {
it("shows a value in bold", async ({ expect }) => {
const wrapper = await mountSuspended(HookNoticeValue, {
props: { value: "oxynote.test", fallback: "the page" },
})
expect(wrapper.text()).toBe("oxynote.test")
expect(wrapper.find(".font-semibold").text()).toBe("oxynote.test")
})
it.for([
{ name: "missing", input: undefined },
{ name: "null", input: null },
{ name: "empty", input: "" },
])(
"shows the fallback plainly while the value is $name",
async ({ input }, { expect }) => {
const wrapper = await mountSuspended(HookNoticeValue, {
props: { value: input, fallback: "the page" },
})
expect(wrapper.text()).toBe("the page")
expect(wrapper.find(".font-semibold").exists()).toBe(false)
},
)
})

View File

@ -0,0 +1,14 @@
<script lang="ts" setup>
const props = defineProps<{
// left out while the hook has nothing to name yet
value?: string | null
// what the sentence says in the value's place until then
fallback?: string
}>()
</script>
<template>
<span v-if="props.value" class="font-semibold text-foreground">
{{ props.value }}
</span>
<template v-else>{{ props.fallback }}</template>
</template>

View File

@ -3,39 +3,87 @@ import { describe, it } from "vitest"
import HookReadonlyField from "./HookReadonlyField.vue"
import { DiffStatus } from "../diff/position-map"
const ROWS = [
{ value: "https://new.test", status: DiffStatus.Added },
{ value: "https://old.test", status: DiffStatus.Removed },
{ value: "https://same.test", status: DiffStatus.Unchanged },
]
describe("<HookReadonlyField>", () => {
it("shows each value read only under the label", async ({ expect }) => {
const wrapper = await mountSuspended(HookReadonlyField, {
props: {
label: "Link",
rows: [
{ value: "https://new.test", status: DiffStatus.Added },
{ value: "https://old.test", status: DiffStatus.Removed },
{ value: "https://same.test", status: DiffStatus.Unchanged },
],
},
props: { label: "Link", rows: ROWS },
})
const inputs = wrapper.findAll("input")
const fields = wrapper.findAll("[role='textbox']")
expect(wrapper.text()).toContain("Link")
expect(inputs.map((input) => input.element.value)).toEqual([
expect(fields.map((field) => field.text())).toEqual([
"https://new.test",
"https://old.test",
"https://same.test",
])
expect(inputs.map((input) => input.attributes("readonly"))).toEqual([
"",
"",
"",
expect(fields.map((field) => field.attributes("aria-readonly"))).toEqual([
"true",
"true",
"true",
])
expect(inputs.map((input) => input.attributes("aria-label"))).toEqual([
expect(fields.map((field) => field.attributes("aria-label"))).toEqual([
"Link",
"Link",
"Link",
])
expect(inputs[0]?.classes()).toContain("bg-diff-field-added")
expect(inputs[1]?.classes()).toContain("bg-diff-field-removed")
expect(inputs[2]?.classes()).not.toContain("bg-diff-field-added")
expect(inputs[2]?.classes()).not.toContain("bg-diff-field-removed")
expect(fields[0]?.classes()).toContain("bg-diff-field-added")
expect(fields[1]?.classes()).toContain("bg-diff-field-removed")
expect(fields[2]?.classes()).not.toContain("bg-diff-field-added")
expect(fields[2]?.classes()).not.toContain("bg-diff-field-removed")
})
it("strikes through only the values the branch dropped", async ({
expect,
}) => {
const wrapper = await mountSuspended(HookReadonlyField, {
props: { label: "Link", rows: ROWS },
})
expect(
wrapper
.findAll("[role='textbox']")
.map((field) => field.classes("line-through")),
).toEqual([false, true, false])
})
it.for([
{
name: "green when the hook was added",
input: DiffStatus.Added,
expected: "bg-diff-field-added",
},
{
name: "red when the hook was removed",
input: DiffStatus.Removed,
expected: "bg-diff-field-removed",
},
])("marks every value $name", async ({ input, expected }, { expect }) => {
const wrapper = await mountSuspended(HookReadonlyField, {
props: { label: "Link", rows: ROWS, diffStatus: input },
})
expect(
wrapper
.findAll("[role='textbox']")
.map((field) => field.classes(expected)),
).toEqual([true, true, true])
})
it("keeps the marks of each value in a modified hook", async ({ expect }) => {
const wrapper = await mountSuspended(HookReadonlyField, {
props: { label: "Link", rows: ROWS, diffStatus: DiffStatus.Modified },
})
const fields = wrapper.findAll("[role='textbox']")
expect(fields[0]?.classes()).toContain("bg-diff-field-added")
expect(fields[1]?.classes()).toContain("bg-diff-field-removed")
expect(fields[2]?.classes()).not.toContain("bg-diff-field-added")
expect(fields[2]?.classes()).not.toContain("bg-diff-field-removed")
})
})

View File

@ -6,7 +6,19 @@ import type { HookFieldRow } from "./hook-diff"
const props = defineProps<{
label: string
rows: HookFieldRow[]
// the diff status of the whole hook. A hook added or removed marks
// every value its own way
diffStatus?: DiffStatus | null
}>()
const shownRows = computed(() => {
const status = props.diffStatus
if (status !== DiffStatus.Added && status !== DiffStatus.Removed) {
return props.rows
}
return props.rows.map((row) => ({ value: row.value, status: status }))
})
</script>
<template>
@ -14,21 +26,23 @@ const props = defineProps<{
<div class="text-left text-2sm font-medium text-foreground">
{{ props.label }}
</div>
<ShadcnUiInput
v-for="(row, index) in props.rows"
<div
v-for="(row, index) in shownRows"
:key="index"
:model-value="row.value"
role="textbox"
aria-readonly="true"
tabindex="0"
:aria-label="props.label"
transparent-disable
disable-focus-effect
disable-destructive-effect
:class="
cn(
'h-[1.775rem] px-2 text-2sm md:text-2sm',
'flex min-h-[1.775rem] items-center rounded-md border border-input px-2 py-1 text-2sm',
row.status === DiffStatus.Added && 'bg-diff-field-added',
row.status === DiffStatus.Removed && 'bg-diff-field-removed',
row.status === DiffStatus.Removed &&
'bg-diff-field-removed text-muted-foreground line-through',
)
"
/>
>
<span class="min-w-0 break-words">{{ row.value }}</span>
</div>
</div>
</template>

View File

@ -1,25 +1,97 @@
import { beforeEach, describe, it } from "vitest"
import HookSubTrigger from "./HookSubTrigger.vue"
import { makeHook, mountHookMenu } from "./test-helpers"
import { iconNames, makeHook, mountHookMenu } from "./test-helpers"
import type { HookDiffContext } from "./hook-diff"
import type { HookStatus } from "./hook-status"
import { DiffStatus } from "../diff/position-map"
import { DropdownMenuSub } from "~/components/shadcn/ui/dropdown-menu"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
const TITLE = "Website Changes"
const SUBTITLE = "oxynote.test/docs"
const DETAIL = "unreachable"
// the teleported menu bodies are shared, so these tests cannot interleave
describe("<HookSubTrigger>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
})
it("shows the row plainly outside the diff", async ({ expect }) => {
it("shows the icon, title and subtitle plainly outside the diff", async ({
expect,
}) => {
await mountTrigger({})
const row = triggerRow()
expect(row.textContent).toContain("Watching oxynote.test")
expect(row.textContent).toContain(TITLE)
expect(row.textContent).toContain(SUBTITLE)
expect(iconNames(row)).toEqual([
"mingcute:earth-2-line",
"lucide:chevron-right",
])
expect(signColumn(row)).toBeNull()
expect(statusDot(row)).toBeNull()
})
it("joins a subtitle's detail to it after a dot", async ({ expect }) => {
await mountTrigger({}, { detail: DETAIL })
expect(triggerRow().textContent).toContain(
t("editor.hooks.subtext-detail", { subtext: SUBTITLE, detail: DETAIL }),
)
})
it("adds no dot to a subtitle without a detail", async ({ expect }) => {
await mountTrigger({})
expect(triggerRow().textContent.trim()).toBe(`${TITLE}${SUBTITLE}`)
})
it("leaves out the subtitle line when there is none", async ({ expect }) => {
await mountTrigger({}, { subtitle: false })
expect(triggerRow().textContent.trim()).toBe(TITLE)
})
it.for<{
name: string
input: HookStatus
expected: { dot: string; label: string | null }
}>([
{
name: "fresh",
input: "fresh",
expected: { dot: "bg-hook-status-fresh", label: null },
},
{
name: "triggered",
input: "triggered",
expected: {
dot: "bg-hook-status-triggered",
label: "editor.hooks.row-status.triggered",
},
},
{
name: "needs-attention",
input: "needs-attention",
expected: {
dot: "bg-hook-status-needs-attention",
label: "editor.hooks.row-status.needs-attention",
},
},
])(
"marks a $name hook with its dot",
async ({ input, expected }, { expect }) => {
await mountTrigger({ status: input })
const row = triggerRow()
expect(statusDot(row)?.classList).toContain(expected.dot)
expect(row.querySelector(".sr-only")?.textContent.trim() ?? null).toBe(
expected.label ? t(expected.label) : null,
)
},
)
it.for([
{
status: DiffStatus.Added,
@ -67,12 +139,12 @@ describe("<HookSubTrigger>", { concurrent: false }, () => {
},
)
it("strikes through the name of a removed hook", async ({ expect }) => {
it("strikes through the title of a removed hook", async ({ expect }) => {
await mountTrigger({ diff: diffOf(DiffStatus.Removed) })
expect(triggerRow().querySelector(".line-through")?.textContent).toContain(
"Watching oxynote.test",
)
const struck = triggerRow().querySelector(".line-through")
expect(struck?.textContent).toContain(TITLE)
expect(struck?.textContent).not.toContain(SUBTITLE)
})
it("counts the changes of a modified hook", async ({ expect }) => {
@ -113,15 +185,28 @@ describe("<HookSubTrigger>", { concurrent: false }, () => {
})
})
function mountTrigger(props: Record<string, unknown>) {
function mountTrigger(
props: Record<string, unknown>,
options: { subtitle?: boolean; detail?: string } = {},
) {
return mountHookMenu(
defineComponent(
() => () =>
h(DropdownMenuSub, null, {
default: () =>
h(HookSubTrigger, props, {
default: () => h("span", "Watching oxynote.test"),
}),
h(
HookSubTrigger,
{ icon: "mingcute:earth-2-line", ...props },
{
title: () => TITLE,
...(options.subtitle !== false && {
subtitle: () => SUBTITLE,
}),
...(options.detail && {
"subtitle-detail": () => options.detail,
}),
},
),
}),
),
{},
@ -144,6 +229,10 @@ function signColumn(row: HTMLElement): Element | null {
return row.querySelector(".w-2")
}
function statusDot(row: HTMLElement): Element | null {
return row.querySelector(".size-1\\.75.rounded-full")
}
function diffOf(status: DiffStatus, signColumn = false): HookDiffContext {
return { status: status, targetHook: null, signColumn: signColumn }
}

View File

@ -3,11 +3,15 @@ import { cn } from "~/lib/utils"
import DiffCountPill from "../diff/DiffCountPill.vue"
import { DiffStatus } from "../diff/position-map"
import { hookChangeCount, type HookDiffContext } from "./hook-diff"
import { HOOK_STATUS_DOT_CLASS, type HookStatus } from "./hook-status"
const props = defineProps<{
hook?: DocumentHook | null
// set only while the diff is shown
diff?: HookDiffContext | null
// null on a row that offers a new hook
status?: HookStatus | null
icon: string
}>()
const diffStatus = computed(() => props.diff?.status ?? null)
@ -19,9 +23,7 @@ const count = computed(() =>
const rowClass = computed(() =>
cn(
// the label's line is taller than the menu's text, so truncating it
// does not clip the descenders. The row's padding gives that back
"py-1",
"gap-2.5 py-1.5",
// a tinted row deepens its own tint on hover, rather than taking the
// grey the other rows use
diffStatus.value === DiffStatus.Added &&
@ -53,15 +55,56 @@ const rowClass = computed(() =>
{{ $t("editor.hooks.diff.removed-sign") }}
</template>
</span>
<span
:class="
cn(
'flex min-w-0 flex-1 items-center gap-1.5 leading-4.25',
diffStatus === DiffStatus.Removed && 'line-through',
)
"
>
<slot />
<Icon :name="props.icon" class="size-3.75 shrink-0" />
<span class="flex min-w-0 flex-1 flex-col">
<span class="flex min-w-0 items-center gap-1.5 leading-4.25 font-medium">
<span
:class="
cn(
'truncate',
diffStatus === DiffStatus.Removed &&
'text-muted-foreground line-through',
)
"
>
<slot name="title" />
</span>
<span
v-if="props.status"
aria-hidden="true"
:class="
cn(
'size-1.75 shrink-0 rounded-full',
HOOK_STATUS_DOT_CLASS[props.status],
)
"
/>
</span>
<span
v-if="$slots.subtitle"
class="text-xs leading-4 break-words text-muted-foreground"
>
<i18n-t
v-if="$slots['subtitle-detail']"
scope="global"
keypath="editor.hooks.subtext-detail"
tag="span"
>
<template #subtext>
<slot name="subtitle" />
</template>
<template #detail>
<slot name="subtitle-detail" />
</template>
</i18n-t>
<slot v-else name="subtitle" />
</span>
</span>
<span v-if="props.status === 'triggered'" class="sr-only">
{{ $t("editor.hooks.row-status.triggered") }}
</span>
<span v-else-if="props.status === 'needs-attention'" class="sr-only">
{{ $t("editor.hooks.row-status.needs-attention") }}
</span>
<span v-if="diffStatus === DiffStatus.Added" class="sr-only">
{{ $t("editor.hooks.diff.added") }}

View File

@ -2,10 +2,13 @@ import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import { DiffStatus } from "../../diff/position-map"
import {
hookNotice,
makeHook,
menuButton,
menuButtonLabels,
menuText,
mountHookMenu,
openHookSubMenu,
@ -20,6 +23,7 @@ import {
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
emitFrom,
settleMutations,
t,
WAIT_FOR_OPTIONS,
@ -32,8 +36,10 @@ vi.mock("vue-sonner", () => ({
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const HOOK_PATH = `/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`
const NEW_HOOK_LABEL = "editor.hooks.container-image-watcher.title"
const TITLE = "editor.hooks.container-image-watcher.title"
const IMAGE = "postgres:16.4-alpine"
function imageHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
@ -41,9 +47,8 @@ function imageHook(overrides: Partial<DocumentHook> = {}) {
type: DocumentHookType.ContainerImageWatcher,
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
settings: {
image: t("editor.hooks.container-image-watcher.image-input-placeholder"),
},
settings: { image: IMAGE },
state: { digest: "sha256:old", status: "active" },
...overrides,
})
}
@ -62,27 +67,38 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
it("offers to start watching a website", async ({ expect }) => {
it("offers to start watching an image", async ({ expect }) => {
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
expect(menuText()).toContain(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.container-image-watcher.description"),
)
})
it("names the image an active hook watches", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
expect(menuText()).toContain("Watching postgres:16.4-alpine")
expect(menuText()).toContain(IMAGE)
})
it("reports a hook that has already fired", async ({ expect }) => {
await mountMenu({ hook: imageHook({ score: "0" }) })
it("says the image of a failing hook is unreachable", async ({ expect }) => {
await mountMenu({
hook: imageHook({
state: { digest: "sha256:old", status: "unauthorized" },
}),
})
expect(menuText()).toContain("Updates in postgres:16.4-alpine")
const detail = t("editor.hooks.container-image-watcher.detail-unreachable")
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", { subtext: IMAGE, detail: detail }),
)
})
it("prefills the image reference of the hook it edits", async ({
@ -90,29 +106,62 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
}) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
t("editor.hooks.container-image-watcher.image-input-placeholder"),
IMAGE,
)
})
it("explains what an active block hook will do", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
it.for([
{
name: "block",
nodeId: "block-1",
key: "editor.hooks.container-image-watcher.fresh-notice",
},
{
name: "page",
nodeId: null,
key: "editor.hooks.container-image-watcher.fresh-notice",
},
])(
"explains what a $name hook will do",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: imageHook(), nodeId: nodeId })
await openHookSubMenu("Watching postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain("the block will be highlighted")
})
expect(hookNotice().dataset.hookStatus).toBe("fresh")
expect(hookNotice().textContent.trim()).toBe(t(key, { image: IMAGE }))
},
)
it("explains what an active document-wide hook will do", async ({
it("names the image in its notice as the reader types it", async ({
expect,
}) => {
await mountMenu({ hook: imageHook(), nodeId: null })
await mountMenu()
await openHookSubMenu(t(TITLE))
await openHookSubMenu("Watching postgres:16.4-alpine")
await typeInMenu("redis:7")
expect(menuText()).toContain("the relevant sections will be highlighted")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.container-image-watcher.new-notice", {
image: "redis:7",
}),
)
})
it("names no image in its notice before one is typed", async ({ expect }) => {
await mountMenu()
await openHookSubMenu(t(TITLE))
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.container-image-watcher.new-notice", {
image: t("editor.hooks.container-image-watcher.image-fallback"),
}),
)
expect(hookNotice().querySelector(".font-semibold")).toBeNull()
})
it("warns about an image it cannot reach", async ({ expect }) => {
@ -122,16 +171,19 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
}),
})
await openHookSubMenu("Watching postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain("cannot be reached")
expect(hookNotice().dataset.hookStatus).toBe("needs-attention")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.container-image-watcher.unreachable-image"),
)
})
it("keeps the create button out of reach until an image is typed", async ({
expect,
}) => {
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
@ -143,7 +195,7 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
@ -163,172 +215,128 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
).toHaveLength(1)
})
it("warns when the hook cannot be created", async ({ expect }) => {
it("starts empty again once a hook is created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, () => ({
id: HOOK_ID,
}))
await mountMenu()
await openHookSubMenu(t(TITLE))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
"",
)
})
it("keeps the typed image when the hook cannot be created", async ({
expect,
}) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("updates the image an existing hook watches", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
"redis:7",
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await typeInMenu("redis:8")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: { image: "redis:8" },
})
})
it("warns when the hook cannot be updated", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await typeInMenu("redis:8")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("deletes the hook", async ({ expect }) => {
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be deleted", async ({ expect }) => {
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers to approve a hook that has fired", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
() => ({ id: HOOK_ID }),
)
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu("Updates in postgres:16.4-alpine")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be approved", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu("Updates in postgres:16.4-alpine")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers no approval for a hook that has not fired", async ({ expect }) => {
it("keeps the update button out of reach until the image changes", async ({
expect,
}) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(menuText()).not.toContain(t("editor.hooks.reset"))
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("creates nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
it("sends nothing while no image is typed", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await typeInMenu("redis:7")
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.create")).click()
emitFrom(wrapper, HookConfigPanel, "submit")
await settleMutations()
expect(calls).toHaveLength(0)
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toBeUndefined()
})
it("says what a triggered hook did while editing too", async ({ expect }) => {
it("updates the image an existing hook watches", async ({ expect }) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
await mountMenu({ hook: imageHook() })
await openHookSubMenu(t(TITLE))
await typeInMenu("postgres:17")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({ settings: { image: "postgres:17" } })
})
it("deletes the hook", async ({ expect }) => {
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
await mountMenu({ hook: imageHook() })
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("dismisses a triggered hook from its notice", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
await openHookSubMenu("Updates in postgres:16.4-alpine")
expect(menuText()).toContain(
t(
"editor.hooks.container-image-watcher.triggered-item-block-explanation",
),
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(hookNotice().textContent).toContain(
t("editor.hooks.container-image-watcher.triggered-notice", {
image: IMAGE,
}),
)
expect(menuText()).toContain(t("editor.hooks.reset"))
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("offers no dismissal for a hook that has not triggered", async ({
expect,
}) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu(t(TITLE))
expect(menuText()).not.toContain(t("editor.hooks.reset"))
})
describe("when the page is read only", { concurrent: false }, () => {
@ -339,32 +347,47 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
it("shows the image without a way to change it", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([["postgres:16.4-alpine", "unchanged"]])
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
expect(readonlyFields()).toEqual([[IMAGE, "unchanged"]])
expect(menuText()).toContain(t("editor.hooks.read-only-mode"))
expect(menuButtonLabels()).not.toContain(t("editor.hooks.update"))
expect(menuButtonLabels()).not.toContain(t("editor.hooks.delete"))
})
it("still dismisses a triggered hook", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.container-image-watcher.triggered-item-block-explanation",
key: "editor.hooks.container-image-watcher.triggered-notice",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.container-image-watcher.triggered-item-full-document-explanation",
key: "editor.hooks.container-image-watcher.triggered-notice",
},
])(
"says what a triggered hook on $name did",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: imageHook({ score: "0" }), nodeId: nodeId })
await openHookSubMenu("Updates in postgres:16.4-alpine")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(t(key))
expect(hookNotice().textContent).toContain(t(key, { image: IMAGE }))
},
)
})
@ -379,23 +402,54 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
hook: imageHook(),
diff: {
status: DiffStatus.Modified,
targetHook: imageHook({ settings: { image: "redis:7" } }),
targetHook: imageHook({ settings: { image: "postgres:15" } }),
signColumn: false,
},
})
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).toContain(
t(
"editor.hooks.container-image-watcher.existing-item-block-explanation",
),
t("editor.hooks.value-change", { old: "postgres:15", new: IMAGE }),
)
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
["postgres:16.4-alpine", "added"],
["redis:7", "removed"],
[IMAGE, "added"],
["postgres:15", "removed"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuButtonLabels()).not.toContain(t("editor.hooks.update"))
})
it.for([
{ name: "added", status: DiffStatus.Added },
{ name: "removed", status: DiffStatus.Removed },
])(
"marks every field of an $name hook",
async ({ status, name }, { expect }) => {
await mountMenu({
hook: imageHook(),
diff: { status: status, targetHook: null, signColumn: true },
})
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([[IMAGE, name]])
},
)
it("keeps a triggered hook from being dismissed", async ({ expect }) => {
await mountMenu({
hook: imageHook({ score: "0" }),
diff: {
status: DiffStatus.Unchanged,
targetHook: null,
signColumn: false,
},
})
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(menuText()).not.toContain(t("editor.hooks.reset"))
})
})
})

View File

@ -1,10 +1,13 @@
<script setup lang="ts">
import { showToastMessage } from "~/components/toast"
import HookInputField from "./HookInputField.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import HookInputField from "../HookInputField.vue"
import HookNoticeValue from "../HookNoticeValue.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { DiffStatus } from "../../diff/position-map"
import { useHookActions } from "../hook-actions"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
import { hookNoticeKeypath, type HookSubtitle } from "../hook-menu"
import { hookStatus } from "../hook-status"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
@ -17,286 +20,176 @@ const emit = defineEmits<{
}>()
const { t } = useI18n({ useScope: "global" })
const hookData = computed(() => {
if (!props.hook) {
return null
}
return {
score: Number(props.hook.score),
state: props.hook.state as DocumentHookStateContainerImageWatcher,
settings: props.hook.settings as DocumentHookSettingsContainerImageWatcher,
}
const { isReadOnlyOrDiff } = useEditorMeta()
const actions = useHookActions({
type: DocumentHookType.ContainerImageWatcher,
nodeId: () => props.nodeId,
hook: () => props.hook,
close: close,
})
const documentHookAPI = useDocumentHookAPI()
const editorStore = useEditorStore()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
const activeBranchSettings = computed(
() =>
props.hook?.settings as
DocumentHookSettingsContainerImageWatcher | undefined,
)
const targetBranchSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsContainerImageWatcher | undefined,
)
const confirmedImage = ref<string | undefined>(
hookData.value ? hookData.value.settings.image : undefined,
const state = computed(
() => props.hook?.state as DocumentHookStateContainerImageWatcher | undefined,
)
const selectedImage = ref<string | undefined>(confirmedImage.value)
const status = computed(() => (props.hook ? hookStatus(props.hook) : null))
const selectedImage = ref<string | undefined>(activeBranchSettings.value?.image)
const isSubOpen = ref(false)
async function upsertHook() {
if (
const isUnchanged = computed(
() =>
!selectedImage.value ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
selectedImage.value === activeBranchSettings.value?.image,
)
const failure = computed(() =>
state.value ? checkFailure(state.value.status) : null,
)
const subtitle = computed<HookSubtitle>(() => {
if (!activeBranchSettings.value) {
return { text: t("editor.hooks.container-image-watcher.description") }
}
isSubOpen.value = false
emit("force-close")
if (!props.hook) {
try {
await documentHookAPI.createDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
req: {
type: DocumentHookType.ContainerImageWatcher,
branchId: editorStore.activeBranchId,
blockId: props.nodeId,
settings: {
image: selectedImage.value,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.create-failed"))
return
const activeBranchImage = activeBranchSettings.value.image
const targetBranchImage = targetBranchSettings.value?.image
if (
props.diff?.status === DiffStatus.Modified &&
targetBranchImage &&
targetBranchImage !== activeBranchImage
) {
return {
text: t("editor.hooks.value-change", {
old: targetBranchImage,
new: activeBranchImage,
}),
}
}
confirmedImage.value = selectedImage.value
return { text: activeBranchImage, detail: failure.value?.detail }
})
// the notice names the image as it is typed, and the stored one where it
// cannot be changed
const noticeImage = computed(() =>
isReadOnlyOrDiff.value
? activeBranchSettings.value?.image
: selectedImage.value,
)
// since "create new" is reused, reset the state
async function submit() {
if (!selectedImage.value) {
return
}
const next = { image: selectedImage.value }
if (props.hook) {
await actions.update(next)
return
}
// the same form creates the next hook, so it is emptied once a hook is
// created
if (await actions.create(next)) {
selectedImage.value = undefined
return
}
try {
await documentHookAPI.updateDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
req: {
settings: {
image: selectedImage.value,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.update-failed"))
return
}
confirmedImage.value = selectedImage.value
}
async function deleteHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
// checkFailure says what the row and the notice show for a failed check,
// and is null while the check works. It has a case for every status and no
// default, so a status added later fails to compile until it is handled.
function checkFailure(
checkStatus: DocumentHookStateContainerImageWatcher["status"],
): { detail: string; notice: string } | null {
switch (checkStatus) {
case "active":
return null
case "unauthorized":
return {
detail: t("editor.hooks.container-image-watcher.detail-unreachable"),
notice: t("editor.hooks.container-image-watcher.unreachable-image"),
}
}
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.deleteDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.delete-failed"))
return
}
selectedImage.value = undefined
}
async function resetHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
}
function close() {
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.resetDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.reset-failed"))
return
}
selectedImage.value = undefined
}
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<div class="relative h-[0.8125rem] w-[0.8125rem] shrink-0">
<Icon
name="simple-icons:docker"
class="absolute top-1/2 left-1/2 size-3.75 -translate-x-1/2 -translate-y-1/2"
/>
</div>
<span v-if="!hookData">
{{ $t("editor.hooks.container-image-watcher.title") }}
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.container-image-watcher.existing-item"
tag="span"
class="truncate"
>
<template #image>
{{ confirmedImage || "" }}
</template>
</i18n-t>
<HookConfigPanel
v-model:open="isSubOpen"
:hook="props.hook"
:diff="props.diff"
icon="simple-icons:docker"
:acknowledge-label="
status === 'triggered' ? $t('editor.hooks.reset') : undefined
"
:submit-disabled="isUnchanged"
@submit="submit"
@delete="actions.remove"
@acknowledge="actions.reset"
>
<template #title>
{{ $t("editor.hooks.container-image-watcher.title") }}
</template>
<template #subtitle>
{{ subtitle.text }}
</template>
<template v-if="subtitle.detail" #subtitle-detail>
{{ subtitle.detail }}
</template>
<template #notice>
<template v-if="failure">
{{ failure.notice }}
</template>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.container-image-watcher.triggered-item"
:keypath="
hookNoticeKeypath(DocumentHookType.ContainerImageWatcher, status)
"
tag="span"
class="truncate"
>
<template #image>
{{ confirmedImage || "" }}
<HookNoticeValue
:value="noticeImage"
:fallback="
$t('editor.hooks.container-image-watcher.image-fallback')
"
/>
</template>
</i18n-t>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:class="[
'pointer-events-auto!' /* for some reason ShadcnUiSelect disables pointer events, which closes the whole sub menu when the select is closed, so we must override this */,
]"
</template>
<HookReadonlyField
v-if="isReadOnlyOrDiff && activeBranchSettings"
:label="$t('editor.hooks.container-image-watcher.image-input-label')"
:rows="
scalarFieldRows(
activeBranchSettings.image,
targetBranchSettings?.image ?? null,
)
"
:diff-status="props.diff?.status"
/>
<HookInputField
v-else
v-model="selectedImage"
:placeholder="
$t('editor.hooks.container-image-watcher.image-input-placeholder')
"
>
<div class="flex w-[14rem] flex-col">
<template v-if="hookData?.state.status === 'unauthorized'">
<i18n-t
scope="global"
keypath="editor.hooks.container-image-watcher.unreachable-image"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:alert-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-warning"
/>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template v-if="hookData && hookData.state.status === 'active'">
<HookExplanation
:type="DocumentHookType.ContainerImageWatcher"
:triggered="hookData.score === 0"
:node-id="props.nodeId"
/>
<ShadcnUiDropdownMenuSeparator />
</template>
<div class="flex flex-col gap-1 px-0.75 pb-0.75">
<HookReadonlyField
v-if="isReadOnlyOrDiff && hookData"
:label="
$t('editor.hooks.container-image-watcher.image-input-label')
"
:rows="
scalarFieldRows(
hookData.settings.image,
targetSettings?.image ?? null,
)
"
/>
<HookInputField
v-else
v-model="selectedImage"
:placeholder="
$t('editor.hooks.container-image-watcher.image-input-placeholder')
"
>
<template #label>
<span>
{{
$t("editor.hooks.container-image-watcher.image-input-label")
}}
</span>
</template>
</HookInputField>
</div>
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
<div v-if="!isReadOnlyOrDiff" class="p-0.75">
<div v-if="hookData" class="flex flex-col gap-1">
<div class="flex gap-1">
<ShadcnUiButton
class="flex-1 gap-1"
:variant="hookData?.score === 0 ? 'outline' : 'default'"
size="2sm"
:disabled="!selectedImage || selectedImage === confirmedImage"
@click.stop="upsertHook"
>
<Icon name="mingcute:save-2-line" />
{{ $t("editor.hooks.update") }}
</ShadcnUiButton>
<ShadcnUiButton
class="flex-1 gap-1"
variant="secondary"
size="2sm"
@click.stop="deleteHook"
>
<Icon name="mingcute:delete-2-line" />
{{ $t("editor.hooks.delete") }}
</ShadcnUiButton>
</div>
<ShadcnUiButton
v-if="hookData.score === 0"
class="gap-1"
size="2sm"
@click.stop="resetHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.reset") }}
</ShadcnUiButton>
</div>
<template v-else>
<ShadcnUiButton
class="w-full gap-1"
size="2sm"
:disabled="!selectedImage || selectedImage === confirmedImage"
@click.stop="upsertHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.create") }}
</ShadcnUiButton>
</template>
</div>
</div>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
<template #label>
<span>
{{ $t("editor.hooks.container-image-watcher.image-input-label") }}
</span>
</template>
</HookInputField>
</HookConfigPanel>
</template>

View File

@ -1,83 +0,0 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookInputField from "./HookInputField.vue"
import { t } from "~/components/test-helpers"
function mountField(props: Record<string, unknown> = {}) {
return mountSuspended(HookInputField, {
props: {
placeholder: t(
"editor.hooks.container-image-watcher.image-input-placeholder",
),
...props,
},
slots: {
label: () => t("editor.hooks.container-image-watcher.image-input-label"),
},
})
}
describe("<ContainerImageWatcherHookInputField>", () => {
it("labels the field", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.text()).toBe(
t("editor.hooks.container-image-watcher.image-input-label"),
)
})
it("prompts with the placeholder it was given", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("placeholder")).toBe(
t("editor.hooks.container-image-watcher.image-input-placeholder"),
)
})
it("shows the value it was given", async ({ expect }) => {
const wrapper = await mountField({ modelValue: "redis:7" })
expect((wrapper.get("input").element as HTMLInputElement).value).toBe(
"redis:7",
)
})
it("reports what the reader typed", async ({ expect }) => {
const wrapper = await mountField()
await wrapper.get("input").setValue("redis:7")
expect(wrapper.emitted("update:modelValue")).toEqual([["redis:7"]])
})
it("stays editable by default", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("disabled")).toBeUndefined()
})
it("locks the field when the host asks it to", async ({ expect }) => {
const wrapper = await mountField({ disabled: true })
expect(wrapper.get("input").attributes("disabled")).toBeDefined()
})
it("sizes itself for a menu by default", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").classes()).toContain("text-2sm")
})
it("takes the sizing the host asks for", async ({ expect }) => {
const wrapper = await mountField({ inputClass: "h-10 text-base" })
expect(wrapper.get("input").classes()).toContain("h-10")
expect(wrapper.get("input").classes()).not.toContain("text-2sm")
})
it("takes any text as an image reference", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("type")).toBeUndefined()
})
})

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
disabled?: boolean
placeholder: string
inputClass?: string
}>(),
{
inputClass: "text-2sm md:text-2sm px-2 h-[1.775rem]",
},
)
const value = defineModel<string | undefined>()
</script>
<template>
<div class="mt-0.5 flex flex-col">
<div class="mb-1 text-left text-2sm font-medium text-foreground">
<slot name="label" />
</div>
<ShadcnUiInput
v-model="value"
:class="props.inputClass"
:placeholder="props.placeholder"
:disabled="props.disabled"
disable-focus-effect
/>
</div>
</template>

View File

@ -3,11 +3,15 @@ import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import { DiffStatus } from "../../diff/position-map"
import FileSelectInput from "./FileSelectInput.vue"
import {
hookNotice,
iconNames,
makeHook,
menuButton,
menuButtonLabels,
menuText,
mountHookMenu,
openHookSubMenu,
@ -23,6 +27,7 @@ import {
clearTeleportedOverlays,
emitFrom,
emitFromNth,
settleMutations,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
@ -35,8 +40,9 @@ vi.mock("vue-sonner", () => ({
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const HOOK_PATH = `/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`
const NEW_HOOK_LABEL = "editor.hooks.github-tracking.title"
const TITLE = "editor.hooks.github-tracking.title"
const REPOSITORY = "runbooks"
function githubHook(overrides: Partial<DocumentHook> = {}) {
@ -55,6 +61,12 @@ function githubHook(overrides: Partial<DocumentHook> = {}) {
})
}
function failingGithubHook(
status: "missing_installation" | "missing_repository" | "missing_branch",
) {
return githubHook({ state: { pathsChecksums: {}, status: status } })
}
function mockGitHub(
options: { connected?: boolean; repositories?: string[] } = {},
) {
@ -107,6 +119,7 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
@ -117,34 +130,77 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
expect(menuText()).toContain(t(TITLE))
expect(menuText()).toContain(t("editor.hooks.github-tracking.description"))
})
it("names the repository an active hook watches", async ({ expect }) => {
it("names the repository, branch and file count an active hook watches", async ({
expect,
}) => {
mockGitHub()
await mountMenu({ hook: githubHook() })
expect(menuText()).toContain(`Watching ${REPOSITORY}`)
const detail = t("editor.hooks.github-tracking.file-count.one")
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", {
subtext: t("editor.hooks.github-tracking.subtext", {
repository: REPOSITORY,
branch: "main",
}),
detail: detail,
}),
)
})
it("reports a hook that has already fired", async ({ expect }) => {
mockGitHub()
it.for([
{
name: "a missing installation",
status: "missing_installation" as const,
problem: "editor.hooks.github-tracking.problems.missing-installation",
},
{
name: "a missing repository",
status: "missing_repository" as const,
problem: "editor.hooks.github-tracking.problems.missing-repository",
},
{
name: "a missing branch",
status: "missing_branch" as const,
problem: "editor.hooks.github-tracking.problems.missing-branch",
},
])(
"names $name on the hook's row",
async ({ status, problem }, { expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook({ score: "0" }) })
await mountMenu({ hook: failingGithubHook(status) })
expect(menuText()).toContain(`Changes in ${REPOSITORY}`)
})
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", {
subtext: t("editor.hooks.github-tracking.subtext", {
repository: REPOSITORY,
branch: "main",
}),
detail: t(problem),
}),
)
},
)
it("asks the reader to connect github first", async ({ expect }) => {
mockGitHub({ connected: false })
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(menuText()).toContain("connect your GitHub organization")
expect(hookNotice().textContent).toContain(
"connect your GitHub organization",
)
}, WAIT_FOR_OPTIONS)
expect(hookNotice().dataset.hookStatus).toBe("fresh")
expect(iconNames(hookNotice())).toEqual(["mingcute:information-fill"])
menuButton(
t("editor.hooks.github-tracking.not-connected.placeholder"),
).click()
@ -155,74 +211,128 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
])
})
it("warns that a hook lost its github connection", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: failingGithubHook("missing_installation") })
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("needs-attention")
expect(hookNotice().textContent).toContain(
"connect your GitHub organization",
)
expect(iconNames(hookNotice())).toEqual(["mingcute:alert-fill"])
})
it("says so when the connected account has no repositories", async ({
expect,
}) => {
mockGitHub({ repositories: [] })
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(menuText()).toContain("no accessible repositories")
}, WAIT_FOR_OPTIONS)
})
it("warns about a repository it can no longer reach", async ({ expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook({
state: { pathsChecksums: {}, status: "missing_repository" },
}),
})
await openHookSubMenu(`Watching ${REPOSITORY}`)
await vi.waitFor(() => {
expect(menuText()).toContain(
"The selected GitHub repository cannot be accessed",
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.github-tracking.no-repositories"),
)
}, WAIT_FOR_OPTIONS)
expect(hookNotice().dataset.hookStatus).toBe("fresh")
})
it("warns about a branch it can no longer reach", async ({ expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook({
state: { pathsChecksums: {}, status: "missing_branch" },
}),
})
it.for([
{
name: "a repository",
status: "missing_repository" as const,
key: "editor.hooks.github-tracking.missing-target-repository",
},
{
name: "a branch",
status: "missing_branch" as const,
key: "editor.hooks.github-tracking.missing-target-branch",
},
])(
"warns about $name it can no longer reach",
async ({ status, key }, { expect }) => {
mockGitHub()
await mountMenu({ hook: failingGithubHook(status) })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(menuText()).toContain(
"The selected GitHub branch cannot be accessed",
)
}, WAIT_FOR_OPTIONS)
})
await vi.waitFor(() => {
expect(hookNotice().textContent.trim()).toBe(t(key))
}, WAIT_FOR_OPTIONS)
expect(hookNotice().dataset.hookStatus).toBe("needs-attention")
},
)
it("explains what an active block hook will do", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook() })
it.for([
{
name: "block",
nodeId: "block-1",
key: "editor.hooks.github-tracking.fresh-notice",
},
{
name: "page",
nodeId: null,
key: "editor.hooks.github-tracking.fresh-notice",
},
])(
"explains what a $name hook will do",
async ({ nodeId, key }, { expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook(), nodeId: nodeId })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(menuText()).toContain("the block will be highlighted")
}, WAIT_FOR_OPTIONS)
})
await vi.waitFor(() => {
expect(hookNotice().textContent.trim()).toBe(
t(key, { files: "docs/readme.md", branch: "main" }),
)
}, WAIT_FOR_OPTIONS)
expect(hookNotice().dataset.hookStatus).toBe("fresh")
},
)
it("explains what an active document-wide hook will do", async ({
it("names nothing in its notice before anything is picked", async ({
expect,
}) => {
mockGitHub()
await mountMenu({ hook: githubHook(), nodeId: null })
await mountMenu()
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(menuText()).toContain("the relevant sections will be highlighted")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.github-tracking.new-notice", {
files: t("editor.hooks.github-tracking.files-fallback"),
branch: t("editor.hooks.github-tracking.branch-fallback"),
}),
)
}, WAIT_FOR_OPTIONS)
expect(hookNotice().querySelector(".font-semibold")).toBeNull()
})
it("counts the picked files in its notice once there are many", async ({
expect,
}) => {
mockGitHub()
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "next")
await pickPaths(wrapper, ["a.md", "b.md", "c.md"])
await vi.waitFor(() => {
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.github-tracking.new-notice", {
files: t("editor.hooks.github-tracking.file-count.other", {
count: 3,
}),
branch: "next",
}),
)
}, WAIT_FOR_OPTIONS)
})
@ -231,7 +341,7 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
}) => {
mockGitHub()
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
@ -249,7 +359,7 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
@ -274,7 +384,30 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
).toHaveLength(1)
})
it("warns when the hook cannot be created", async ({ expect }) => {
it("starts empty again once a hook is created", async ({ expect }) => {
mockGitHub()
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, () => ({
id: HOOK_ID,
}))
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await settleMutations()
await openHookSubMenu(t(TITLE))
const [repositorySelect, branchSelect] = wrapper.findAllComponents(Select)
expect(repositorySelect?.props("modelValue")).toBeUndefined()
expect(branchSelect?.props("modelValue")).toBeUndefined()
expect(
wrapper.findComponent(FileSelectInput).props("modelValue"),
).toBeUndefined()
})
it("keeps the picks when the hook cannot be created", async ({ expect }) => {
mockGitHub()
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
setResponseStatus(event, 500)
@ -282,27 +415,59 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
return { message: "boom" }
})
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
await openHookSubMenu(t(TITLE))
const [repositorySelect, branchSelect] = wrapper.findAllComponents(Select)
expect(repositorySelect?.props("modelValue")).toBe(REPOSITORY)
expect(branchSelect?.props("modelValue")).toBe("main")
})
it("keeps the update button out of reach until a pick changes", async ({
expect,
}) => {
mockGitHub()
await mountMenu({ hook: githubHook() })
await openHookSubMenu(t(TITLE))
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("sends nothing while no files are picked", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
mockGitHub()
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
emitFrom(wrapper, HookConfigPanel, "submit")
await settleMutations()
expect(calls).toHaveLength(0)
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toBeUndefined()
})
it("updates what an existing hook watches", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
)
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const wrapper = await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
await pickBranch(wrapper, "next")
menuButton(t("editor.hooks.update")).click()
@ -319,37 +484,11 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
})
})
it("warns when the hook cannot be updated", async ({ expect }) => {
mockGitHub()
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await pickBranch(wrapper, "next")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("deletes the hook", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.delete")).click()
@ -358,37 +497,22 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be deleted", async ({ expect }) => {
it("dismisses a triggered hook from its notice", async ({ expect }) => {
mockGitHub()
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
menuButton(t("editor.hooks.delete")).click()
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
expect(hookNotice().textContent).toContain(
t("editor.hooks.github-tracking.triggered-notice", {
branch: "main",
}),
)
}, WAIT_FOR_OPTIONS)
})
it("offers to approve a hook that has fired", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
() => ({ id: HOOK_ID }),
)
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
expect(hookNotice().dataset.hookStatus).toBe("triggered")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
@ -396,57 +520,20 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be approved", async ({ expect }) => {
it("offers no dismissal while the hook cannot check its files", async ({
expect,
}) => {
mockGitHub()
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
(_c, event) => {
setResponseStatus(event, 500)
await mountMenu({
hook: githubHook({
score: "0",
state: { pathsChecksums: {}, status: "missing_branch" },
}),
})
return { message: "boom" }
},
)
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("creates nothing while no page is open", async ({ expect }) => {
mockGitHub()
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await nextTick()
expect(calls).toHaveLength(0)
})
it("says what a triggered hook did while editing too", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
expect(menuText()).toContain(
t("editor.hooks.github-tracking.triggered-item-block-explanation"),
)
expect(menuText()).toContain(t("editor.hooks.reset"))
expect(menuButtonLabels()).not.toContain(t("editor.hooks.reset"))
})
describe("when the page is read only", { concurrent: false }, () => {
@ -460,31 +547,50 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
mockGitHub({ connected: false })
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
[REPOSITORY, "unchanged"],
["main", "unchanged"],
["docs/readme.md", "unchanged"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
// the setup banner is for someone who can connect github
expect(menuText()).not.toContain(
t("editor.hooks.github-tracking.not-connected.placeholder"),
expect(menuText()).toContain(t("editor.hooks.read-only-mode"))
expect(menuButtonLabels()).toEqual([])
// connecting github is setup for an editor, so a working hook
// says what it will do instead
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.github-tracking.fresh-notice", {
files: "docs/readme.md",
branch: "main",
}),
)
})
it("still dismisses a triggered hook", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.github-tracking.triggered-item-block-explanation",
key: "editor.hooks.github-tracking.triggered-notice",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.github-tracking.triggered-item-full-document-explanation",
key: "editor.hooks.github-tracking.triggered-notice",
},
])(
"says what a triggered hook on $name did",
@ -492,9 +598,9 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
mockGitHub()
await mountMenu({ hook: githubHook({ score: "0" }), nodeId: nodeId })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(t(key))
expect(hookNotice().textContent).toContain(t(key, { branch: "main" }))
},
)
})
@ -521,11 +627,8 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
},
})
await openHookSubMenu(`Watching ${REPOSITORY}`)
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.github-tracking.existing-item-block-explanation"),
)
expect(readonlyFields()).toEqual([
[REPOSITORY, "unchanged"],
["main", "added"],
@ -533,6 +636,29 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
["docs/readme.md", "unchanged"],
["docs/old.md", "removed"],
])
expect(menuButtonLabels()).toEqual([])
})
it.for([
{ name: "added", status: DiffStatus.Added },
{ name: "removed", status: DiffStatus.Removed },
])(
"marks every field of an $name hook",
async ({ status, name }, { expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook(),
diff: { status: status, targetHook: null, signColumn: true },
})
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
[REPOSITORY, name],
["main", name],
["docs/readme.md", name],
])
},
)
})
})

View File

@ -1,15 +1,17 @@
<script setup lang="ts">
import { showToastMessage } from "~/components/toast"
import FileSelectInput from "./FileSelectInput.vue"
import { cn } from "~/lib/utils"
import HookConfigPanel from "../HookConfigPanel.vue"
import HookNoticeValue from "../HookNoticeValue.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { useHookActions } from "../hook-actions"
import {
listFieldRows,
scalarFieldRows,
type HookDiffContext,
} from "../hook-diff"
import { hookNoticeKeypath, type HookSubtitle } from "../hook-menu"
import { hookStatus, type HookStatus } from "../hook-status"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
@ -22,62 +24,122 @@ const emit = defineEmits<{
(e: "open-settings", target: "github"): void
}>()
const editorStore = useEditorStore()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsGitHubTracking | undefined,
)
const hookData = computed(() => {
if (!props.hook) {
return null
}
return {
score: Number(props.hook.score),
state: props.hook.state as DocumentHookStateGitHubTracking,
settings: props.hook.settings as DocumentHookSettingsGitHubTracking,
}
})
const documentHookAPI = useDocumentHookAPI()
const { fetchGitHubConnectionStatus } = useGitHubAPI()
const { t } = useI18n({ useScope: "global" })
const confirmedRepository = ref<string | undefined>(
hookData.value ? hookData.value.settings.repository : undefined,
)
const selectedRepository = ref<string | undefined>(confirmedRepository.value)
const confirmedBranch = ref<string | undefined>(
hookData.value ? hookData.value.settings.branch : undefined,
)
const selectedBranch = ref<string | undefined>(confirmedBranch.value)
const confirmedPaths = ref<string[] | undefined>(
hookData.value ? hookData.value.settings.paths : undefined,
)
const selectedPaths = ref<string[] | undefined>(confirmedPaths.value)
const invalidData = computed(() => {
return (
!selectedRepository.value ||
!selectedBranch.value ||
!selectedPaths.value?.length ||
(selectedRepository.value === confirmedRepository.value &&
selectedBranch.value === confirmedBranch.value &&
arraysEqual(selectedPaths.value, confirmedPaths.value))
)
const { isReadOnlyOrDiff } = useEditorMeta()
const actions = useHookActions({
type: DocumentHookType.GitHubTracking,
nodeId: () => props.nodeId,
hook: () => props.hook,
close: close,
})
const {
fetchGitHubConnectionStatus,
fetchGitHubRepositories,
useFetchGitHubBranchesByRepositoryName,
useFetchGitHubFileTreeByRepositoryNameAndBranch,
} = useGitHubAPI()
const activeBranchSettings = computed(
() => props.hook?.settings as DocumentHookSettingsGitHubTracking | undefined,
)
const targetBranchSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsGitHubTracking | undefined,
)
const state = computed(
() => props.hook?.state as DocumentHookStateGitHubTracking | undefined,
)
const status = computed(() => (props.hook ? hookStatus(props.hook) : null))
const selectedRepository = ref<string | undefined>(
activeBranchSettings.value?.repository,
)
const selectedBranch = ref<string | undefined>(
activeBranchSettings.value?.branch,
)
const selectedPaths = ref<string[] | undefined>(
activeBranchSettings.value?.paths,
)
const fetchGitHubBranches =
useFetchGitHubBranchesByRepositoryName(selectedRepository)
const fetchGitHubPaths = useFetchGitHubFileTreeByRepositoryNameAndBranch(
selectedRepository,
selectedBranch,
)
const isSubOpen = ref(false)
const invalidData = computed(() => {
return (
!selectedRepository.value ||
!selectedBranch.value ||
!selectedPaths.value?.length ||
(selectedRepository.value === activeBranchSettings.value?.repository &&
selectedBranch.value === activeBranchSettings.value.branch &&
arraysEqual(selectedPaths.value, activeBranchSettings.value.paths))
)
})
const failure = computed(() =>
state.value ? checkFailure(state.value.status) : null,
)
// the setup an editor still has to do comes first, then what the last
// check found
const notice = computed(() => {
if (
failure.value?.notice === "not-connected" ||
(!isReadOnlyOrDiff.value &&
!fetchGitHubConnectionStatus.data.value?.connected)
) {
return "not-connected"
}
if (
!isReadOnlyOrDiff.value &&
!fetchGitHubRepositories.state.value.data?.length
) {
return "no-repositories"
}
return failure.value?.notice ?? "summary"
})
// setup is not the hook's own state, so it shows plain unless the last
// check failed over it
const noticeStatus = computed<HookStatus | undefined>(() =>
(notice.value === "not-connected" || notice.value === "no-repositories") &&
status.value !== "needs-attention"
? "fresh"
: undefined,
)
const subtitle = computed<HookSubtitle>(() => {
if (!activeBranchSettings.value) {
return { text: t("editor.hooks.github-tracking.description") }
}
return {
text: t("editor.hooks.github-tracking.subtext", {
repository: activeBranchSettings.value.repository,
branch: activeBranchSettings.value.branch,
}),
detail:
failure.value?.detail ??
fileCount(activeBranchSettings.value.paths.length),
}
})
// the notice names the picks as they are made, and the stored ones where
// they cannot be changed
const noticeFiles = computed(() => {
const paths = isReadOnlyOrDiff.value
? activeBranchSettings.value?.paths
: selectedPaths.value
if (!paths?.length) {
return null
}
return paths.length > 2 ? fileCount(paths.length) : paths.join(", ")
})
const noticeBranch = computed(() =>
isReadOnlyOrDiff.value
? activeBranchSettings.value?.branch
: selectedBranch.value,
)
onMounted(async () => {
await Promise.all([
@ -86,518 +148,338 @@ onMounted(async () => {
])
})
async function upsertHook() {
if (
invalidData.value ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
}
isSubOpen.value = false
emit("force-close")
async function submit() {
const repository = selectedRepository.value
const branch = selectedBranch.value
const paths = selectedPaths.value
if (!repository || !branch || !paths) {
if (invalidData.value || !repository || !branch || !paths) {
return
}
if (!props.hook) {
try {
await documentHookAPI.createDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
req: {
type: DocumentHookType.GitHubTracking,
branchId: editorStore.activeBranchId,
blockId: props.nodeId,
settings: {
repository,
branch,
paths,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.create-failed"))
return
}
const next = { repository: repository, branch: branch, paths: paths }
if (props.hook) {
await actions.update(next)
return
}
confirmedRepository.value = selectedRepository.value
confirmedBranch.value = selectedBranch.value
confirmedPaths.value = selectedPaths.value
// since "create new" is reused, reset the state
// the same form creates the next hook, so it is emptied once a hook is
// created
if (await actions.create(next)) {
selectedRepository.value = undefined
selectedBranch.value = undefined
selectedPaths.value = undefined
return
}
try {
await documentHookAPI.updateDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
req: {
settings: {
repository,
branch,
paths,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.update-failed"))
return
}
confirmedRepository.value = selectedRepository.value
confirmedBranch.value = selectedBranch.value
confirmedPaths.value = selectedPaths.value
}
async function deleteHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
}
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.deleteDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.delete-failed"))
return
}
selectedRepository.value = undefined
selectedBranch.value = undefined
selectedPaths.value = undefined
function fileCount(count: number): string {
return count === 1
? t("editor.hooks.github-tracking.file-count.one")
: t("editor.hooks.github-tracking.file-count.other", { count: count })
}
async function resetHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
// checkFailure says what the row and the notice show for a failed check,
// and is null while the check works. It has a case for every status and no
// default, so a status added later fails to compile until it is handled.
function checkFailure(checkStatus: DocumentHookStateGitHubTracking["status"]): {
detail: string
notice: "not-connected" | "missing-repository" | "missing-branch"
} | null {
switch (checkStatus) {
case "active":
return null
case "missing_installation":
return {
detail: t("editor.hooks.github-tracking.problems.missing-installation"),
notice: "not-connected",
}
case "missing_repository":
return {
detail: t("editor.hooks.github-tracking.problems.missing-repository"),
notice: "missing-repository",
}
case "missing_branch":
return {
detail: t("editor.hooks.github-tracking.problems.missing-branch"),
notice: "missing-branch",
}
}
}
function close() {
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.resetDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.reset-failed"))
return
}
}
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<Icon name="simple-icons:github" class="shrink-0" />
<span v-if="!hookData">
{{ $t("editor.hooks.github-tracking.title") }}
</span>
<HookConfigPanel
v-model:open="isSubOpen"
:hook="props.hook"
:diff="props.diff"
icon="simple-icons:github"
:notice-status="noticeStatus"
:notice-icon="noticeStatus ? 'mingcute:information-fill' : undefined"
:acknowledge-label="
status === 'triggered' && state?.status === 'active'
? $t('editor.hooks.reset')
: undefined
"
:submit-disabled="invalidData"
@submit="submit"
@delete="actions.remove"
@acknowledge="actions.reset"
>
<template #title>
{{ $t("editor.hooks.github-tracking.title") }}
</template>
<template #subtitle>
{{ subtitle.text }}
</template>
<template v-if="subtitle.detail" #subtitle-detail>
{{ subtitle.detail }}
</template>
<template #notice>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
v-if="notice === 'not-connected'"
scope="global"
keypath="editor.hooks.github-tracking.existing-item"
keypath="editor.hooks.github-tracking.not-connected.main"
tag="span"
class="truncate"
>
<template #repository>
{{ confirmedRepository }}
<template #placeholder>
<ShadcnUiButton
type="button"
variant="link"
size="custom"
class="h-fit p-0 text-xs"
@click="emit('open-settings', 'github')"
>
{{ $t("editor.hooks.github-tracking.not-connected.placeholder") }}
</ShadcnUiButton>
</template>
</i18n-t>
<template v-else-if="notice === 'no-repositories'">
{{ $t("editor.hooks.github-tracking.no-repositories") }}
</template>
<template v-else-if="notice === 'missing-repository'">
{{ $t("editor.hooks.github-tracking.missing-target-repository") }}
</template>
<template v-else-if="notice === 'missing-branch'">
{{ $t("editor.hooks.github-tracking.missing-target-branch") }}
</template>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.github-tracking.triggered-item"
:keypath="hookNoticeKeypath(DocumentHookType.GitHubTracking, status)"
tag="span"
class="truncate"
>
<template #repository>
{{ confirmedRepository }}
<template #files>
<HookNoticeValue
:value="noticeFiles"
:fallback="$t('editor.hooks.github-tracking.files-fallback')"
/>
</template>
<template #branch>
<HookNoticeValue
:value="noticeBranch"
:fallback="$t('editor.hooks.github-tracking.branch-fallback')"
/>
</template>
</i18n-t>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:class="[
'pointer-events-auto!' /* for some reason ShadcnUiSelect disables pointer events, which closes the whole sub menu when the select is closed, so we must override this */,
]"
</template>
<div
v-if="isReadOnlyOrDiff && activeBranchSettings"
class="flex flex-col gap-1"
>
<div class="flex w-[14rem] flex-col">
<!-- the setup banners tell an editor what to connect -->
<template
v-if="
!isReadOnlyOrDiff &&
(!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation')
"
>
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.not-connected.main"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:information-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-info"
/>
</template>
<template #placeholder>
<ShadcnUiButton
type="button"
variant="link"
size="custom"
class="h-fit p-0 text-2sm"
@click="emit('open-settings', 'github')"
>
{{
$t("editor.hooks.github-tracking.not-connected.placeholder")
}}
</ShadcnUiButton>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template
v-else-if="
!isReadOnlyOrDiff &&
!fetchGitHubRepositories.state.value.data?.length
"
>
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.no-repositories"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:information-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-info"
/>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template v-else-if="hookData?.state.status === 'missing_repository'">
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.missing-target-repository"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:alert-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-warning"
/>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template v-else-if="hookData?.state.status === 'missing_branch'">
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.missing-target-branch"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:alert-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-warning"
/>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<div
:class="
cn(
'flex flex-col opacity-100 transition-opacity duration-200',
!isReadOnlyOrDiff &&
(!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation') &&
'pointer-events-none opacity-60',
)
"
>
<template v-if="hookData && hookData.state.status === 'active'">
<HookExplanation
:type="DocumentHookType.GitHubTracking"
:triggered="hookData.score === 0"
:node-id="props.nodeId"
/>
<ShadcnUiDropdownMenuSeparator />
</template>
<div class="flex flex-col gap-1 px-0.75 pb-0.75">
<template v-if="isReadOnlyOrDiff && hookData">
<HookReadonlyField
:label="
$t('editor.hooks.github-tracking.repository-select-label')
"
:rows="
scalarFieldRows(
hookData.settings.repository,
targetSettings?.repository ?? null,
<HookReadonlyField
:label="$t('editor.hooks.github-tracking.repository-select-label')"
:rows="
scalarFieldRows(
activeBranchSettings.repository,
targetBranchSettings?.repository ?? null,
)
"
:diff-status="props.diff?.status"
/>
<HookReadonlyField
:label="$t('editor.hooks.github-tracking.branch-select-label')"
:rows="
scalarFieldRows(
activeBranchSettings.branch,
targetBranchSettings?.branch ?? null,
)
"
:diff-status="props.diff?.status"
/>
<HookReadonlyField
:label="$t('editor.hooks.github-tracking.path-select-label')"
:rows="
listFieldRows(
activeBranchSettings.paths,
targetBranchSettings?.paths ?? null,
)
"
:diff-status="props.diff?.status"
/>
</div>
<div
v-else
:class="
cn(
'flex flex-col gap-1 opacity-100 transition-opacity duration-200',
(!fetchGitHubConnectionStatus.data.value?.connected ||
state?.status === 'missing_installation') &&
'pointer-events-none opacity-60',
)
"
>
<ShadcnUiSelect
v-model="selectedRepository"
:disabled="
!fetchGitHubConnectionStatus.data.value?.connected ||
fetchGitHubRepositories.isLoading.value ||
fetchGitHubRepositories.state.value.data?.length === 0 ||
(!!state &&
state.status !== 'active' &&
state.status !== 'missing_repository' &&
state.status !== 'missing_branch')
"
>
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{ $t("editor.hooks.github-tracking.repository-select-label") }}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
fetchGitHubRepositories.isLoading.value
? $t(
'editor.hooks.github-tracking.repository-select-loading-placeholder',
)
"
/>
<HookReadonlyField
:label="$t('editor.hooks.github-tracking.branch-select-label')"
:rows="
scalarFieldRows(
hookData.settings.branch,
targetSettings?.branch ?? null,
: $t(
'editor.hooks.github-tracking.repository-select-placeholder',
)
"
/>
<HookReadonlyField
:label="$t('editor.hooks.github-tracking.path-select-label')"
:rows="
listFieldRows(
hookData.settings.paths,
targetSettings?.paths ?? null,
)
"
/>
</template>
<ShadcnUiSelect
v-if="!isReadOnlyOrDiff"
v-model="selectedRepository"
:disabled="
!fetchGitHubConnectionStatus.data.value?.connected ||
fetchGitHubRepositories.isLoading.value ||
fetchGitHubRepositories.state.value.data?.length === 0 ||
(!!hookData &&
hookData.state.status !== 'active' &&
hookData.state.status !== 'missing_repository' &&
hookData.state.status !== 'missing_branch')
"
>
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{
$t("editor.hooks.github-tracking.repository-select-label")
}}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
fetchGitHubRepositories.isLoading.value
? $t(
'editor.hooks.github-tracking.repository-select-loading-placeholder',
)
: $t(
'editor.hooks.github-tracking.repository-select-placeholder',
)
"
/>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh] max-w-[15rem]"
side="bottom"
align="start"
body-lock
>
<ShadcnUiSelectItem
v-for="item in fetchGitHubRepositories.state.value.data"
:key="item.name"
:value="item.name"
class="text-2sm"
>
<div class="flex-1 truncate">
{{ item.name }}
</div>
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<ShadcnUiSelect
v-if="!isReadOnlyOrDiff"
v-model="selectedBranch"
:disabled="
!selectedRepository ||
fetchGitHubBranches.isLoading.value ||
(!!hookData &&
hookData.state.status !== 'active' &&
hookData.state.status !== 'missing_branch')
"
>
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{ $t("editor.hooks.github-tracking.branch-select-label") }}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
!selectedRepository
? $t(
'editor.hooks.github-tracking.branch-select-repository-placeholder',
)
: fetchGitHubBranches.isLoading.value
? $t(
'editor.hooks.github-tracking.branch-select-loading-placeholder',
)
: $t(
'editor.hooks.github-tracking.branch-select-placeholder',
)
"
/>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh] max-w-[15rem]"
side="bottom"
align="start"
>
<ShadcnUiSelectItem
v-for="branch in fetchGitHubBranches.state.value.data"
:key="branch"
:value="branch"
class="text-2sm"
>
<div class="flex-1 truncate">
{{ branch }}
</div>
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<FileSelectInput
v-if="!isReadOnlyOrDiff"
v-model="selectedPaths"
class="w-full"
:disabled="
!selectedRepository ||
!selectedBranch ||
fetchGitHubPaths.isLoading.value ||
(!!hookData && hookData.state.status !== 'active')
"
:options="fetchGitHubPaths.state.value.data || []"
:placeholder="
!selectedRepository
? $t(
'editor.hooks.github-tracking.path-select-repository-placeholder',
)
: !selectedBranch
? $t(
'editor.hooks.github-tracking.path-select-branch-placeholder',
)
: fetchGitHubPaths.isLoading.value
? $t(
'editor.hooks.github-tracking.path-select-loading-placeholder',
)
: $t(
'editor.hooks.github-tracking.path-select-placeholder',
)
"
:empty-folder-placeholder="
$t('editor.hooks.github-tracking.empty-folder-placeholder')
"
>
<template #label>
<span>
{{ $t("editor.hooks.github-tracking.path-select-label") }}
</span>
</template>
<template #selection-text="{ count }">
<i18n-t
v-if="count > 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.other"
tag="span"
>
<template #count>{{ count }}</template>
</i18n-t>
<i18n-t
v-if="count === 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.one"
tag="span"
/>
</template>
</FileSelectInput>
</div>
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
</div>
<div v-if="!isReadOnlyOrDiff" class="p-0.75">
<div v-if="hookData" class="flex flex-col gap-1">
<div class="flex gap-1">
<ShadcnUiButton
class="flex-1 gap-1"
:variant="hookData?.score === 0 ? 'outline' : 'default'"
size="2sm"
:disabled="invalidData"
@click.stop="upsertHook"
>
<Icon name="mingcute:save-2-line" />
{{ $t("editor.hooks.update") }}
</ShadcnUiButton>
<ShadcnUiButton
class="flex-1 gap-1"
variant="secondary"
size="2sm"
@click.stop="deleteHook"
>
<Icon name="mingcute:delete-2-line" />
{{ $t("editor.hooks.delete") }}
</ShadcnUiButton>
"
/>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh] max-w-[15rem]"
side="bottom"
align="start"
body-lock
>
<ShadcnUiSelectItem
v-for="item in fetchGitHubRepositories.state.value.data"
:key="item.name"
:value="item.name"
class="text-2sm"
>
<div class="flex-1 truncate">
{{ item.name }}
</div>
<ShadcnUiButton
v-if="hookData?.score === 0 && hookData.state.status === 'active'"
class="gap-1"
size="2sm"
@click.stop="resetHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.reset") }}
</ShadcnUiButton>
</div>
<template v-else>
<ShadcnUiButton
class="w-full gap-1"
size="2sm"
:disabled="invalidData"
@click.stop="upsertHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.create") }}
</ShadcnUiButton>
</template>
</div>
</div>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<ShadcnUiSelect
v-model="selectedBranch"
:disabled="
!selectedRepository ||
fetchGitHubBranches.isLoading.value ||
(!!state &&
state.status !== 'active' &&
state.status !== 'missing_branch')
"
>
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{ $t("editor.hooks.github-tracking.branch-select-label") }}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
!selectedRepository
? $t(
'editor.hooks.github-tracking.branch-select-repository-placeholder',
)
: fetchGitHubBranches.isLoading.value
? $t(
'editor.hooks.github-tracking.branch-select-loading-placeholder',
)
: $t('editor.hooks.github-tracking.branch-select-placeholder')
"
/>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh] max-w-[15rem]"
side="bottom"
align="start"
>
<ShadcnUiSelectItem
v-for="branch in fetchGitHubBranches.state.value.data"
:key="branch"
:value="branch"
class="text-2sm"
>
<div class="flex-1 truncate">
{{ branch }}
</div>
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<FileSelectInput
v-model="selectedPaths"
class="w-full"
:disabled="
!selectedRepository ||
!selectedBranch ||
fetchGitHubPaths.isLoading.value ||
(!!state && state.status !== 'active')
"
:options="fetchGitHubPaths.state.value.data || []"
:placeholder="
!selectedRepository
? $t(
'editor.hooks.github-tracking.path-select-repository-placeholder',
)
: !selectedBranch
? $t(
'editor.hooks.github-tracking.path-select-branch-placeholder',
)
: fetchGitHubPaths.isLoading.value
? $t(
'editor.hooks.github-tracking.path-select-loading-placeholder',
)
: $t('editor.hooks.github-tracking.path-select-placeholder')
"
:empty-folder-placeholder="
$t('editor.hooks.github-tracking.empty-folder-placeholder')
"
>
<template #label>
<span>
{{ $t("editor.hooks.github-tracking.path-select-label") }}
</span>
</template>
<template #selection-text="{ count }">
<i18n-t
v-if="count > 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.other"
tag="span"
>
<template #count>{{ count }}</template>
</i18n-t>
<i18n-t
v-if="count === 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.one"
tag="span"
/>
</template>
</FileSelectInput>
</div>
</HookConfigPanel>
</template>

View File

@ -0,0 +1,249 @@
import { setResponseStatus, type H3Event } from "h3"
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import { useHookActions } from "./hook-actions"
import { makeHook } from "./test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
type RecordedCall,
} from "~/composables/api/test-helpers"
import { raisedToasts, t, WAIT_FOR_OPTIONS } from "~/components/test-helpers"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const HOOK_PATH = `/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`
const SETTINGS = { url: "https://oxynote.test" }
// the editor store, the query cache and the mocked toast module are all
// shared, so these tests cannot interleave
describe("useHookActions", { concurrent: false }, () => {
beforeEach(() => {
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
})
afterEach(disposeMockEndpoints)
it("creates a hook on the open block after closing the menu", async ({
expect,
}) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const { actions, close } = await mountActions(null)
const done = await actions.create(SETTINGS)
expect(done).toBe(true)
expect(close).toHaveBeenCalledTimes(1)
expect(calls).toHaveLength(1)
expect(calls[0]?.body).toEqual({
type: DocumentHookType.URLWatcher,
branchId: BRANCH_ID,
blockId: "block-1",
settings: SETTINGS,
})
expect(raisedToasts()).toEqual([])
})
it("creates nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const { actions, close } = await mountActions(null)
const done = await actions.create(SETTINGS)
expect(done).toBe(false)
expect(close).toHaveBeenCalledTimes(0)
expect(calls).toHaveLength(0)
})
it("says so when the hook cannot be created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, failing)
const { actions } = await mountActions(null)
const done = await actions.create(SETTINGS)
expect(done).toBe(false)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.create-failed") },
])
})
it("updates the hook's settings after closing the menu", async ({
expect,
}) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const { actions, close } = await mountActions()
const done = await actions.update(SETTINGS)
expect(done).toBe(true)
expect(close).toHaveBeenCalledTimes(1)
expect(calls).toHaveLength(1)
expect(calls[0]?.body).toEqual({ settings: SETTINGS })
})
it.for([
{
name: "an update",
input: false,
expected: "editor.hooks.errors.update-failed",
},
{
name: "a renewal",
input: true,
expected: "editor.hooks.errors.renew-failed",
},
])("says so when $name fails", async ({ input, expected }, { expect }) => {
mockEndpoint("PUT", HOOK_PATH, failing)
const { actions } = await mountActions()
const done = await actions.update(SETTINGS, input)
expect(done).toBe(false)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t(expected) },
])
})
it("updates nothing without a hook", async ({ expect }) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const { actions, close } = await mountActions(null)
const done = await actions.update(SETTINGS)
expect(done).toBe(false)
expect(close).toHaveBeenCalledTimes(0)
expect(calls).toHaveLength(0)
})
it("deletes the hook after closing the menu", async ({ expect }) => {
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
const { actions, close } = await mountActions()
const done = await actions.remove()
expect(done).toBe(true)
expect(close).toHaveBeenCalledTimes(1)
expect(calls).toHaveLength(1)
})
it("says so when the hook cannot be deleted", async ({ expect }) => {
mockEndpoint("DELETE", HOOK_PATH, failing)
const { actions } = await mountActions()
const done = await actions.remove()
expect(done).toBe(false)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.delete-failed") },
])
})
it("deletes nothing without a hook", async ({ expect }) => {
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
const { actions, close } = await mountActions(null)
const done = await actions.remove()
expect(done).toBe(false)
expect(close).toHaveBeenCalledTimes(0)
expect(calls).toHaveLength(0)
})
it("resets the hook after closing the menu", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
const { actions, close } = await mountActions()
const done = await actions.reset()
expect(done).toBe(true)
expect(close).toHaveBeenCalledTimes(1)
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("says so when the hook cannot be reset", async ({ expect }) => {
mockEndpoint("PUT", `${HOOK_PATH}/reset`, failing)
const { actions } = await mountActions()
const done = await actions.reset()
expect(done).toBe(false)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.reset-failed") },
])
})
it("resets nothing without a hook", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
const { actions, close } = await mountActions(null)
const done = await actions.reset()
expect(done).toBe(false)
expect(close).toHaveBeenCalledTimes(0)
expect(calls).toHaveLength(0)
})
})
// the composable reads i18n, the editor store and the api through a
// component instance, so each test mounts a small host
async function mountActions(
hook: DocumentHook | null = makeHook({
id: HOOK_ID,
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
}),
) {
const close = vi.fn()
const host: { actions?: ReturnType<typeof useHookActions> } = {}
await mountSuspended(
defineComponent({
setup() {
host.actions = useHookActions({
type: DocumentHookType.URLWatcher,
nodeId: () => "block-1",
hook: () => hook,
close: close,
})
return () => h("div")
},
}),
)
if (!host.actions) {
throw new Error("the host did not set up the actions")
}
return { actions: host.actions, close: close }
}
function failing(_call: RecordedCall, event: H3Event) {
setResponseStatus(event, 500)
return { message: "boom" }
}

View File

@ -0,0 +1,131 @@
import { showToastMessage } from "~/components/toast"
// useHookActions sends the requests a hook's menu makes. Each one closes
// the menu before it goes out, raises a toast when it fails, and resolves
// to whether it went through.
export function useHookActions(options: {
type: DocumentHookType
nodeId: () => string | null
hook: () => DocumentHook | null | undefined
close: () => void
}) {
const { t } = useI18n({ useScope: "global" })
const editorStore = useEditorStore()
const documentHookAPI = useDocumentHookAPI()
async function create(settings: DocumentHookSettings): Promise<boolean> {
const docId = editorStore.activeDocumentId
const branchId = editorStore.activeBranchId
if (!docId || !branchId) {
return false
}
options.close()
try {
await documentHookAPI.createDocumentHookByDocID.mutateAsync({
docId: docId,
req: {
type: options.type,
branchId: branchId,
blockId: options.nodeId(),
settings: settings,
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.create-failed"))
return false
}
return true
}
// a renewal is an update that moves a reminder's date, and its toast
// says so
async function update(
settings: DocumentHookSettings,
renewal = false,
): Promise<boolean> {
const hook = options.hook()
const docId = editorStore.activeDocumentId
const branchId = editorStore.activeBranchId
if (!hook || !docId || !branchId) {
return false
}
options.close()
try {
await documentHookAPI.updateDocumentHookByDocID.mutateAsync({
docId: docId,
branchId: branchId,
hookId: hook.id,
req: { settings: settings },
})
} catch {
showToastMessage(
"error",
renewal
? t("editor.hooks.errors.renew-failed")
: t("editor.hooks.errors.update-failed"),
)
return false
}
return true
}
async function remove(): Promise<boolean> {
const hook = options.hook()
const docId = editorStore.activeDocumentId
const branchId = editorStore.activeBranchId
if (!hook || !docId || !branchId) {
return false
}
options.close()
try {
await documentHookAPI.deleteDocumentHookByDocID.mutateAsync({
docId: docId,
branchId: branchId,
hookId: hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.delete-failed"))
return false
}
return true
}
async function reset(): Promise<boolean> {
const hook = options.hook()
const docId = editorStore.activeDocumentId
const branchId = editorStore.activeBranchId
if (!hook || !docId || !branchId) {
return false
}
options.close()
try {
await documentHookAPI.resetDocumentHookByDocID.mutateAsync({
docId: docId,
branchId: branchId,
hookId: hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.reset-failed"))
return false
}
return true
}
return { create, update, remove, reset }
}

View File

@ -68,6 +68,16 @@ function metricBlock(uid: string | null, str: string): PMNode {
return schema.nodes[METRIC_BLOCK_NAME].create({ uid }, schema.text(str))
}
// a website hook whose last check failed
function failedHook(blockId: string): DocumentHook {
return {
...hook(blockId, "100"),
type: DocumentHookType.URLWatcher,
settings: { url: "https://example.com" },
state: { lastCheckedAt: "2026-01-01T00:00:00Z", status: "unreachable_url" },
}
}
function hook(blockId: string | null, score = "0"): DocumentHook {
return {
id: `hook-${blockId ?? "none"}`,
@ -138,6 +148,23 @@ function decorationShape(
.sort((a, b) => a[0] - b[0] || a[1] - b[1])
}
// the status each bar shows, read off its widget key
function barStatuses(plugin: Plugin, state: EditorState): string[] {
const decorations = plugin.props.decorations?.call(plugin, state)
if (!decorations) {
return []
}
return (decorations as DecorationSet)
.find()
.sort((a, b) => a.from - b.from)
.flatMap((deco) => {
const spec = deco.spec as { key?: string }
return spec.key ? [spec.key.replace(/^hook-decoration-\d+-/, "")] : []
})
}
describe("HookDecorator", () => {
it("decorates a matching block with a node decoration and an inner widget", ({
expect,
@ -191,6 +218,36 @@ describe("HookDecorator", () => {
expect(decorationShape(plugin, state)).toEqual(expected)
})
it.for([
{
name: "marks a block with a triggered hook as triggered",
hooks: () => [hook("u1"), hook("u1", "100")],
expected: ["triggered"],
},
{
name: "marks a block whose hook failed its check as needing attention",
hooks: () => [failedHook("u1")],
expected: ["needs-attention"],
},
{
name: "marks a block with a trigger and a failed check as mixed",
hooks: () => [hook("u1"), failedHook("u1")],
expected: ["mixed"],
},
{
name: "keeps the kinds of two blocks apart",
hooks: () => [hook("u1"), failedHook("u2")],
expected: ["triggered", "needs-attention"],
},
])("$name", ({ hooks, expected }, { expect }) => {
const { plugin, state } = makeState(
docOf(para("u1", "hi"), para("u2", "yo")),
hooks,
)
expect(barStatuses(plugin, state)).toEqual(expected)
})
it("never decorates a block whose uid is empty", ({ expect }) => {
const { plugin, state } = makeState(docOf(para("", "hi")), () => [hook("")])

View File

@ -10,6 +10,11 @@ import {
IMAGE_BLOCK_NAME,
METRIC_BLOCK_NAME,
} from "../blocks/node-names"
import {
HOOK_BAR_STATUS_CLASS,
hookGroupStatus,
type HookGroupStatus,
} from "./hook-status"
/** block types that use node views and need the decoration widget placed
* outside the node (at `pos`) rather than inside (at `pos + 1`) */
@ -34,14 +39,36 @@ interface Options {
getHooks: () => DocumentHook[]
}
type BarStatus = Exclude<HookGroupStatus, "fresh">
const key = new PluginKey("hook-decorator")
function toAvailableIDs(hooks: DocumentHook[]): Set<string> {
return new Set(
hooks
.filter((h) => h.blockId !== null && Number(h.score) === 0)
.map((h) => String(h.blockId)),
)
// barStatusByBlock sums up the hooks of each block into what its bar
// shows. A block whose hooks are all fresh is left out.
function barStatusByBlock(hooks: DocumentHook[]): Map<string, BarStatus> {
const hooksByBlock = new Map<string, DocumentHook[]>()
for (const hook of hooks) {
if (hook.blockId === null) {
continue
}
hooksByBlock.set(hook.blockId, [
...(hooksByBlock.get(hook.blockId) ?? []),
hook,
])
}
const statuses = new Map<string, BarStatus>()
for (const [blockId, blockHooks] of hooksByBlock) {
const status = hookGroupStatus(blockHooks)
if (status && status !== "fresh") {
statuses.set(blockId, status)
}
}
return statuses
}
const HOOK_DECORATION_ATTR = "data-hook-decoration"
@ -56,7 +83,7 @@ interface HookDecorationElement extends HTMLElement {
function buildDecorations(
doc: PMNode,
opt: Options,
ids: Set<string>,
statuses: Map<string, BarStatus>,
): DecorationSet {
const decorations: Decoration[] = []
@ -67,8 +94,8 @@ function buildDecorations(
const val = node.attrs[opt.attributeName] as string | null | undefined
const hasAttr = val != null && val !== ""
if (!hasAttr || !ids.has(val)) {
const status = hasAttr ? statuses.get(val) : undefined
if (!status) {
return true
}
@ -145,7 +172,8 @@ function buildDecorations(
el.className = cn(
"pointer-events-none absolute top-0",
"w-1.25 bg-hook-decoration block rounded-r-lg",
"block w-1.25 rounded-r-lg",
HOOK_BAR_STATUS_CLASS[status],
// the bar hugs the page edge in full view; in compact view the
// column floats, so it becomes a pill
"in-data-compact-view:rounded-full",
@ -156,7 +184,9 @@ function buildDecorations(
},
{
side: -1,
key: `hook-decoration-${pos}`,
// the key carries the status, so a bar whose status changes
// is drawn again
key: `hook-decoration-${pos}-${status}`,
ignoreSelection: true,
},
),
@ -200,10 +230,10 @@ export const HookDecorator = Extension.create<Partial<Options>>({
key,
state: {
init: (_cfg, { doc }) => {
const ids = toAvailableIDs(opt.getHooks())
const statuses = barStatusByBlock(opt.getHooks())
return {
ids,
decos: buildDecorations(doc, opt, ids),
statuses,
decos: buildDecorations(doc, opt, statuses),
}
},
apply(tr, old, _oldState, newState) {
@ -211,15 +241,15 @@ export const HookDecorator = Extension.create<Partial<Options>>({
{ hooksChanged?: boolean } | undefined
if (tr.docChanged || meta?.hooksChanged) {
const ids = toAvailableIDs(opt.getHooks())
const statuses = barStatusByBlock(opt.getHooks())
return {
ids,
decos: buildDecorations(newState.doc, opt, ids),
statuses,
decos: buildDecorations(newState.doc, opt, statuses),
}
}
return {
ids: old.ids,
statuses: old.statuses,
decos: old.decos.map(tr.mapping, tr.doc),
}
},

View File

@ -0,0 +1,31 @@
import { describe, it } from "vitest"
import { hookNoticeKeypath } from "./hook-menu"
import type { HookStatus } from "./hook-status"
import { DocumentHookType } from "~/utils/api/document"
describe("hookNoticeKeypath", () => {
it.for<{ name: string; input: HookStatus | null; expected: string }>([
{
name: "says what a hook that is still being made does",
input: null,
expected: "editor.hooks.url-watcher.new-notice",
},
{
name: "says what a waiting hook will do",
input: "fresh",
expected: "editor.hooks.url-watcher.fresh-notice",
},
{
name: "says what a triggered hook did",
input: "triggered",
expected: "editor.hooks.url-watcher.triggered-notice",
},
{
name: "says what a hook that needs attention will do",
input: "needs-attention",
expected: "editor.hooks.url-watcher.fresh-notice",
},
])("$name", ({ input, expected }, { expect }) => {
expect(hookNoticeKeypath(DocumentHookType.URLWatcher, input)).toBe(expected)
})
})

View File

@ -0,0 +1,33 @@
import type { HookStatus } from "./hook-status"
// HookSubtitle is the second line of a hook's row. The detail follows the
// text after a dot, like a status or how far off a date is.
export interface HookSubtitle {
text: string
detail?: string
}
// the list of hooks keeps to this width. The floor leaves a two line row
// room to breathe, and the cap fits a phone 22.5rem wide
export const HOOK_MENU_WIDTH_CLASS =
"min-w-[min(17rem,calc(100dvw-1.5rem))] max-w-[min(20rem,calc(100dvw-1.5rem))]"
// the type picker and the forms hold less, so their floor is lower
export const HOOK_SUBMENU_WIDTH_CLASS =
"min-w-[min(15rem,calc(100dvw-1.5rem))] max-w-[min(20rem,calc(100dvw-1.5rem))]"
// hookNoticeKeypath picks the message of a hook's notice. A hook that is
// still being made says what it does, a triggered one what it did, and any
// other what it will do. The status is null until the hook exists.
export function hookNoticeKeypath(
type: DocumentHookType,
status: HookStatus | null,
): string {
if (!status) {
return `editor.hooks.${type}.new-notice`
}
return status === "triggered"
? `editor.hooks.${type}.triggered-notice`
: `editor.hooks.${type}.fresh-notice`
}

View File

@ -0,0 +1,127 @@
import { describe, it } from "vitest"
import { hookGroupStatus, hookStatus } from "./hook-status"
import {
DocumentHookType,
type DocumentHook,
type DocumentHookState,
} from "~/utils/api/document"
const FRESH = { score: "100", state: { status: "active" } }
const TRIGGERED = { score: "0", state: { status: "active" } }
const FAILED = { score: "100", state: { status: "unreachable_url" } }
describe("hookStatus", () => {
it.for([
{
name: "shows a waiting hook as fresh",
input: { score: "100", state: { status: "active" } },
expected: "fresh",
},
{
name: "shows a reminder, which stores no status, as fresh",
input: { score: "40", state: { lastActiveAt: "2026-01-01" } },
expected: "fresh",
},
{
name: "shows a hook at score 0 as triggered",
input: { score: "0", state: { status: "active" } },
expected: "triggered",
},
{
name: "shows an unreachable website as needing attention",
input: { score: "100", state: { status: "unreachable_url" } },
expected: "needs-attention",
},
{
name: "shows an unreachable image as needing attention",
input: { score: "100", state: { status: "unauthorized", digest: "" } },
expected: "needs-attention",
},
{
name: "shows a missing github installation as needing attention",
input: {
score: "100",
state: { status: "missing_installation", pathsChecksums: {} },
},
expected: "needs-attention",
},
{
name: "shows a missing repository as needing attention",
input: {
score: "100",
state: { status: "missing_repository", pathsChecksums: {} },
},
expected: "needs-attention",
},
{
name: "shows a missing branch as needing attention",
input: {
score: "100",
state: { status: "missing_branch", pathsChecksums: {} },
},
expected: "needs-attention",
},
{
name: "puts a failed check before a trigger",
input: { score: "0", state: { status: "unreachable_url" } },
expected: "needs-attention",
},
])("$name", ({ input, expected }, { expect }) => {
expect(hookStatus(makeHook(input))).toBe(expected)
})
})
describe("hookGroupStatus", () => {
it.for([
{
name: "has no status without hooks",
input: [],
expected: null,
},
{
name: "is fresh while every hook is",
input: [FRESH, FRESH],
expected: "fresh",
},
{
name: "is triggered once one hook is",
input: [FRESH, TRIGGERED],
expected: "triggered",
},
{
name: "needs attention once a check failed",
input: [FRESH, FAILED],
expected: "needs-attention",
},
{
name: "counts a failed check that also scored 0 as the failure alone",
input: [{ score: "0", state: { status: "unreachable_url" } }],
expected: "needs-attention",
},
{
name: "is mixed with a trigger next to a failed check",
input: [FAILED, FRESH, TRIGGERED],
expected: "mixed",
},
])("$name", ({ input, expected }, { expect }) => {
expect(hookGroupStatus(input.map(makeHook))).toBe(expected)
})
})
function makeHook(input: {
score: string
state: Record<string, unknown>
}): DocumentHook {
return {
id: "hook-1",
type: DocumentHookType.URLWatcher,
documentId: "doc-1",
organizationId: "org-1",
branchId: "branch-1",
blockId: null,
settings: { url: "https://example.com" },
state: input.state as unknown as DocumentHookState,
score: input.score,
createdAt: new Date("2026-01-01T00:00:00Z"),
}
}

View File

@ -0,0 +1,71 @@
// HookStatus is how a hook shows in its menu: fresh while it waits,
// triggered once it went off, and needs-attention while the last check of
// what it watches failed.
export type HookStatus = "fresh" | "triggered" | "needs-attention"
export const HOOK_STATUS_DOT_CLASS: Record<HookStatus, string> = {
fresh: "bg-hook-status-fresh",
triggered: "bg-hook-status-triggered",
"needs-attention": "bg-hook-status-needs-attention",
}
// a pill tints its ground with the status colour and darkens its text
export const HOOK_STATUS_PILL_CLASS: Record<HookStatus, string> = {
fresh: "bg-hook-status-fresh/15 text-status-success-foreground",
triggered: "bg-hook-status-triggered/15 text-status-info-foreground",
"needs-attention":
"bg-hook-status-needs-attention/25 text-status-warning-foreground",
}
// HookGroupStatus sums up the hooks of one block, or the page's own. It is
// mixed when one hook is triggered and another needs attention.
export type HookGroupStatus = HookStatus | "mixed"
// the hook icon takes the colour of the status in its data-hook-status. An
// icon is drawn through a mask, so the stripes of a mixed status show on
// it as they do on the bar.
export const HOOK_ICON_STATUS_CLASS =
"data-[hook-status=fresh]:text-hook-status-fresh data-[hook-status=triggered]:text-hook-status-triggered data-[hook-status=needs-attention]:text-hook-status-needs-attention data-[hook-status=mixed]:bg-hook-status-mixed"
// the bar beside a block or the page title. Fresh hooks get none.
export const HOOK_BAR_STATUS_CLASS: Record<
Exclude<HookGroupStatus, "fresh">,
string
> = {
triggered: "bg-hook-status-triggered",
"needs-attention": "bg-hook-status-needs-attention",
mixed: "bg-hook-status-mixed",
}
// hookStatus puts a failed check first. A hook that cannot check what it
// watches needs fixing before its trigger means anything. A stored status
// is "active" or the reason the last check failed, and a reminder stores
// none.
export function hookStatus(hook: DocumentHook): HookStatus {
if ("status" in hook.state && hook.state.status !== "active") {
return "needs-attention"
}
return Number(hook.score) === 0 ? "triggered" : "fresh"
}
// hookGroupStatus is null without hooks, so that nothing is coloured as
// healthy where there is nothing to watch.
export function hookGroupStatus(hooks: DocumentHook[]): HookGroupStatus | null {
if (!hooks.length) {
return null
}
const statuses = hooks.map(hookStatus)
const triggered = statuses.includes("triggered")
const needsAttention = statuses.includes("needs-attention")
if (triggered && needsAttention) {
return "mixed"
}
if (triggered) {
return "triggered"
}
return needsAttention ? "needs-attention" : "fresh"
}

View File

@ -1,12 +1,16 @@
import { setResponseStatus } from "h3"
import { CalendarDate } from "@internationalized/date"
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import { DiffStatus } from "../../diff/position-map"
import {
hookNotice,
makeHook,
menuButton,
menuButtonLabels,
menuText,
mountHookMenu,
openHookSubMenu,
@ -15,7 +19,6 @@ import {
import {
clearQueryCache,
disposeMockEndpoints,
ANY_STRING,
makeXid,
mockEndpoint,
runInApp,
@ -23,9 +26,12 @@ import {
import {
clearTeleportedOverlays,
emitFrom,
raisedToasts,
settleMutations,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import CalendarInput from "~/components/CalendarInput.vue"
import { Select } from "~/components/shadcn/ui/select"
vi.mock("vue-sonner", () => ({
@ -35,9 +41,12 @@ vi.mock("vue-sonner", () => ({
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const HOOK_PATH = `/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`
const NEW_HOOK_LABEL = "editor.hooks.scheduled-reminder.title"
const TITLE = "editor.hooks.scheduled-reminder.title"
const NOW = new Date("2026-08-29T10:00:00Z")
const SCHEDULE = new Date("2026-09-01T10:00:00Z")
const DAY_MS = 24 * 60 * 60 * 1000
function reminderHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
@ -46,7 +55,14 @@ function reminderHook(overrides: Partial<DocumentHook> = {}) {
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
settings: { scale: "linear", duration: "24h", schedule: SCHEDULE },
state: { status: "active" },
state: { lastActiveAt: NOW },
...overrides,
})
}
function customReminderHook(overrides: Partial<DocumentHook> = {}) {
return reminderHook({
settings: { scale: "linear", duration: "custom", schedule: SCHEDULE },
...overrides,
})
}
@ -60,45 +76,64 @@ async function pickDuration(wrapper: VueWrapper, duration: string) {
await nextTick()
}
// the editor store, the query cache, the mocked toast module and the
// teleported menu bodies are all shared, so these tests cannot interleave
async function pickDate(wrapper: VueWrapper, date: CalendarDate) {
emitFrom(wrapper, CalendarInput, "update:modelValue", date)
await nextTick()
}
// the editor store, the query cache, the mocked toast module, the faked
// clock and the teleported menu bodies are all shared, so these tests
// cannot interleave
describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] })
vi.setSystemTime(NOW)
clearTeleportedOverlays()
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
afterEach(() => {
vi.useRealTimers()
disposeMockEndpoints()
})
it("offers to schedule a reminder", async ({ expect }) => {
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
expect(menuText()).toContain(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.description"),
)
})
it("names the date and time an active reminder will fire on", async ({
expect,
}) => {
it("says when a waiting reminder goes out", async ({ expect }) => {
await mountMenu({ hook: reminderHook() })
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.existing-item", {
value: formatSchedule(SCHEDULE),
t("editor.hooks.subtext-detail", {
subtext: formatSchedule(SCHEDULE),
detail: "in 3 days",
}),
)
})
it("reports when a triggered reminder went out", async ({ expect }) => {
it("says how long ago a triggered reminder went out", async ({ expect }) => {
vi.setSystemTime(new Date(SCHEDULE.getTime() + DAY_MS))
await mountMenu({ hook: reminderHook({ score: "0" }) })
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.triggered-item", {
value: formatSchedule(SCHEDULE),
t("editor.hooks.subtext-detail", {
subtext: t("editor.hooks.scheduled-reminder.subtext-triggered", {
date: formatSchedule(SCHEDULE),
}),
detail: "1 day ago",
}),
)
})
@ -106,7 +141,7 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
it("offers the preset durations", async ({ expect }) => {
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.duration-label"),
@ -120,7 +155,7 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
expect,
}) => {
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
@ -134,7 +169,7 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "24h")
menuButton(t("editor.hooks.create")).click()
@ -146,18 +181,37 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
type: DocumentHookType.ScheduledReminder,
branchId: BRANCH_ID,
blockId: "block-1",
settings: { scale: "linear", duration: "24h", schedule: ANY_STRING },
settings: {
scale: "linear",
duration: "24h",
schedule: new Date(NOW.getTime() + DAY_MS).toISOString(),
},
})
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toHaveLength(1)
})
it("starts empty again once a reminder is created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, () => ({
id: HOOK_ID,
}))
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "24h")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
await openHookSubMenu(t(TITLE))
expect(wrapper.findComponent(Select).props("modelValue")).toBeUndefined()
})
it("asks for a date when the reader picks a custom duration", async ({
expect,
}) => {
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "custom")
@ -167,59 +221,221 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
it("warns when the reminder cannot be created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
it.for([
{
name: "block",
nodeId: "block-1",
key: "editor.hooks.scheduled-reminder.fresh-notice",
},
{
name: "page",
nodeId: null,
key: "editor.hooks.scheduled-reminder.fresh-notice",
},
])(
"says when a waiting $name reminder goes out",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: reminderHook(), nodeId: nodeId })
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("fresh")
expect(hookNotice().textContent.trim()).toBe(
t(key, {
when: t("editor.hooks.scheduled-reminder.when-on-date", {
date: formatSchedule(SCHEDULE),
}),
}),
)
},
)
it("names the picked preset in its notice", async ({ expect }) => {
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "72h")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.scheduled-reminder.new-notice", {
when: t("editor.hooks.scheduled-reminder.when-options.72h"),
}),
)
})
it("asks for a date in its notice until one is picked", async ({
expect,
}) => {
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "custom")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.scheduled-reminder.new-notice", {
when: t("editor.hooks.scheduled-reminder.when-unset"),
}),
)
expect(hookNotice().querySelector(".font-semibold")).toBeNull()
})
it("names the picked date in its notice", async ({ expect }) => {
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "custom")
await pickDate(wrapper, new CalendarDate(2026, 9, 10))
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.scheduled-reminder.new-notice", {
when: t("editor.hooks.scheduled-reminder.when-on-date", {
date: formatLong(new Date(2026, 8, 10)),
}),
}),
)
})
it("starts a waiting reminder on its own preset", async ({ expect }) => {
const wrapper = await mountMenu({ hook: reminderHook() })
await openHookSubMenu(t(TITLE))
expect(wrapper.findComponent(Select).props("modelValue")).toBe("24h")
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("starts a waiting custom reminder on its date", async ({ expect }) => {
const wrapper = await mountMenu({ hook: customReminderHook() })
await openHookSubMenu(t(TITLE))
expect(wrapper.findComponent(Select).props("modelValue")).toBe("custom")
// the input is generic, which find by component cannot type
expect(
String(
wrapper.findComponent({ name: "CalendarInput" }).props("modelValue"),
),
).toBe(String(dateToCalendarDate(SCHEDULE)))
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("sends nothing while no duration is picked", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
emitFrom(wrapper, HookConfigPanel, "submit")
await settleMutations()
expect(calls).toHaveLength(0)
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toBeUndefined()
})
it("keeps the update button out of reach until a custom date is picked", async ({
expect,
}) => {
const wrapper = await mountMenu({ hook: reminderHook() })
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "custom")
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("updates a waiting reminder once another duration is picked", async ({
expect,
}) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const wrapper = await mountMenu({ hook: reminderHook() })
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "72h")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: {
scale: "linear",
duration: "72h",
schedule: new Date(NOW.getTime() + 3 * DAY_MS).toISOString(),
},
})
})
it("calls a failed change to a waiting reminder an update", async ({
expect,
}) => {
mockEndpoint("PUT", HOOK_PATH, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickDuration(wrapper, "24h")
const wrapper = await mountMenu({ hook: reminderHook() })
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "72h")
menuButton(t("editor.hooks.create")).click()
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.update-failed") },
])
}, WAIT_FOR_OPTIONS)
})
it("explains what an active block reminder will do", async ({ expect }) => {
await mountMenu({ hook: reminderHook() })
await openHookSubMenu("Remind on")
expect(menuText()).toContain("the block will be highlighted")
})
it("explains what an active document-wide reminder will do", async ({
expect,
}) => {
await mountMenu({ hook: reminderHook(), nodeId: null })
await openHookSubMenu("Remind on")
expect(menuText()).toContain("the relevant sections will be highlighted")
})
it("offers no renewal for a reminder that has not fired", async ({
expect,
}) => {
await mountMenu({ hook: reminderHook() })
await openHookSubMenu("Remind on")
expect(menuText()).not.toContain(t("editor.hooks.renew"))
})
it("renews a reminder that has fired", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
)
it("asks for a new pick on a triggered reminder", async ({ expect }) => {
const wrapper = await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu("Reminded on")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.duration-label-triggered"),
)
expect(wrapper.findComponent(Select).props("modelValue")).toBeUndefined()
expect(menuButton(t("editor.hooks.renew")).disabled).toBe(true)
expect(menuButtonLabels()).not.toContain(t("editor.hooks.reset"))
})
it("asks for a new pick once a reminder triggers", async ({ expect }) => {
const props = reactive<Record<string, unknown>>({
nodeId: "block-1",
hook: reminderHook(),
})
const wrapper = await mountHookMenu(ConfigMenu, props)
props.hook = reminderHook({ score: "0" })
await nextTick()
expect(wrapper.findComponent(Select).exists()).toBe(false)
await openHookSubMenu(t(TITLE))
expect(wrapper.findComponent(Select).props("modelValue")).toBeUndefined()
})
it("tells when a triggered reminder went out", async ({ expect }) => {
await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.scheduled-reminder.triggered-notice", {
date: formatSchedule(SCHEDULE),
}),
)
})
it("renews a triggered reminder", async ({ expect }) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const wrapper = await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "72h")
menuButton(t("editor.hooks.renew")).click()
@ -228,39 +444,39 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: { scale: "linear", duration: "72h", schedule: ANY_STRING },
settings: {
scale: "linear",
duration: "72h",
schedule: new Date(NOW.getTime() + 3 * DAY_MS).toISOString(),
},
})
})
it("warns when the reminder cannot be renewed", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
it("calls a failed change to a triggered reminder a renewal", async ({
expect,
}) => {
mockEndpoint("PUT", HOOK_PATH, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
return { message: "boom" }
})
const wrapper = await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu("Reminded on")
await openHookSubMenu(t(TITLE))
await pickDuration(wrapper, "72h")
menuButton(t("editor.hooks.renew")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.renew-failed") },
])
}, WAIT_FOR_OPTIONS)
})
it("deletes the reminder", async ({ expect }) => {
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
await mountMenu({ hook: reminderHook() })
await openHookSubMenu("Remind on")
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.delete")).click()
@ -269,127 +485,132 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
}, WAIT_FOR_OPTIONS)
})
it("warns when the reminder cannot be deleted", async ({ expect }) => {
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: reminderHook() })
await openHookSubMenu("Remind on")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("creates nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickDuration(wrapper, "24h")
menuButton(t("editor.hooks.create")).click()
await nextTick()
expect(calls).toHaveLength(0)
})
it("tells the date and time an upcoming reminder goes out", async ({
expect,
}) => {
await mountMenu({ hook: reminderHook() })
await openHookSubMenu("Remind on")
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.existing-item-block-explanation", {
value: formatSchedule(SCHEDULE),
}),
)
})
it("tells when a triggered reminder went out while offering to renew it", async ({
expect,
}) => {
await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu("Reminded on")
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.triggered-item-block-explanation", {
value: formatSchedule(SCHEDULE),
}),
)
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.duration-label-triggered"),
)
})
describe("when the page is read only", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(false)
})
it("shows a waiting reminder's preset without a way to change it", async ({
expect,
}) => {
const wrapper = await mountMenu({ hook: reminderHook() })
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
[presetValue("24h", SCHEDULE), "unchanged"],
])
expect(wrapper.findComponent(Select).exists()).toBe(false)
expect(menuText()).toContain(t("editor.hooks.read-only-mode"))
expect(menuButtonLabels()).toEqual([])
})
it("shows a custom reminder's date", async ({ expect }) => {
await mountMenu({ hook: customReminderHook() })
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
[
t("editor.hooks.scheduled-reminder.field-value-custom", {
date: formatSchedule(SCHEDULE),
}),
"unchanged",
],
])
})
it("renews a triggered reminder by its own preset", async ({ expect }) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
menuButton(
t("editor.hooks.scheduled-reminder.remind-again-options.24h"),
).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: {
scale: "linear",
duration: "24h",
schedule: new Date(NOW.getTime() + DAY_MS).toISOString(),
},
})
})
it("calls a failed renewal by preset a renewal", async ({ expect }) => {
mockEndpoint("PUT", HOOK_PATH, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
await mountMenu({ hook: reminderHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
menuButton(
t("editor.hooks.scheduled-reminder.remind-again-options.24h"),
).click()
await vi.waitFor(() => {
expect(raisedToasts()).toMatchObject([
{ type: "error", title: t("editor.hooks.errors.renew-failed") },
])
}, WAIT_FOR_OPTIONS)
})
it("renews nothing for a reminder without a preset", async ({ expect }) => {
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
const wrapper = await mountMenu({
hook: customReminderHook({ score: "0" }),
})
await openHookSubMenu(t(TITLE))
emitFrom(wrapper, HookConfigPanel, "acknowledge")
await settleMutations()
expect(calls).toHaveLength(0)
})
it("says a triggered custom reminder will not repeat", async ({
expect,
}) => {
await mountMenu({ hook: customReminderHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
expect(hookNotice().textContent).toContain(
t("editor.hooks.scheduled-reminder.one-off-note"),
)
expect(menuButtonLabels()).toEqual([])
})
it.for([
{
name: "an upcoming reminder on a block",
score: "100",
name: "a block",
nodeId: "block-1",
label: "Remind on",
key: "editor.hooks.scheduled-reminder.existing-item-block-explanation",
key: "editor.hooks.scheduled-reminder.triggered-notice",
},
{
name: "an upcoming reminder on the page",
score: "100",
name: "the page",
nodeId: null,
label: "Remind on",
key: "editor.hooks.scheduled-reminder.existing-item-full-document-explanation",
},
{
name: "a triggered reminder on a block",
score: "0",
nodeId: "block-1",
label: "Reminded on",
key: "editor.hooks.scheduled-reminder.triggered-item-block-explanation",
},
{
name: "a triggered reminder on the page",
score: "0",
nodeId: null,
label: "Reminded on",
key: "editor.hooks.scheduled-reminder.triggered-item-full-document-explanation",
key: "editor.hooks.scheduled-reminder.triggered-notice",
},
])(
"explains $name without a way to change it",
async ({ score, nodeId, label, key }, { expect }) => {
await mountMenu({
hook: reminderHook({ score: score }),
nodeId: nodeId,
})
"says when a triggered reminder on $name went out",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: reminderHook({ score: "0" }), nodeId: nodeId })
await openHookSubMenu(label)
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(
t(key, { value: formatSchedule(SCHEDULE) }),
expect(hookNotice().textContent).toContain(
t(key, { date: formatSchedule(SCHEDULE) }),
)
expect(readonlyFields()).toEqual([])
expect(menuText()).not.toContain(
t("editor.hooks.scheduled-reminder.duration-label-triggered"),
expect(hookNotice().textContent).not.toContain(
t("editor.hooks.scheduled-reminder.one-off-note"),
)
expect(menuText()).not.toContain(t("editor.hooks.renew"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
},
)
})
@ -416,17 +637,47 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
},
})
await openHookSubMenu("Remind on")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.existing-item-block-explanation", {
value: formatSchedule(SCHEDULE),
}),
)
expect(readonlyFields()).toEqual([
[formatSchedule(SCHEDULE), "added"],
[formatSchedule(oldSchedule), "removed"],
[presetValue("24h", SCHEDULE), "added"],
[presetValue("24h", oldSchedule), "removed"],
])
expect(menuButtonLabels()).toEqual([])
})
it.for([
{ name: "added", status: DiffStatus.Added },
{ name: "removed", status: DiffStatus.Removed },
])(
"marks every field of an $name reminder",
async ({ status, name }, { expect }) => {
await mountMenu({
hook: reminderHook(),
diff: { status: status, targetHook: null, signColumn: true },
})
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([[presetValue("24h", SCHEDULE), name]])
},
)
it("keeps a triggered reminder from being renewed", async ({ expect }) => {
useEditorMeta().setEditable(false)
await mountMenu({
hook: reminderHook({ score: "0" }),
diff: {
status: DiffStatus.Unchanged,
targetHook: null,
signColumn: false,
},
})
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(menuButtonLabels()).toEqual([])
})
})
})
@ -434,3 +685,14 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
function formatSchedule(date: Date): string {
return runInApp(() => useNuxtApp().$i18n.d(date, "short-with-time"))
}
function formatLong(date: Date): string {
return runInApp(() => useNuxtApp().$i18n.d(date, "long"))
}
function presetValue(duration: string, date: Date): string {
return t("editor.hooks.scheduled-reminder.field-value", {
duration: t(`editor.hooks.scheduled-reminder.duration-options.${duration}`),
date: formatSchedule(date),
})
}

View File

@ -1,11 +1,14 @@
<script setup lang="ts">
import { type DateValue, getLocalTimeZone } from "@internationalized/date"
import { formatDistanceToNowStrict } from "date-fns"
import { presetDurations } from "./durations"
import { showToastMessage } from "~/components/toast"
import HookConfigPanel from "../HookConfigPanel.vue"
import HookNoticeValue from "../HookNoticeValue.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { useHookActions } from "../hook-actions"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
import { hookNoticeKeypath, type HookSubtitle } from "../hook-menu"
import { hookStatus } from "../hook-status"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
@ -17,307 +20,320 @@ const emit = defineEmits<{
(e: "force-close"): void
}>()
const hookData = computed(() => {
if (!props.hook) {
return null
}
return {
score: Number(props.hook.score),
state: props.hook.state as DocumentHookStateScheduledReminder,
settings: props.hook.settings as DocumentHookSettingsScheduledReminder,
}
const { t, d, locale } = useI18n({ useScope: "global" })
const { isEditable, isReadOnlyOrDiff } = useEditorMeta()
const actions = useHookActions({
type: DocumentHookType.ScheduledReminder,
nodeId: () => props.nodeId,
hook: () => props.hook,
close: close,
})
const { t } = useI18n({ useScope: "global" })
const editorStore = useEditorStore()
const documentHookAPI = useDocumentHookAPI()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
const activeBranchSettings = computed(
() =>
props.hook?.settings as DocumentHookSettingsScheduledReminder | undefined,
)
const targetBranchSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsScheduledReminder | undefined,
)
const selectedDuration = ref<string | undefined>(undefined)
const selectedSchedule = ref<DateValue | undefined>(undefined)
const confirmedSchedule = ref<DateValue | undefined>(
hookData.value
? dateToCalendarDate(hookData.value.settings.schedule)
const status = computed(() => (props.hook ? hookStatus(props.hook) : null))
const isTriggered = computed(() => status.value === "triggered")
const storedPreset = computed(() => presetOf(activeBranchSettings.value))
const selectedDuration = ref<string | undefined>(initialDuration())
const selectedSchedule = ref<DateValue | undefined>(initialSchedule())
const isSubOpen = ref(false)
const isComplete = computed(
() =>
!!selectedDuration.value &&
(selectedDuration.value !== "custom" || !!selectedSchedule.value),
)
// a waiting reminder starts on its own setting, so it saves once that
// changes
const isChanged = computed(() => {
if (!isComplete.value || !activeBranchSettings.value) {
return false
}
if (selectedDuration.value !== "custom") {
return selectedDuration.value !== storedPreset.value
}
return (
storedPreset.value !== null ||
selectedSchedule.value?.toString() !==
dateToCalendarDate(activeBranchSettings.value.schedule).toString()
)
})
const submitDisabled = computed(() =>
props.hook && !isTriggered.value ? !isChanged.value : !isComplete.value,
)
const subtitle = computed<HookSubtitle>(() => {
if (!activeBranchSettings.value) {
return { text: t("editor.hooks.scheduled-reminder.description") }
}
const schedule = new Date(activeBranchSettings.value.schedule)
const date = d(schedule, "short-with-time")
return {
text: isTriggered.value
? t("editor.hooks.scheduled-reminder.subtext-triggered", { date: date })
: date,
detail: formatDistanceToNowStrict(schedule, {
addSuffix: true,
locale: convertDateFnsLocale(locale.value),
}),
}
})
// the notice follows the pick while one is being made, and the stored
// date otherwise. It is null until there is either
const noticeWhen = computed(() => {
const picking = !isReadOnlyOrDiff.value && (!props.hook || isChanged.value)
if (picking && selectedDuration.value === "custom") {
return selectedSchedule.value
? t("editor.hooks.scheduled-reminder.when-on-date", {
date: d(selectedSchedule.value.toDate(getLocalTimeZone()), "long"),
})
: null
}
if (picking && selectedDuration.value) {
return t(
`editor.hooks.scheduled-reminder.when-options.${selectedDuration.value}`,
)
}
if (activeBranchSettings.value) {
return t("editor.hooks.scheduled-reminder.when-on-date", {
date: d(new Date(activeBranchSettings.value.schedule), "short-with-time"),
})
}
return null
})
// a reader who cannot pick a new date renews a triggered reminder by its
// own preset. A custom date has no preset to renew by
const remindAgainLabel = computed(() =>
isTriggered.value && storedPreset.value && !isEditable.value
? t(
`editor.hooks.scheduled-reminder.remind-again-options.${storedPreset.value}`,
)
: undefined,
)
const isSubOpen = ref(false)
async function upsertHook() {
if (
!selectedDuration.value ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
// a reminder that triggers while its menu exists asks for a new pick
watch(isTriggered, () => {
selectedDuration.value = initialDuration()
selectedSchedule.value = initialSchedule()
})
async function submit() {
const duration = selectedDuration.value
if (!duration || !isComplete.value) {
return
}
const newSchedule =
selectedDuration.value === "custom" && selectedSchedule.value
? selectedSchedule.value.toDate(getLocalTimeZone())
: addDurationToDate(new Date(), selectedDuration.value)
const next = {
scale: "linear" as const,
duration: duration,
schedule:
duration === "custom" && selectedSchedule.value
? selectedSchedule.value.toDate(getLocalTimeZone())
: addDurationToDate(new Date(), duration),
}
if (props.hook) {
await actions.update(next, isTriggered.value)
return
}
isSubOpen.value = false
emit("force-close")
if (!props.hook) {
try {
await documentHookAPI.createDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
req: {
type: DocumentHookType.ScheduledReminder,
branchId: editorStore.activeBranchId,
blockId: props.nodeId,
settings: {
scale: "linear",
duration: selectedDuration.value,
schedule: newSchedule,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.create-failed"))
return
}
confirmedSchedule.value = dateToCalendarDate(newSchedule)
// since "create new" is reused, reset the state
// the same form creates the next hook, so it is emptied once a hook is
// created
if (await actions.create(next)) {
selectedDuration.value = undefined
selectedSchedule.value = undefined
return
}
try {
await documentHookAPI.updateDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
req: {
settings: {
scale: "linear",
duration: selectedDuration.value,
schedule: newSchedule,
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.renew-failed"))
return
}
confirmedSchedule.value = dateToCalendarDate(newSchedule)
}
async function deleteHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
async function remindAgain() {
const preset = storedPreset.value
if (!preset) {
return
}
await actions.update(
{
scale: "linear",
duration: preset,
schedule: addDurationToDate(new Date(), preset),
},
true,
)
}
// a waiting reminder shows its own setting. A new or triggered one waits
// for a pick
function initialDuration(): string | undefined {
if (!activeBranchSettings.value || isTriggered.value) {
return undefined
}
return storedPreset.value ?? "custom"
}
function initialSchedule(): DateValue | undefined {
if (!activeBranchSettings.value || isTriggered.value || storedPreset.value) {
return undefined
}
return dateToCalendarDate(activeBranchSettings.value.schedule)
}
function presetOf(
reminder: DocumentHookSettingsScheduledReminder | undefined,
): string | null {
return reminder?.duration && reminder.duration !== "custom"
? reminder.duration
: null
}
function fieldValue(reminder: DocumentHookSettingsScheduledReminder): string {
const date = d(new Date(reminder.schedule), "short-with-time")
const preset = presetOf(reminder)
return preset
? t("editor.hooks.scheduled-reminder.field-value", {
duration: t(
`editor.hooks.scheduled-reminder.duration-options.${preset}`,
),
date: date,
})
: t("editor.hooks.scheduled-reminder.field-value-custom", { date: date })
}
function close() {
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.deleteDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.delete-failed"))
return
}
selectedDuration.value = undefined
selectedSchedule.value = undefined
confirmedSchedule.value = undefined
}
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<div class="relative h-[0.8125rem] w-[0.8125rem] shrink-0">
<Icon
class="absolute top-1/2 left-1/2 size-3.75 -translate-x-1/2 -translate-y-1/2"
:name="
!hookData || hookData.score !== 0
? 'lucide:timer'
: 'lucide:timer-reset'
<HookConfigPanel
v-model:open="isSubOpen"
:hook="props.hook"
:diff="props.diff"
icon="lucide:timer"
:acknowledge-label="remindAgainLabel"
:submit-label="isTriggered ? $t('editor.hooks.renew') : undefined"
:submit-icon="isTriggered ? 'mingcute:check-fill' : undefined"
:submit-disabled="submitDisabled"
@submit="submit"
@delete="actions.remove"
@acknowledge="remindAgain"
>
<template #title>
{{ $t("editor.hooks.scheduled-reminder.title") }}
</template>
<template #subtitle>
{{ subtitle.text }}
</template>
<template v-if="subtitle.detail" #subtitle-detail>
{{ subtitle.detail }}
</template>
<template #notice>
<template v-if="isTriggered && activeBranchSettings">
<i18n-t
scope="global"
:keypath="
hookNoticeKeypath(DocumentHookType.ScheduledReminder, status)
"
/>
</div>
<span v-if="!hookData">
{{ $t("editor.hooks.scheduled-reminder.title") }}
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.scheduled-reminder.existing-item"
tag="span"
class="truncate"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short-with-time") }}
</template>
</i18n-t>
tag="span"
>
<template #date>
<HookNoticeValue
:value="
$d(new Date(activeBranchSettings.schedule), 'short-with-time')
"
/>
</template>
</i18n-t>
<p v-if="isReadOnlyOrDiff && !storedPreset" class="mt-1">
{{ $t("editor.hooks.scheduled-reminder.one-off-note") }}
</p>
</template>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.scheduled-reminder.triggered-item"
:keypath="hookNoticeKeypath(DocumentHookType.ScheduledReminder, status)"
tag="span"
class="truncate"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short-with-time") }}
<template #when>
<HookNoticeValue
:value="noticeWhen"
:fallback="$t('editor.hooks.scheduled-reminder.when-unset')"
/>
</template>
</i18n-t>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:class="[
'pointer-events-auto!' /* for some reason ShadcnUiSelect disables pointer events, which closes the whole sub menu when the select is closed, so we must override this */,
]"
>
<div class="flex w-[12rem] flex-col">
<template v-if="hookData">
<HookExplanation
:type="DocumentHookType.ScheduledReminder"
:triggered="hookData.score === 0"
:node-id="props.nodeId"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short-with-time") }}
</template>
</HookExplanation>
<ShadcnUiDropdownMenuSeparator
v-if="!isReadOnlyOrDiff && hookData.score === 0"
/>
</template>
<div
v-if="!isReadOnlyOrDiff && (!hookData || hookData.score === 0)"
class="flex flex-col gap-1 px-0.75 pb-0.75"
>
<ShadcnUiSelect v-model="selectedDuration">
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{
!hookData || hookData.score !== 0
? $t("editor.hooks.scheduled-reminder.duration-label")
: $t(
"editor.hooks.scheduled-reminder.duration-label-triggered",
)
}}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
$t(`editor.hooks.scheduled-reminder.select-placeholder`)
"
/>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh]"
side="bottom"
align="start"
>
<ShadcnUiSelectItem
v-for="durationKey in presetDurations"
:key="durationKey"
:value="durationKey"
class="text-2sm"
>
{{
$t(
`editor.hooks.scheduled-reminder.duration-options.${durationKey}`,
)
}}
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<CalendarInput
v-if="selectedDuration === 'custom'"
v-model="selectedSchedule"
</template>
<HookReadonlyField
v-if="isReadOnlyOrDiff && activeBranchSettings"
:label="$t('editor.hooks.scheduled-reminder.duration-label')"
:rows="
scalarFieldRows(
fieldValue(activeBranchSettings),
targetBranchSettings ? fieldValue(targetBranchSettings) : null,
)
"
:diff-status="props.diff?.status"
/>
<div v-else class="flex flex-col gap-1">
<ShadcnUiSelect v-model="selectedDuration">
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{
isTriggered
? $t("editor.hooks.scheduled-reminder.duration-label-triggered")
: $t("editor.hooks.scheduled-reminder.duration-label")
}}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
$t('editor.hooks.scheduled-reminder.calendar-placeholder')
$t('editor.hooks.scheduled-reminder.select-placeholder')
"
class="w-full"
available-from-tomorrow
/>
</div>
<!--
the diff compares the dates as fields. The time shows too, so a
reminder moved within a day differs
-->
<template v-if="props.diff && hookData">
<ShadcnUiDropdownMenuSeparator />
<div class="flex flex-col gap-1 px-0.75 pb-0.75">
<HookReadonlyField
:label="$t('editor.hooks.scheduled-reminder.schedule-label')"
:rows="
scalarFieldRows(
$d(new Date(hookData.settings.schedule), 'short-with-time'),
targetSettings
? $d(new Date(targetSettings.schedule), 'short-with-time')
: null,
)
"
/>
</div>
</template>
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
<div v-if="!isReadOnlyOrDiff" class="p-0.75">
<div v-if="hookData" class="flex gap-1">
<ShadcnUiButton
v-if="hookData.score === 0"
class="flex-1 gap-1"
size="2sm"
:disabled="
!selectedDuration ||
(selectedDuration === 'custom' && !selectedSchedule)
"
@click.stop="upsertHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.renew") }}
</ShadcnUiButton>
<ShadcnUiButton
class="flex-1 gap-1"
variant="secondary"
size="2sm"
@click.stop="deleteHook"
>
<Icon name="mingcute:delete-2-line" />
{{ $t("editor.hooks.delete") }}
</ShadcnUiButton>
</div>
<template v-else>
<ShadcnUiButton
class="w-full gap-1"
size="2sm"
:disabled="
!selectedDuration ||
(selectedDuration === 'custom' && !selectedSchedule)
"
@click.stop="upsertHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.create") }}
</ShadcnUiButton>
</template>
</div>
</div>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
</ShadcnUiSelectTrigger>
<ShadcnUiSelectContent
class="max-h-[40dvh]"
side="bottom"
align="start"
>
<ShadcnUiSelectItem
v-for="durationKey in presetDurations"
:key="durationKey"
:value="durationKey"
class="text-2sm"
>
{{
$t(
`editor.hooks.scheduled-reminder.duration-options.${durationKey}`,
)
}}
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<CalendarInput
v-if="selectedDuration === 'custom'"
v-model="selectedSchedule"
:placeholder="
$t('editor.hooks.scheduled-reminder.calendar-placeholder')
"
class="w-full"
available-from-tomorrow
/>
</div>
</HookConfigPanel>
</template>

View File

@ -6,6 +6,7 @@ import {
DropdownMenu,
DropdownMenuContent,
} from "~/components/shadcn/ui/dropdown-menu"
import { TooltipProvider } from "~/components/shadcn/ui/tooltip"
// eslint's ts program resolves .vue imports as error typed, so a
// component handed to mountHookMenu looks unsafe to it while vue-tsc
@ -29,18 +30,25 @@ export function makeHook(overrides: Partial<DocumentHook> = {}): DocumentHook {
}
// the hook menus render a dropdown sub-menu, which reka-ui only lets
// mount inside an open menu — and teleports into <body> from there
// mount inside an open menu — and teleports into <body> from there. The
// menu header's tooltips need the provider the page installs
export function mountHookMenu(
component: TestComponent,
props: Record<string, unknown>,
) {
return mountSuspended(DropdownMenu, {
props: { open: true },
return mountSuspended(TooltipProvider, {
slots: {
default: () =>
h(DropdownMenuContent, null, {
default: () => h(component, props),
}),
h(
DropdownMenu,
{ open: true },
{
default: () =>
h(DropdownMenuContent, null, {
default: () => h(component, props),
}),
},
),
},
})
}
@ -94,13 +102,43 @@ export function readonlyFields(): [
"added" | "removed" | "unchanged",
][] {
return Array.from(
document.body.querySelectorAll<HTMLInputElement>("input[readonly]"),
).map((input) => [
input.value,
input.classList.contains("bg-diff-field-added")
document.body.querySelectorAll<HTMLElement>(
"[role='textbox'][aria-readonly='true']",
),
).map((field) => [
field.textContent.trim(),
field.classList.contains("bg-diff-field-added")
? "added"
: input.classList.contains("bg-diff-field-removed")
: field.classList.contains("bg-diff-field-removed")
? "removed"
: "unchanged",
])
}
// @nuxt/icon's css mode renders an icon as <span class="iconify i-<name>">,
// so the class is the only trace of which icon a menu picked
export function iconNames(root: Element): string[] {
return Array.from(root.querySelectorAll(".iconify")).flatMap((icon) => {
const name = Array.from(icon.classList).find((c) => c.startsWith("i-"))
return name ? [name.slice("i-".length)] : []
})
}
// the notice of the open hook menu, with the status its tint shows
export function hookNotice(): HTMLElement {
const notice = document.body.querySelector<HTMLElement>("[data-hook-status]")
if (!notice) {
throw new Error("no hook notice is rendered")
}
return notice
}
// the labels of every button in the menu bodies, for when a label also
// shows up in other text
export function menuButtonLabels(): string[] {
return Array.from(document.body.querySelectorAll("button")).map((button) =>
button.textContent.trim(),
)
}

View File

@ -2,8 +2,10 @@ import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import { DiffStatus } from "../../diff/position-map"
import {
hookNotice,
makeHook,
menuButton,
menuText,
@ -20,6 +22,7 @@ import {
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
emitFrom,
settleMutations,
t,
WAIT_FOR_OPTIONS,
@ -32,8 +35,10 @@ vi.mock("vue-sonner", () => ({
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const HOOK_PATH = `/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`
const NEW_HOOK_LABEL = "editor.hooks.url-watcher.title"
const TITLE = "editor.hooks.url-watcher.title"
const SHOWN_URL = "oxynote.test/docs"
function urlHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
@ -60,6 +65,7 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setActiveBranchProtected(false)
useEditorMeta().setEditable(true)
})
@ -68,47 +74,99 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
it("offers to start watching a website", async ({ expect }) => {
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
expect(menuText()).toContain(t(TITLE))
expect(menuText()).toContain(t("editor.hooks.url-watcher.description"))
})
it("names the site an active hook watches", async ({ expect }) => {
it("names the page an active hook watches", async ({ expect }) => {
await mountMenu({ hook: urlHook() })
expect(menuText()).toContain("Watching oxynote.test")
expect(menuText()).toContain(SHOWN_URL)
expect(menuText()).not.toContain("https://")
})
it("reports a hook that has already fired", async ({ expect }) => {
it("says the page of a triggered hook changed", async ({ expect }) => {
await mountMenu({ hook: urlHook({ score: "0" }) })
expect(menuText()).toContain("Changes in oxynote.test")
const detail = t("editor.hooks.url-watcher.detail-triggered")
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", { subtext: SHOWN_URL, detail: detail }),
)
})
it("says the page of a failing hook is unreachable", async ({ expect }) => {
await mountMenu({
hook: urlHook({ state: { status: "unreachable_url" } }),
})
const detail = t("editor.hooks.url-watcher.detail-unreachable")
expect(menuText()).toContain(
t("editor.hooks.subtext-detail", { subtext: SHOWN_URL, detail: detail }),
)
})
it("prefills the address of the hook it edits", async ({ expect }) => {
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
"https://oxynote.test/docs",
)
})
it("explains what an active block hook will do", async ({ expect }) => {
await mountMenu({ hook: urlHook() })
it.for([
{
name: "block",
nodeId: "block-1",
key: "editor.hooks.url-watcher.fresh-notice",
},
{
name: "page",
nodeId: null,
key: "editor.hooks.url-watcher.fresh-notice",
},
])(
"explains what a $name hook will do",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: urlHook(), nodeId: nodeId })
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain("the block will be highlighted")
})
expect(hookNotice().dataset.hookStatus).toBe("fresh")
expect(hookNotice().textContent.trim()).toBe(t(key, { url: SHOWN_URL }))
},
)
it("explains what an active document-wide hook will do", async ({
it("names the page in its notice as the reader types it", async ({
expect,
}) => {
await mountMenu({ hook: urlHook(), nodeId: null })
await mountMenu()
await openHookSubMenu(t(TITLE))
await openHookSubMenu("Watching oxynote.test")
await typeInMenu("https://oxynote.test/guide/")
expect(menuText()).toContain("the relevant sections will be highlighted")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.url-watcher.new-notice", {
url: "oxynote.test/guide",
}),
)
expect(hookNotice().querySelector(".font-semibold")?.textContent).toBe(
"oxynote.test/guide",
)
})
it("names no page in its notice before one is typed", async ({ expect }) => {
await mountMenu()
await openHookSubMenu(t(TITLE))
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.url-watcher.new-notice", {
url: t("editor.hooks.url-watcher.url-fallback"),
}),
)
expect(hookNotice().querySelector(".font-semibold")).toBeNull()
})
it("warns about a site it cannot reach", async ({ expect }) => {
@ -116,16 +174,19 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
hook: urlHook({ state: { status: "unreachable_url" } }),
})
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain("cannot be reached")
expect(hookNotice().dataset.hookStatus).toBe("needs-attention")
expect(hookNotice().textContent.trim()).toBe(
t("editor.hooks.url-watcher.unreachable-url"),
)
})
it("keeps the create button out of reach until an address is typed", async ({
expect,
}) => {
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
@ -137,7 +198,7 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await typeInMenu("oxynote.test/docs")
menuButton(t("editor.hooks.create")).click()
@ -157,32 +218,79 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
).toHaveLength(1)
})
it("warns when the hook cannot be created", async ({ expect }) => {
it("starts empty again once a hook is created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, () => ({
id: HOOK_ID,
}))
await mountMenu()
await openHookSubMenu(t(TITLE))
await typeInMenu("oxynote.test/docs")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
"",
)
})
it("keeps the typed address when the hook cannot be created", async ({
expect,
}) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await openHookSubMenu(t(TITLE))
await typeInMenu("oxynote.test/docs")
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
await openHookSubMenu(t(TITLE))
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
"oxynote.test/docs",
)
})
it("keeps the update button out of reach until the address changes", async ({
expect,
}) => {
await mountMenu({ hook: urlHook() })
await openHookSubMenu(t(TITLE))
expect(menuButton(t("editor.hooks.update")).disabled).toBe(true)
})
it("sends nothing while no address is typed", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(TITLE))
emitFrom(wrapper, HookConfigPanel, "submit")
await settleMutations()
expect(calls).toHaveLength(0)
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toBeUndefined()
})
it("updates the address an existing hook watches", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
)
const calls = mockEndpoint("PUT", HOOK_PATH, () => ({ id: HOOK_ID }))
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await typeInMenu("https://other.test")
await openHookSubMenu(t(TITLE))
await typeInMenu("other.test")
menuButton(t("editor.hooks.update")).click()
@ -194,35 +302,10 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
})
})
it("warns when the hook cannot be updated", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await typeInMenu("https://other.test")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("deletes the hook", async ({ expect }) => {
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
const calls = mockEndpoint("DELETE", HOOK_PATH, () => null)
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.delete")).click()
@ -231,35 +314,17 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be deleted", async ({ expect }) => {
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers to approve a hook that has fired", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
() => ({ id: HOOK_ID }),
)
it("dismisses a triggered hook from its notice", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: urlHook({ score: "0" }) })
await openHookSubMenu("Changes in oxynote.test")
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(hookNotice().textContent).toContain(
t("editor.hooks.url-watcher.triggered-notice", { url: SHOWN_URL }),
)
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
@ -267,98 +332,67 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be approved", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: urlHook({ score: "0" }) })
await openHookSubMenu("Changes in oxynote.test")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers no approval for a hook that has not fired", async ({ expect }) => {
it("offers no dismissal for a hook that has not triggered", async ({
expect,
}) => {
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
expect(menuText()).not.toContain(t("editor.hooks.reset"))
})
it("creates nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await typeInMenu("oxynote.test")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
expect(calls).toHaveLength(0)
})
it("says what a triggered hook did while editing too", async ({ expect }) => {
await mountMenu({ hook: urlHook({ score: "0" }) })
await openHookSubMenu("Changes in oxynote.test")
expect(menuText()).toContain(
t("editor.hooks.url-watcher.triggered-item-block-explanation"),
)
expect(menuText()).toContain(t("editor.hooks.reset"))
})
describe("when the page is read only", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(false)
})
it("shows the url without a way to change it", async ({ expect }) => {
it("shows the address without a way to change it", async ({ expect }) => {
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
["https://oxynote.test/docs", "unchanged"],
])
expect(menuText()).toContain(t("editor.hooks.read-only-mode"))
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
})
it("still dismisses a triggered hook", async ({ expect }) => {
const calls = mockEndpoint("PUT", `${HOOK_PATH}/reset`, () => ({
id: HOOK_ID,
}))
await mountMenu({ hook: urlHook({ score: "0" }) })
await openHookSubMenu(t(TITLE))
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.url-watcher.triggered-item-block-explanation",
key: "editor.hooks.url-watcher.triggered-notice",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.url-watcher.triggered-item-full-document-explanation",
key: "editor.hooks.url-watcher.triggered-notice",
},
])(
"says what a triggered hook on $name did",
async ({ nodeId, key }, { expect }) => {
await mountMenu({ hook: urlHook({ score: "0" }), nodeId: nodeId })
await openHookSubMenu("Changes in oxynote.test")
await openHookSubMenu(t(TITLE))
expect(menuText()).toContain(t(key))
expect(hookNotice().textContent).toContain(t(key, { url: SHOWN_URL }))
},
)
})
@ -378,16 +412,64 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
},
})
await openHookSubMenu("Watching oxynote.test")
expect(menuText()).toContain(
t("editor.hooks.url-watcher.existing-item-block-explanation"),
t("editor.hooks.value-change", { old: "old.test", new: SHOWN_URL }),
)
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([
["https://oxynote.test/docs", "added"],
["https://old.test", "removed"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
})
it.for([
{ name: "added", status: DiffStatus.Added },
{ name: "removed", status: DiffStatus.Removed },
])(
"marks every field of an $name hook",
async ({ status, name }, { expect }) => {
await mountMenu({
hook: urlHook(),
diff: { status: status, targetHook: null, signColumn: true },
})
await openHookSubMenu(t(TITLE))
expect(readonlyFields()).toEqual([["https://oxynote.test/docs", name]])
},
)
it("says a removed hook was removed", async ({ expect }) => {
await mountMenu({
hook: urlHook(),
diff: {
status: DiffStatus.Removed,
targetHook: null,
signColumn: true,
},
})
await openHookSubMenu(t(TITLE))
expect(hookNotice().textContent.trim()).toBe(t("editor.hooks.removed"))
})
it("keeps a triggered hook from being dismissed", async ({ expect }) => {
await mountMenu({
hook: urlHook({ score: "0" }),
diff: {
status: DiffStatus.Unchanged,
targetHook: null,
signColumn: false,
},
})
await openHookSubMenu(t(TITLE))
expect(hookNotice().dataset.hookStatus).toBe("triggered")
expect(menuText()).not.toContain(t("editor.hooks.reset"))
})
})
})

View File

@ -1,10 +1,13 @@
<script setup lang="ts">
import { showToastMessage } from "~/components/toast"
import HookInputField from "./HookInputField.vue"
import HookConfigPanel from "../HookConfigPanel.vue"
import HookInputField from "../HookInputField.vue"
import HookNoticeValue from "../HookNoticeValue.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { DiffStatus } from "../../diff/position-map"
import { useHookActions } from "../hook-actions"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
import { hookNoticeKeypath, type HookSubtitle } from "../hook-menu"
import { hookStatus } from "../hook-status"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
@ -16,281 +19,180 @@ const emit = defineEmits<{
(e: "force-close"): void
}>()
const editorStore = useEditorStore()
const { t } = useI18n({ useScope: "global" })
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
const actions = useHookActions({
type: DocumentHookType.URLWatcher,
nodeId: () => props.nodeId,
hook: () => props.hook,
close: close,
})
const activeBranchSettings = computed(
() => props.hook?.settings as DocumentHookSettingsURLWatcher | undefined,
)
const targetBranchSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsURLWatcher | undefined,
)
const { t } = useI18n({ useScope: "global" })
const hookData = computed(() => {
if (!props.hook) {
return null
const state = computed(
() => props.hook?.state as DocumentHookStateURLWatcher | undefined,
)
const status = computed(() => (props.hook ? hookStatus(props.hook) : null))
const selectedURL = ref<string | undefined>(activeBranchSettings.value?.url)
const isSubOpen = ref(false)
const isUnchanged = computed(
() =>
!selectedURL.value ||
ensureHttps(selectedURL.value) === activeBranchSettings.value?.url,
)
const failure = computed(() =>
state.value ? checkFailure(state.value.status) : null,
)
const subtitle = computed<HookSubtitle>(() => {
if (!activeBranchSettings.value) {
return { text: t("editor.hooks.url-watcher.description") }
}
return {
score: Number(props.hook.score),
state: props.hook.state as DocumentHookStateURLWatcher,
settings: props.hook.settings as DocumentHookSettingsURLWatcher,
const activeBranchURL = displayURL(activeBranchSettings.value.url)
const targetBranchURL = targetBranchSettings.value?.url
if (
props.diff?.status === DiffStatus.Modified &&
targetBranchURL &&
targetBranchURL !== activeBranchSettings.value.url
) {
return {
text: t("editor.hooks.value-change", {
old: displayURL(targetBranchURL),
new: activeBranchURL,
}),
}
}
if (failure.value) {
return { text: activeBranchURL, detail: failure.value.detail }
}
return status.value === "triggered"
? {
text: activeBranchURL,
detail: t("editor.hooks.url-watcher.detail-triggered"),
}
: { text: activeBranchURL }
})
// the notice names the page as it is typed, and the stored one where it
// cannot be changed
const noticeURL = computed(() => {
const url = isReadOnlyOrDiff.value
? activeBranchSettings.value?.url
: selectedURL.value
return url ? displayURL(url) : null
})
const documentHookAPI = useDocumentHookAPI()
const confirmedURL = ref<string | undefined>(
hookData.value ? hookData.value.settings.url : undefined,
)
const selectedURL = ref<string | undefined>(confirmedURL.value)
const isSubOpen = ref(false)
async function upsertHook() {
if (
!selectedURL.value ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
async function submit() {
if (!selectedURL.value) {
return
}
isSubOpen.value = false
emit("force-close")
const next = { url: ensureHttps(selectedURL.value) }
if (props.hook) {
await actions.update(next)
return
}
if (!props.hook) {
try {
await documentHookAPI.createDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
req: {
type: DocumentHookType.URLWatcher,
branchId: editorStore.activeBranchId,
blockId: props.nodeId,
settings: {
url: ensureHttps(selectedURL.value),
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.create-failed"))
return
}
confirmedURL.value = selectedURL.value
// since "create new" is reused, reset the state
// the same form creates the next hook, so it is emptied once a hook is
// created
if (await actions.create(next)) {
selectedURL.value = undefined
return
}
try {
await documentHookAPI.updateDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
req: {
settings: {
url: ensureHttps(selectedURL.value),
},
},
})
} catch {
showToastMessage("error", t("editor.hooks.errors.update-failed"))
return
}
confirmedURL.value = selectedURL.value
}
async function deleteHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
// checkFailure says what the row and the notice show for a failed check,
// and is null while the check works. It has a case for every status and no
// default, so a status added later fails to compile until it is handled.
function checkFailure(
checkStatus: DocumentHookStateURLWatcher["status"],
): { detail: string; notice: string } | null {
switch (checkStatus) {
case "active":
return null
case "unreachable_url":
return {
detail: t("editor.hooks.url-watcher.detail-unreachable"),
notice: t("editor.hooks.url-watcher.unreachable-url"),
}
}
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.deleteDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.delete-failed"))
return
}
selectedURL.value = undefined
}
async function resetHook() {
if (
!props.hook ||
!editorStore.activeDocumentId ||
!editorStore.activeBranchId
) {
return
}
function close() {
isSubOpen.value = false
emit("force-close")
try {
await documentHookAPI.resetDocumentHookByDocID.mutateAsync({
docId: editorStore.activeDocumentId,
branchId: editorStore.activeBranchId,
hookId: props.hook.id,
})
} catch {
showToastMessage("error", t("editor.hooks.errors.reset-failed"))
return
}
selectedURL.value = undefined
}
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<div class="relative h-[0.8125rem] w-[0.8125rem] shrink-0">
<Icon
name="mingcute:earth-2-line"
class="absolute top-1/2 left-1/2 size-3.75 -translate-x-1/2 -translate-y-1/2"
/>
</div>
<span v-if="!hookData">
{{ $t("editor.hooks.url-watcher.title") }}
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.url-watcher.existing-item"
tag="span"
class="truncate"
>
<template #url>
{{ extractDomain(confirmedURL || "") }}
</template>
</i18n-t>
<HookConfigPanel
v-model:open="isSubOpen"
:hook="props.hook"
:diff="props.diff"
icon="mingcute:earth-2-line"
:acknowledge-label="
status === 'triggered' ? $t('editor.hooks.reset') : undefined
"
:submit-disabled="isUnchanged"
@submit="submit"
@delete="actions.remove"
@acknowledge="actions.reset"
>
<template #title>
{{ $t("editor.hooks.url-watcher.title") }}
</template>
<template #subtitle>
{{ subtitle.text }}
</template>
<template v-if="subtitle.detail" #subtitle-detail>
{{ subtitle.detail }}
</template>
<template #notice>
<template v-if="failure">
{{ failure.notice }}
</template>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.url-watcher.triggered-item"
:keypath="hookNoticeKeypath(DocumentHookType.URLWatcher, status)"
tag="span"
class="truncate"
>
<template #url>
{{ extractDomain(confirmedURL || "") }}
<HookNoticeValue
:value="noticeURL"
:fallback="$t('editor.hooks.url-watcher.url-fallback')"
/>
</template>
</i18n-t>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
loop
:class="[
'pointer-events-auto!' /* for some reason ShadcnUiSelect disables pointer events, which closes the whole sub menu when the select is closed, so we must override this */,
]"
</template>
<HookReadonlyField
v-if="isReadOnlyOrDiff && activeBranchSettings"
:label="$t('editor.hooks.url-watcher.url-input-label')"
:rows="
scalarFieldRows(
activeBranchSettings.url,
targetBranchSettings?.url ?? null,
)
"
:diff-status="props.diff?.status"
/>
<HookInputField
v-else
v-model="selectedURL"
type="url"
:placeholder="$t('editor.hooks.url-watcher.url-input-placeholder')"
>
<div class="flex w-[14rem] flex-col">
<template v-if="hookData?.state.status === 'unreachable_url'">
<i18n-t
scope="global"
keypath="editor.hooks.url-watcher.unreachable-url"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
>
<template #icon>
<Icon
name="mingcute:alert-fill"
class="mr-0.5 inline-block -translate-y-px align-middle text-status-warning"
/>
</template>
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template v-if="hookData && hookData.state.status === 'active'">
<HookExplanation
:type="DocumentHookType.URLWatcher"
:triggered="hookData.score === 0"
:node-id="props.nodeId"
/>
<ShadcnUiDropdownMenuSeparator />
</template>
<div class="flex flex-col gap-1 px-0.75 pb-0.75">
<HookReadonlyField
v-if="isReadOnlyOrDiff && hookData"
:label="$t('editor.hooks.url-watcher.url-input-label')"
:rows="
scalarFieldRows(
hookData.settings.url,
targetSettings?.url ?? null,
)
"
/>
<HookInputField
v-else
v-model="selectedURL"
:placeholder="$t('editor.hooks.url-watcher.url-input-placeholder')"
>
<template #label>
<span>
{{ $t("editor.hooks.url-watcher.url-input-label") }}
</span>
</template>
</HookInputField>
</div>
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
<div v-if="!isReadOnlyOrDiff" class="p-0.75">
<div v-if="hookData" class="flex flex-col gap-1">
<div class="flex gap-1">
<ShadcnUiButton
class="flex-1 gap-1"
:variant="hookData?.score === 0 ? 'outline' : 'default'"
size="2sm"
:disabled="!selectedURL || selectedURL === confirmedURL"
@click.stop="upsertHook"
>
<Icon name="mingcute:save-2-line" />
{{ $t("editor.hooks.update") }}
</ShadcnUiButton>
<ShadcnUiButton
class="flex-1 gap-1"
variant="secondary"
size="2sm"
@click.stop="deleteHook"
>
<Icon name="mingcute:delete-2-line" />
{{ $t("editor.hooks.delete") }}
</ShadcnUiButton>
</div>
<ShadcnUiButton
v-if="hookData.score === 0"
class="gap-1"
size="2sm"
@click.stop="resetHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.reset") }}
</ShadcnUiButton>
</div>
<template v-else>
<ShadcnUiButton
class="w-full gap-1"
size="2sm"
:disabled="!selectedURL || selectedURL === confirmedURL"
@click.stop="upsertHook"
>
<Icon name="mingcute:check-fill" />
{{ $t("editor.hooks.create") }}
</ShadcnUiButton>
</template>
</div>
</div>
</ShadcnUiDropdownMenuSubContent>
</ShadcnUiDropdownMenuSub>
<template #label>
<span>
{{ $t("editor.hooks.url-watcher.url-input-label") }}
</span>
</template>
</HookInputField>
</HookConfigPanel>
</template>

View File

@ -3,6 +3,7 @@ import {
addDeletionSuccessStatusToUrl,
createNameSlug,
createNameSlugWithId,
displayURL,
ensureHttps,
equalNameSlugs,
extractDocInfoFromSlug,
@ -190,6 +191,28 @@ describe("extractDomain", () => {
})
})
describe("displayURL", () => {
it.for([
{
name: "drops the scheme and keeps the path",
input: "https://docs.example.com/guide?x=1",
expected: "docs.example.com/guide?x=1",
},
{
name: "drops a trailing slash",
input: "HTTP://example.com/",
expected: "example.com",
},
{
name: "keeps a url without a scheme as it is",
input: "example.com/guide",
expected: "example.com/guide",
},
])("$name", ({ input, expected }, { expect }) => {
expect(displayURL(input)).toBe(expected)
})
})
describe("addDeletionSuccessStatusToUrl", () => {
it("appends the deletion status parameter", ({ expect }) => {
expect(addDeletionSuccessStatusToUrl("https://app.test/docs")).toBe(

View File

@ -124,6 +124,10 @@ export function extractDomain(url: string): string {
}
}
export function displayURL(url: string): string {
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "")
}
export function addDeletionSuccessStatusToUrl(url: string): string {
const urlObj = new URL(url)
urlObj.searchParams.set("deletion", "success")

View File

@ -146,8 +146,8 @@ export default withNuxt([
// never reported unused.
ignores: [
// dynamically built keys: t(`ns.${x}`)
"/^editor\\.hooks\\.[a-z-]+\\.(existing|triggered)-item-(block|full-document)-explanation$/",
"/^editor\\.hooks\\.scheduled-reminder\\.duration-options\\./",
"/^editor\\.hooks\\.[a-z-]+\\.(new|fresh|triggered)-notice$/",
"/^editor\\.hooks\\.scheduled-reminder\\.(duration|when|remind-again)-options\\./",
"/^editor\\.metrics\\.config\\.modal-title-diff-/",
"/^editor\\.metrics\\.config\\.query-placeholder\\./",
"/^editor\\.metrics\\.config\\.refresh-interval-options(-short)?\\./",

View File

@ -341,9 +341,24 @@
"delete": "Delete Link"
},
"hooks": {
"add-new": "New freshness hook",
"empty-block": "This block has no freshness hooks",
"empty-document": "There are no page-wide freshness hooks",
"title": "Freshness Hooks",
"status": {
"fresh": "All quiet",
"triggered": "{count} triggered",
"needs-attention": {
"one": "{count} needs attention",
"other": "{count} need attention"
}
},
"row-status": {
"triggered": "triggered",
"needs-attention": "needs attention"
},
"empty-block": "No hooks on this block",
"empty-document": "No hooks on this page",
"empty-block-description": "A hook reminds maintainers to re-check this block when something it depends on changes.",
"empty-document-description": "A hook reminds maintainers to re-check this page when something it depends on changes.",
"add-new": "Add Hook",
"diff": {
"added": "added",
"removed": "removed",
@ -354,27 +369,37 @@
"update": "Update",
"renew": "Renew",
"delete": "Delete",
"reset": "Approve & Continue",
"reset": "Got It, Keep Watching",
"removed": "This hook was removed.",
"dismiss-in-diff": "To dismiss it, turn off {toggle}.",
"read-only-protected": "Read-only · change this hook on the draft branch and merge it here",
"read-only-mode": "Read-only · switch to edit mode to change this hook",
"showing-changes": "Showing changes · turn off {toggle} to edit",
"value-change": "{old} → {new}",
"subtext-detail": "{subtext} · {detail}",
"errors": {
"create-failed": "Failed to create the hook. Please try again.",
"update-failed": "Failed to update the hook. Please try again.",
"delete-failed": "Failed to delete the hook. Please try again.",
"reset-failed": "Failed to approve the hook. Please try again.",
"reset-failed": "Failed to reset the hook. Please try again.",
"renew-failed": "Failed to renew the hook. Please try again."
},
"scheduled-reminder": {
"title": "Scheduled Reminder",
"description": "Nudge maintainers on a date",
"select-placeholder": "Choose when",
"calendar-placeholder": "Pick a date",
"duration-label": "Remind",
"duration-label-triggered": "Remind again",
"schedule-label": "Reminder Date",
"existing-item": "Remind on {value}",
"triggered-item": "Reminded on {value}",
"existing-item-block-explanation": "A reminder will be sent on {value} to the maintainers of the page and the block will be highlighted.",
"existing-item-full-document-explanation": "A reminder will be sent on {value} to the maintainers of the page and the relevant sections will be highlighted.",
"triggered-item-block-explanation": "A reminder was sent on {value} to the maintainers of the page and the block was highlighted.",
"triggered-item-full-document-explanation": "A reminder was sent on {value} to the maintainers of the page and the relevant sections were highlighted.",
"subtext-triggered": "Triggered on {date}",
"new-notice": "Maintainers get a reminder {when}.",
"fresh-notice": "Maintainers will get a reminder {when}.",
"triggered-notice": "A reminder was sent on {date}.",
"one-off-note": "This was a one-off custom date, so it will not repeat.",
"when-on-date": "on {date}",
"when-unset": "on the date you choose",
"field-value": "{duration} · {date}",
"field-value-custom": "On {date}",
"duration-options": {
"0": "Instant (dev)",
"1m": "In 1 minute (dev)",
@ -386,23 +411,55 @@
"2160h": "In 3 months",
"4320h": "In 6 months",
"custom": "On a custom date"
},
"when-options": {
"0": "right away",
"1m": "in 1 minute",
"24h": "in 1 day",
"72h": "in 3 days",
"168h": "in 1 week",
"336h": "in 2 weeks",
"720h": "in 1 month",
"2160h": "in 3 months",
"4320h": "in 6 months"
},
"remind-again-options": {
"0": "Got It, Remind Again Right Away",
"1m": "Got It, Remind Again in 1 Minute",
"24h": "Got It, Remind Again in 1 Day",
"72h": "Got It, Remind Again in 3 Days",
"168h": "Got It, Remind Again in 1 Week",
"336h": "Got It, Remind Again in 2 Weeks",
"720h": "Got It, Remind Again in 1 Month",
"2160h": "Got It, Remind Again in 3 Months",
"4320h": "Got It, Remind Again in 6 Months"
}
},
"github-tracking": {
"title": "GitHub Commits",
"existing-item": "Watching {repository}",
"triggered-item": "Changes in {repository}",
"existing-item-block-explanation": "When the selected GitHub repository changes, a reminder will be sent to the maintainers of the page and the block will be highlighted.",
"existing-item-full-document-explanation": "When the selected GitHub repository changes, a reminder will be sent to the maintainers of the page and the relevant sections will be highlighted.",
"triggered-item-block-explanation": "The selected GitHub repository changed, so a reminder was sent to the maintainers of the page and the block was highlighted.",
"triggered-item-full-document-explanation": "The selected GitHub repository changed, so a reminder was sent to the maintainers of the page and the relevant sections were highlighted.",
"description": "Watch files in a repo",
"subtext": "{repository} · {branch}",
"problems": {
"missing-installation": "GitHub not connected",
"missing-repository": "repository inaccessible",
"missing-branch": "branch inaccessible"
},
"file-count": {
"one": "1 file",
"other": "{count} files"
},
"new-notice": "Maintainers are notified of changes to {files} on {branch}.",
"fresh-notice": "Maintainers will be notified of changes to {files} on {branch}.",
"triggered-notice": "The watched files changed on {branch}, so maintainers were notified.",
"files-fallback": "the selected files",
"branch-fallback": "the selected branch",
"not-connected": {
"main": "{icon}To use this hook, connect your GitHub organization or personal account in the {placeholder}.",
"main": "To use this hook, connect your GitHub organization or personal account in the {placeholder}.",
"placeholder": "settings"
},
"no-repositories": "{icon}You have no accessible repositories in the connected GitHub organization or personal account.",
"missing-target-repository": "{icon}The selected GitHub repository cannot be accessed.",
"missing-target-branch": "{icon}The selected GitHub branch cannot be accessed.",
"no-repositories": "You have no accessible repositories in the connected GitHub organization or personal account.",
"missing-target-repository": "The selected GitHub repository cannot be accessed.",
"missing-target-branch": "The selected GitHub branch cannot be accessed.",
"repository-select-label": "Repository",
"repository-select-placeholder": "Select repository",
"repository-select-loading-placeholder": "Loading...",
@ -423,25 +480,26 @@
},
"url-watcher": {
"title": "Website Changes",
"existing-item": "Watching {url}",
"triggered-item": "Changes in {url}",
"existing-item-block-explanation": "When the selected website changes, a reminder will be sent to the maintainers of the page and the block will be highlighted.",
"existing-item-full-document-explanation": "When the selected website changes, a reminder will be sent to the maintainers of the page and the relevant sections will be highlighted.",
"triggered-item-block-explanation": "The selected website changed, so a reminder was sent to the maintainers of the page and the block was highlighted.",
"triggered-item-full-document-explanation": "The selected website changed, so a reminder was sent to the maintainers of the page and the relevant sections were highlighted.",
"unreachable-url": "{icon}The selected website cannot be reached. They may be temporarily down, no longer exist, or have anti-scraping measures in place.",
"description": "Watch a webpage for changes",
"detail-triggered": "webpage changed",
"detail-unreachable": "unreachable",
"new-notice": "Maintainers are notified when {url} changes.",
"fresh-notice": "Maintainers will be notified when {url} changes.",
"triggered-notice": "{url} changed, so maintainers were notified.",
"url-fallback": "the webpage",
"unreachable-url": "The selected webpage cannot be reached. They may be temporarily down, no longer exist, or have anti-scraping measures in place.",
"url-input-label": "Link to Website",
"url-input-placeholder": "https://example.com"
},
"container-image-watcher": {
"title": "Docker Image Updates",
"existing-item": "Watching {image}",
"triggered-item": "Updates in {image}",
"existing-item-block-explanation": "When the selected Docker image changes, a reminder will be sent to the maintainers of the page and the block will be highlighted.",
"existing-item-full-document-explanation": "When the selected Docker image changes, a reminder will be sent to the maintainers of the page and the relevant sections will be highlighted.",
"triggered-item-block-explanation": "The selected Docker image changed, so a reminder was sent to the maintainers of the page and the block was highlighted.",
"triggered-item-full-document-explanation": "The selected Docker image changed, so a reminder was sent to the maintainers of the page and the relevant sections were highlighted.",
"unreachable-image": "{icon}The selected Docker image cannot be reached. They may be temporarily down, no longer exist, or have authentication.",
"description": "Watch an image:tag",
"detail-unreachable": "unreachable",
"new-notice": "Maintainers are notified when {image} is updated.",
"fresh-notice": "Maintainers will be notified when {image} is updated.",
"triggered-notice": "{image} was updated, so maintainers were notified.",
"image-fallback": "the image",
"unreachable-image": "The selected Docker image cannot be reached. They may be temporarily down, no longer exist, or have authentication.",
"image-input-label": "Image Reference",
"image-input-placeholder": "postgres:16.4-alpine"
}