mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
overhaul hook menus
This commit is contained in:
parent
5a0f75c093
commit
c93b245298
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
276
web/app/components/editor/hooks/HookConfigPanel.nuxt.test.ts
Normal file
276
web/app/components/editor/hooks/HookConfigPanel.nuxt.test.ts
Normal 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, " ")
|
||||
}
|
||||
143
web/app/components/editor/hooks/HookConfigPanel.vue
Normal file
143
web/app/components/editor/hooks/HookConfigPanel.vue
Normal 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>
|
||||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ?? ""
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
131
web/app/components/editor/hooks/HookMenuFooter.nuxt.test.ts
Normal file
131
web/app/components/editor/hooks/HookMenuFooter.nuxt.test.ts
Normal 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, " ")
|
||||
}
|
||||
91
web/app/components/editor/hooks/HookMenuFooter.vue
Normal file
91
web/app/components/editor/hooks/HookMenuFooter.vue
Normal 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>
|
||||
168
web/app/components/editor/hooks/HookMenuHeader.nuxt.test.ts
Normal file
168
web/app/components/editor/hooks/HookMenuHeader.nuxt.test.ts
Normal 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-"),
|
||||
),
|
||||
)
|
||||
}
|
||||
104
web/app/components/editor/hooks/HookMenuHeader.vue
Normal file
104
web/app/components/editor/hooks/HookMenuHeader.vue
Normal 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>
|
||||
150
web/app/components/editor/hooks/HookNotice.nuxt.test.ts
Normal file
150
web/app/components/editor/hooks/HookNotice.nuxt.test.ts
Normal 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([[]])
|
||||
})
|
||||
})
|
||||
82
web/app/components/editor/hooks/HookNotice.vue
Normal file
82
web/app/components/editor/hooks/HookNotice.vue
Normal 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>
|
||||
30
web/app/components/editor/hooks/HookNoticeValue.nuxt.test.ts
Normal file
30
web/app/components/editor/hooks/HookNoticeValue.nuxt.test.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
})
|
||||
14
web/app/components/editor/hooks/HookNoticeValue.vue
Normal file
14
web/app/components/editor/hooks/HookNoticeValue.vue
Normal 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>
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") }}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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],
|
||||
])
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
249
web/app/components/editor/hooks/hook-actions.nuxt.test.ts
Normal file
249
web/app/components/editor/hooks/hook-actions.nuxt.test.ts
Normal 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" }
|
||||
}
|
||||
131
web/app/components/editor/hooks/hook-actions.ts
Normal file
131
web/app/components/editor/hooks/hook-actions.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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("")])
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
},
|
||||
|
|
|
|||
31
web/app/components/editor/hooks/hook-menu.test.ts
Normal file
31
web/app/components/editor/hooks/hook-menu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
33
web/app/components/editor/hooks/hook-menu.ts
Normal file
33
web/app/components/editor/hooks/hook-menu.ts
Normal 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`
|
||||
}
|
||||
127
web/app/components/editor/hooks/hook-status.test.ts
Normal file
127
web/app/components/editor/hooks/hook-status.test.ts
Normal 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"),
|
||||
}
|
||||
}
|
||||
71
web/app/components/editor/hooks/hook-status.ts
Normal file
71
web/app/components/editor/hooks/hook-status.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)?\\./",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user