add hook diff

This commit is contained in:
swithek 2026-10-02 10:11:59 +02:00
parent 766bef1cab
commit 48e6652d4b
45 changed files with 3135 additions and 328 deletions

View File

@ -25,29 +25,29 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("shows the placeholder while nothing is picked", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
})
expect(wrapper.text()).toContain(
t("editor.hooks.time-expiration.calendar-placeholder"),
t("editor.hooks.scheduled-reminder.calendar-placeholder"),
)
})
it("shows the picked date instead of the placeholder", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
expect(wrapper.text()).toContain("March 14, 2026")
expect(wrapper.text()).not.toContain(
t("editor.hooks.time-expiration.calendar-placeholder"),
t("editor.hooks.scheduled-reminder.calendar-placeholder"),
)
})
it("greys out the trigger while nothing is picked", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
})
expect(wrapper.get("button").classes()).toContain("text-muted-foreground")
@ -57,7 +57,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
expect,
}) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -70,7 +70,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
expect,
}) => {
await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
})
expect(popoverText()).not.toContain("March")
@ -78,7 +78,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("opens the calendar when the trigger is pressed", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -91,7 +91,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("closes the calendar once a date is picked", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
await wrapper.get("button").trigger("click")
@ -105,7 +105,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("reports the day the user clicks in the calendar", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
await wrapper.get("button").trigger("click")
@ -125,7 +125,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("leaves every day selectable by default", async ({ expect }) => {
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -142,7 +142,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
}) => {
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
placeholder: t("editor.hooks.scheduled-reminder.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 20),
availableFromTomorrow: true,
})

View File

@ -178,7 +178,7 @@ function buildNotificationDescription(notification: Notification) {
hook = t("editor.hooks.github-tracking.title")
break
case DocumentHookType.ScheduledReminder:
hook = t("editor.hooks.time-expiration.title")
hook = t("editor.hooks.scheduled-reminder.title")
break
case DocumentHookType.ContainerImageWatcher:
hook = t("editor.hooks.container-image-watcher.title")

View File

@ -70,7 +70,7 @@ function paragraph(text: string, uid: string) {
}
function mountEditor(
options: { branch?: Branch; documentHooks?: DocumentHook[] } = {},
options: { branch?: Branch; activeBranchHooks?: DocumentHook[] } = {},
) {
const branch = options.branch ?? makeBranch()
@ -78,7 +78,7 @@ function mountEditor(
props: {
activeBranchProvider: branch.provider,
activeBranchYdoc: branch.ydoc,
documentHooks: options.documentHooks ?? [],
activeBranchHooks: options.activeBranchHooks ?? [],
nameEditor: null,
userCaretDetails: { name: "Me", color: "#ff0000" },
},
@ -300,7 +300,7 @@ describe("<ContentEditor>", { concurrent: false }, () => {
const called = watchCommands(editorOf(wrapper))
await wrapper.setProps({
documentHooks: [
activeBranchHooks: [
{
id: "hook-1",
blockId: "block-1",

View File

@ -48,7 +48,7 @@ import { SUPPRESS_SCROLL_TO_SELECTION_META } from "./scroll-control"
const props = defineProps<{
activeBranchProvider: HocuspocusProvider
activeBranchYdoc: Y.Doc
documentHooks: DocumentHook[]
activeBranchHooks: DocumentHook[]
nameEditor: Editor | null | undefined
userCaretDetails: { name: string; color: string }
}>()
@ -66,13 +66,10 @@ const linkBubbleMenuElem = useTemplateRef("link-bubble-menu")
const editorStore = useEditorStore()
const { uploadDocumentFile } = useDocumentFileAPI()
const suppressNextSelectionScroll = ref(false)
const { isEditable } = useEditorMeta()
const isEditingDisabled = computed(() => {
return !isEditable.value || editorStore.reviewableDiffActive
})
const { isReadOnlyOrDiff } = useEditorMeta()
const textCommentState = ref<TextCommentIndicatorState | null>(null)
const editorExtensions = contentExtensionsWithIDs(t, isEditingDisabled, {
const editorExtensions = contentExtensionsWithIDs(t, isReadOnlyOrDiff, {
onIndicatorStateChange: (v) => {
textCommentState.value = v
},
@ -174,7 +171,7 @@ const contentEditor = useEditor({
}),
HookDecorator.configure({
attributeName: "uid",
getHooks: () => props.documentHooks,
getHooks: () => props.activeBranchHooks,
}),
Typography,
Collaboration.configure({
@ -308,7 +305,7 @@ watch(
)
watchDeep(
() => props.documentHooks,
() => props.activeBranchHooks,
() => {
contentEditor.value?.commands.refreshHookDecorations()
},
@ -375,7 +372,7 @@ function setTextCommentIndicatorHoverChange(
ref="editor-container"
:class="[
'group/content-editor content-editor relative z-editor-content w-full min-w-0 flex-1 bg-transparent',
{ 'editor-not-editable': isEditingDisabled },
{ 'editor-not-editable': isReadOnlyOrDiff },
]"
>
<template v-if="contentEditor">
@ -391,7 +388,7 @@ function setTextCommentIndicatorHoverChange(
:container="containerElem"
/>
<BlockHandle
:document-hooks="documentHooks"
:active-branch-hooks="activeBranchHooks"
:editor="contentEditor"
:data-sync-provider="activeBranchProvider"
@add-node-comment="

View File

@ -268,6 +268,50 @@ describe("<DocumentContainer>", { concurrent: false }, () => {
expect(calls.length).toBe(before)
})
it("loads the target's hooks before the diff is turned on", async ({
expect,
}) => {
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
const calls = mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => [],
)
await mountContainer()
// the query fires from a watcher once the container has mounted
await vi.waitFor(() => {
expect(calls.map((call) => call.query.branchId)).toEqual([
TARGET_BRANCH_ID,
])
}, WAIT_FOR_OPTIONS)
})
it("refetches the target's hooks when the server says they changed", async ({
expect,
}) => {
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
seedQueryData(["documents", "hooks", DOCUMENT_ID, TARGET_BRANCH_ID], [])
const calls = mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => [],
)
const { handlers } = stubSocket()
await mountContainer()
handlers.forEach((handler) => {
handler({ branchId: TARGET_BRANCH_ID })
})
await vi.waitFor(() => {
expect(calls.map((call) => call.query.branchId)).toEqual([
TARGET_BRANCH_ID,
])
}, WAIT_FOR_OPTIONS)
})
it("subscribes to no hooks topic while no page is open", async ({
expect,
}) => {

View File

@ -44,10 +44,16 @@ const fetchBranches = useFetchDocumentBranchesByDocId(
const { isEditable, isCompactView } = useEditorMeta()
const route = useRoute()
const nuxtApp = useNuxtApp()
const fetchDocumentHooks = documentHookAPI.useFetchDocumentHooksByDocID(
const fetchActiveBranchHooks = documentHookAPI.useFetchDocumentHooksByDocID(
() => editorStore.activeDocumentId,
() => editorStore.activeBranchId,
)
// the target's hooks load alongside the active ones, like its provider,
// so turning the diff on shows them without waiting on a request
const fetchTargetBranchHooks = documentHookAPI.useFetchDocumentHooksByDocID(
() => editorStore.activeDocumentId,
() => editorStore.targetBranchId,
)
const queryCache = useQueryCache()
const wsState = useWebSocketStateStore()
let unsubWsHooksChange: (() => void) | null | undefined = null
@ -380,7 +386,8 @@ function showSettings(target: "github") {
@transitionend.self="emit('fade-in-complete')"
>
<NameEditor
:document-hooks="fetchDocumentHooks.state.value.data || []"
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
:target-branch-hooks="fetchTargetBranchHooks.state.value.data || []"
:timestamps="props.timestamps"
:active-branch-ydoc="activeBranchProvider.ydoc"
:active-branch-provider="activeBranchProvider.provider"
@ -397,7 +404,7 @@ function showSettings(target: "github") {
v-show="!editorStore.reviewableDiffActive"
:active-branch-provider="activeBranchProvider.provider"
:active-branch-ydoc="activeBranchProvider.ydoc"
:document-hooks="fetchDocumentHooks.state.value.data || []"
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
:name-editor="nameEditor as Editor"
:user-caret-details="userCaretDetails"
@editor-ready="(v) => updateEditor(v)"
@ -412,6 +419,8 @@ function showSettings(target: "github") {
"
:target-branch-ydoc="targetBranchProvider.ydoc"
:active-branch-ydoc="activeBranchProvider.ydoc"
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
:target-branch-hooks="fetchTargetBranchHooks.state.value.data || []"
:content-editor="contentEditor as Editor"
@diff-mode-changed="handleDiffModeChange"
/>

View File

@ -899,12 +899,12 @@ describe("<DocumentTagList>", { concurrent: false }, () => {
`${t("editor.tags.diff.added-sign")}Staging${t("editor.tags.diff.added")}`,
`${t("editor.tags.diff.removed-sign")}Incidents${t("editor.tags.diff.removed")}`,
])
// the tints run edge to edge rather than as rounded insets
expect(items[0]?.parentElement?.classList).toContain("-mx-1")
// the tints fill the menu's usual inset rounded rows
expect(items[0]?.parentElement?.classList).not.toContain("-mx-1")
expect(items.map((el) => el.classList.contains("rounded-none"))).toEqual([
true,
true,
true,
false,
false,
false,
])
// a row keeps its tint under the pointer, since it toggles nothing
expect(
@ -918,6 +918,21 @@ describe("<DocumentTagList>", { concurrent: false }, () => {
])
})
it("keeps no sign column when no tag was added or removed", async ({
expect,
}) => {
mockEndpoint("GET", TARGET_TAGS_URL, () => [TAG_A, TAG_B])
const wrapper = await mountTags()
await openPicker(wrapper)
expect(pickerRows()).toEqual(["Production", "Staging"])
expect(pickerItems().map((el) => !!el.querySelector(".w-2"))).toEqual([
false,
false,
])
})
it("toggles nothing from the picker", async ({ expect }) => {
const assigned = mockEndpoint("POST", BRANCH_TAGS_URL, () => ({}))
const unassigned = mockEndpoint(

View File

@ -114,6 +114,11 @@ const addedTags = computed(() => {
const removedTags = computed(
() => targetTags.value?.filter((tag) => !carriesTag(tag.id)) ?? [],
)
// a sign marks a tag added or removed, so without one the rows need no
// room for it
const hasSigns = computed(
() => addedTags.value.length > 0 || removedTags.value.length > 0,
)
const rowElem = useTemplateRef<HTMLElement>("tag-row")
const triggerElem = useTemplateRef<HTMLElement>("tag-trigger")
@ -461,15 +466,7 @@ function handleSearchEnter() {
/>
<ShadcnUiDropdownMenuSeparator />
<template v-if="matchingTags.length">
<!--
the diff tints run edge to edge, so the list takes back the
menu's padding and each row adds it to its own
-->
<div
ref="tag-list"
class="max-h-47.5 overflow-y-auto"
:class="{ '-mx-1': targetTags }"
>
<div ref="tag-list" class="max-h-47.5 overflow-y-auto">
<ShadcnUiDropdownMenuItem
v-for="tag in matchingTags"
:key="tag.id"
@ -480,7 +477,6 @@ function handleSearchEnter() {
// a row that toggles nothing does not react to the pointer
!canEdit &&
'cursor-default focus:bg-transparent focus:text-inherit active:bg-transparent active:text-inherit',
targetTags && 'rounded-none px-3',
tagDiffStatus(tag.id) === DiffStatus.Added &&
'bg-diff-added/30 focus:bg-diff-added/30 active:bg-diff-added/30',
tagDiffStatus(tag.id) === DiffStatus.Removed &&
@ -493,7 +489,7 @@ function handleSearchEnter() {
<div class="flex min-w-0 flex-1 items-center gap-2">
<!-- every row keeps the sign's column so the dots line up -->
<span
v-if="targetTags"
v-if="hasSigns"
aria-hidden="true"
class="w-2 shrink-0 text-center font-semibold"
:class="{

View File

@ -13,6 +13,7 @@ import * as Y from "yjs"
import NameEditor from "./NameEditor.vue"
import IconPicker from "./IconPicker.vue"
import DiffTitle from "./diff/DiffTitle.vue"
import HookDiffMarker from "./hooks/HookDiffMarker.vue"
import ReviewerList from "./ReviewerList.vue"
import {
clearQueryCache,
@ -101,7 +102,8 @@ function mountEditor(
options: {
active?: Branch
target?: Branch | null
documentHooks?: DocumentHook[]
activeBranchHooks?: DocumentHook[]
targetBranchHooks?: DocumentHook[]
contentEditor?: unknown
} = {},
) {
@ -111,7 +113,8 @@ function mountEditor(
// once at page level
return mountUnderTooltipProvider(NameEditor, {
props: {
documentHooks: options.documentHooks ?? [],
activeBranchHooks: options.activeBranchHooks ?? [],
targetBranchHooks: options.targetBranchHooks ?? [],
activeBranchYdoc: active.ydoc,
activeBranchProvider: active.provider,
targetBranchProvider: options.target?.provider ?? null,
@ -296,7 +299,7 @@ describe("<NameEditor>", { concurrent: false }, () => {
it("shows the page's own hooks", async ({ expect }) => {
const wrapper = await mountEditor({
documentHooks: [
activeBranchHooks: [
{
id: "hook-1",
type: DocumentHookType.URLWatcher,
@ -325,6 +328,38 @@ describe("<NameEditor>", { concurrent: false }, () => {
)
})
it("reaches into both page margins for the hook handle's hover", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(wrapper.get(".group\\/name-line").classes()).toEqual(
expect.arrayContaining([
"before:right-full",
"before:w-5",
"lg:before:w-12.5",
"after:left-full",
"after:w-5",
"lg:after:w-12.5",
]),
)
})
it("tints the hook icon alone and backs the narrow dots", async ({
expect,
}) => {
const wrapper = await mountEditor({
activeBranchHooks: [pageHook("https://oxynote.test")],
})
expect(
wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"),
).toBe("fresh")
const dots = wrapper.get(".i-mingcute\\:dots-line").element
expect(dots.closest("[data-hook-status]")).toBeNull()
expect(dots.parentElement?.classList).toContain("bg-background-translucent")
})
it("leaves the hook handle untinted on a page with no hooks", async ({
expect,
}) => {
@ -468,20 +503,82 @@ describe("<NameEditor>", { concurrent: false }, () => {
)
})
it("takes the hook handle away while a diff is on", async ({ expect }) => {
it("keeps the hook handle while a diff is on", async ({ expect }) => {
const wrapper = await mountEditor({ target: makeBranch() })
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.text()).not.toContain(
t("editor.hook-handle.screen-reader-hint"),
expect(wrapper.text()).toContain(t("editor.hook-handle.screen-reader-hint"))
})
it("marks the changes to the page's own hooks while a diff is on", async ({
expect,
}) => {
const wrapper = await mountEditor({
target: makeBranch(),
activeBranchHooks: [pageHook("https://new.test")],
targetBranchHooks: [pageHook("https://old.test")],
})
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.getComponent(HookDiffMarker).props()).toEqual(
expect.objectContaining({
count: { removed: 1, added: 1 },
hookStatus: "fresh",
lifted: false,
}),
)
// the gap centres the pill under the 1.375rem hook button
expect(wrapper.getComponent(HookDiffMarker).classes()).toContain(
"lg:pr-1.75",
)
})
it("leaves the page unmarked while the diff is off", async ({ expect }) => {
const wrapper = await mountEditor({
target: makeBranch(),
activeBranchHooks: [pageHook("https://new.test")],
})
expect(wrapper.findComponent(HookDiffMarker).exists()).toBe(false)
})
it("leaves the page unmarked when its hooks did not change", async ({
expect,
}) => {
const wrapper = await mountEditor({
target: makeBranch(),
activeBranchHooks: [pageHook("https://same.test")],
targetBranchHooks: [pageHook("https://same.test")],
})
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent(HookDiffMarker).exists()).toBe(false)
})
it("moves the page marker below the hook handle while the title is hovered", async ({
expect,
}) => {
const wrapper = await mountEditor({
target: makeBranch(),
activeBranchHooks: [pageHook("https://new.test")],
})
useEditorStore().setReviewableDiffActive(true)
await nextTick()
await wrapper.get(".group\\/name-line").trigger("mouseenter")
expect(wrapper.getComponent(HookDiffMarker).props("lifted")).toBe(true)
})
it("marks the page when one of its hooks has fired", async ({ expect }) => {
const wrapper = await mountEditor({
documentHooks: [
activeBranchHooks: [
{
id: "hook-1",
type: DocumentHookType.URLWatcher,
@ -694,3 +791,15 @@ describe("<NameEditor>", { concurrent: false }, () => {
expect(wrapper.findComponent(ReviewerList).exists()).toBe(false)
})
})
function pageHook(url: string): DocumentHook {
return {
id: url,
type: DocumentHookType.URLWatcher,
blockId: null,
score: "100",
settings: { url: url },
state: { status: "active" },
createdAt: "2026-01-01",
} as unknown as DocumentHook
}

View File

@ -18,6 +18,8 @@ import HooksMenuContent from "./hooks/HookMenuContent.vue"
import MaintainerList from "./MaintainerList.vue"
import DocumentTagList from "./DocumentTagList.vue"
import ReviewerList from "./ReviewerList.vue"
import HookDiffMarker from "./hooks/HookDiffMarker.vue"
import { blockChangeCount, diffHooks } from "./hooks/hook-diff"
import { showToastMessage } from "../toast"
import {
DEFAULT_HIGHLIGHT_OVERLAY_PADDING,
@ -31,7 +33,8 @@ enum ReviewableAction {
}
const props = defineProps<{
documentHooks: DocumentHook[]
activeBranchHooks: DocumentHook[]
targetBranchHooks?: DocumentHook[]
activeBranchYdoc: Y.Doc
activeBranchProvider: HocuspocusProvider
targetBranchProvider?: HocuspocusProvider | null | undefined
@ -86,7 +89,7 @@ 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.documentHooks.filter((h) => h.blockId === null)
const hooks = props.activeBranchHooks.filter((h) => h.blockId === null)
if (!hooks.length) {
return null
}
@ -95,7 +98,9 @@ const hookStatus = computed(() => {
})
const hookMenuOpen = ref(false)
const rootElem = useTemplateRef<HTMLElement>("name-editor-root")
const nameLineElem = useTemplateRef<HTMLElement>("name-line")
const hookHandleElem = useTemplateRef<HTMLElement>("hook-handle")
const hoveringNameLine = useElementHover(nameLineElem)
const hoveringHookHandle = useElementHover(hookHandleElem)
// the menu hands focus back to the handle when it closes, also after an
// outside click that never reaches the editor, so a plain focus would
@ -104,15 +109,28 @@ const hoveringHookHandle = useElementHover(hookHandleElem)
const { focused: keyboardFocusOnHookHandle } = useFocus(hookHandleElem, {
focusVisible: true,
})
const { isEditable } = useEditorMeta()
const { isScrolling } = useWindowScroll()
const { show: showHighlight, hide: hideHighlight } = useHighlightOverlay()
// the hook handle edits the page's hooks, so it follows the same rule as
// the block handles: gone while the page cannot be edited, and gone in
// diff mode, where the title is a rendering of two branches rather than
// an editor
const isEditingDisabled = computed(
() => !isEditable.value || editorStore.reviewableDiffActive,
// the changes to the page's own hooks, null while there are none to mark
const titleHookChanges = computed(() => {
if (!editorStore.reviewableDiffActive || !props.targetBranchHooks) {
return null
}
const count = blockChangeCount(
diffHooks(props.activeBranchHooks, props.targetBranchHooks),
null,
)
return count.removed || count.added ? count : null
})
// the hook handle shows over the marker's spot, so the marker moves below
// it whenever the handle is up
const hookHandleShown = computed(
() =>
hoveringNameLine.value ||
keyboardFocusOnHookHandle.value ||
hookMenuOpen.value,
)
const selectedReviewableAction = ref<ReviewableAction>(
ReviewableAction.ApproveUnapprove,
@ -393,16 +411,24 @@ async function executeReviewableAction() {
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)]"
/>
<!--
the line reaches into the page margins on both sides, so hovering
them shows the hook handle the way a block's margins show its
drag handle
-->
<div
class="flex flex-col-reverse items-start gap-2 sm:flex-row sm:justify-between"
ref="name-line"
class="group/name-line relative flex flex-col-reverse items-start gap-2 before:absolute before:inset-y-0 before:right-full before:w-5 after:absolute after:inset-y-0 after:left-full after:w-5 sm:flex-row sm:justify-between lg:before:w-12.5 lg:after:w-12.5"
>
<div
class="group/name-row relative flex flex-1 items-start gap-2 text-foreground"
>
<ShadcnUiDropdownMenu
v-if="!isEditingDisabled"
@update:open="(v: boolean) => (hookMenuOpen = v)"
>
<div class="relative flex flex-1 items-start gap-2 text-foreground">
<HookDiffMarker
v-if="titleHookChanges"
class="absolute top-1.25 right-full pr-0.5 lg:pr-1.75"
:count="titleHookChanges"
:hook-status="hookStatus"
:lifted="hookHandleShown"
/>
<ShadcnUiDropdownMenu @update:open="(v: boolean) => (hookMenuOpen = v)">
<ShadcnUiDropdownMenuTrigger as-child>
<button
ref="hook-handle"
@ -411,9 +437,9 @@ async function executeReviewableAction() {
:data-hovering="hoveringHookHandle ? '' : undefined"
:class="
cn(
'group/hook-handle absolute top-0.5 right-full flex h-7 items-center pr-1.5',
'group/hook-handle absolute top-0.5 right-full flex h-7 items-center pr-1 lg:pr-1.5',
'pointer-events-none opacity-0 transition-opacity duration-100',
'group-hover/name-row:pointer-events-auto group-hover/name-row:opacity-100',
'group-hover/name-line:pointer-events-auto group-hover/name-line:opacity-100',
'focus-visible:pointer-events-auto focus-visible:opacity-100',
'data-menu-open:pointer-events-auto data-menu-open:opacity-100',
'data-hovering:pointer-events-auto data-hovering:opacity-100',
@ -421,11 +447,9 @@ async function executeReviewableAction() {
"
>
<span
:data-hook-status="hookStatus"
:class="
cn(
'flex h-5 w-3 cursor-pointer items-center justify-center rounded-sm text-foreground/50 lg:size-5.5 lg:rounded-md',
'data-[hook-status=fresh]:text-hook-status-fresh data-[hook-status=stale]:text-hook-status-stale',
'group-data-menu-open/hook-handle:bg-sidebar-accent/50 hover:bg-sidebar-accent/50 active:bg-sidebar-accent',
)
"
@ -434,9 +458,13 @@ async function executeReviewableAction() {
the block handles fold their hook menu into the drag
handle where the gutter is too narrow for both, so the
title takes the same handle at the same width there and
keeps the hook button where the block ones do
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
-->
<span class="relative h-5 w-3 overflow-hidden lg:hidden">
<span
class="relative h-5 w-3 overflow-hidden rounded-sm bg-background-translucent lg:hidden"
>
<Icon
name="mingcute:dots-line"
class="absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2"
@ -444,7 +472,8 @@ async function executeReviewableAction() {
</span>
<Icon
name="mingcute:leaf-line"
class="mt-0.25 hidden size-4.5 lg:block"
: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"
/>
</span>
<span class="sr-only">
@ -454,7 +483,8 @@ async function executeReviewableAction() {
</ShadcnUiDropdownMenuTrigger>
<ShadcnUiDropdownMenuContent side="right" align="start" loop>
<HooksMenuContent
:document-hooks="documentHooks"
:active-branch-hooks="activeBranchHooks"
:target-branch-hooks="props.targetBranchHooks"
:node-id="null"
@open-settings="(v) => emit('open-settings', v)"
/>

View File

@ -0,0 +1,106 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { describe, it } from "vitest"
import DiffCountPill from "./DiffCountPill.vue"
import { t } from "~/components/test-helpers"
describe("<DiffCountPill>", () => {
it("shows both counts and names them for a screen reader", async ({
expect,
}) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: 2, added: 3 },
})
expect(visibleCounts(wrapper)).toEqual([
t("editor.diff-change-marker.removed", { count: 2 }),
t("editor.diff-change-marker.added", { count: 3 }),
])
expect(wrapper.get(".sr-only").text()).toBe(
t("editor.diff-change-marker.label", { removed: 2, added: 3 }),
)
})
it.for([
{ name: "removals", removed: 0, added: 1, key: "added" },
{ name: "additions", removed: 1, added: 0, key: "removed" },
])(
"leaves out the side with no $name",
async ({ removed, added, key }, { expect }) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: removed, added: added },
})
expect(visibleCounts(wrapper)).toEqual([
t(`editor.diff-change-marker.${key}`, { count: 1 }),
])
},
)
it("stacks the counts when vertical", async ({ expect }) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: 1, added: 1, vertical: true },
})
expect(wrapper.classes()).toContain("flex-col")
})
it.for([
{
name: "pads the outer edges of a stack more than the shared one",
lead: false,
expected: [
["pt-0.75", "pb-0.5"],
["pt-0.5", "pb-0.75"],
],
},
{
name: "pads the top of a stack under a lead-in as a shared edge",
lead: true,
expected: [
["pt-0.5", "pb-0.5"],
["pt-0.5", "pb-0.75"],
],
},
])("$name", async ({ lead, expected }, { expect }) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: 1, added: 1, vertical: true },
slots: lead ? { default: () => h("i") } : {},
})
expect(
wrapper
.findAll("[aria-hidden='true']")
.map((cell) =>
cell
.classes()
.filter((c) => c.startsWith("pt-") || c.startsWith("pb-")),
),
).toEqual(expected)
})
it("pads a lone stacked count as an outer edge on both sides", async ({
expect,
}) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: 0, added: 2, vertical: true },
})
expect(wrapper.get("[aria-hidden='true']").classes()).toEqual(
expect.arrayContaining(["pt-0.75", "pb-0.75"]),
)
})
it("puts its lead-in before the counts", async ({ expect }) => {
const wrapper = await mountSuspended(DiffCountPill, {
props: { removed: 1, added: 1 },
slots: { default: () => h("i", { class: "lead" }) },
})
expect(wrapper.get(".lead").element.previousElementSibling).toBeNull()
})
})
function visibleCounts(wrapper: VueWrapper): string[] {
return wrapper.findAll("[aria-hidden='true']").map((el) => el.text())
}

View File

@ -0,0 +1,66 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
const props = defineProps<{
removed: number
added: number
vertical?: boolean
}>()
const slots = useSlots()
// a stacked cell pads its outer edge more than an edge it shares, or two
// cells meeting would add their padding into one wide gap
const removedPadding = computed(() =>
cn(slots.default ? "pt-0.5" : "pt-0.75", props.added ? "pb-0.5" : "pb-0.75"),
)
const addedPadding = computed(() =>
cn(slots.default || props.removed ? "pt-0.5" : "pt-0.75", "pb-0.75"),
)
</script>
<template>
<span
:class="
cn(
'flex overflow-hidden rounded-full border border-border bg-background text-[0.625rem] leading-3.5 font-semibold select-none',
// a stack sits in the page margin, which is narrow below lg
props.vertical && 'flex-col max-lg:text-[0.5625rem]',
)
"
>
<!-- a lead-in the pill carries before its counts -->
<slot />
<span class="sr-only">
{{
$t("editor.diff-change-marker.label", {
removed: props.removed,
added: props.added,
})
}}
</span>
<span
v-if="props.removed"
aria-hidden="true"
:class="
cn(
'bg-diff-removed text-center text-diff-removed-foreground',
props.vertical ? ['px-0.25 lg:px-0.75', removedPadding] : 'px-1.5',
)
"
>
{{ $t("editor.diff-change-marker.removed", { count: props.removed }) }}
</span>
<span
v-if="props.added"
aria-hidden="true"
:class="
cn(
'bg-diff-added text-center text-diff-added-foreground',
props.vertical ? ['px-0.25 lg:px-0.75', addedPadding] : 'px-1.5',
)
"
>
{{ $t("editor.diff-change-marker.added", { count: props.added }) }}
</span>
</span>
</template>

View File

@ -44,6 +44,8 @@ function mountDiff(target: Y.Doc, active: Y.Doc) {
props: {
targetBranchYdoc: target,
activeBranchYdoc: active,
activeBranchHooks: [],
targetBranchHooks: [],
contentEditor: {} as unknown as Editor,
},
global: { stubs: childStubs },

View File

@ -9,10 +9,13 @@ import CommentIndicatorContainer from "../comments/CommentIndicatorContainer.vue
import type { NodeCommentOverlayState } from "../comments/node-comment-extension"
import type { TextCommentIndicatorState } from "../comments/comment-mark"
import BlockHandle from "../drag-handle/BlockHandle.vue"
import BlockHookDiffMarkers from "../hooks/BlockHookDiffMarkers.vue"
const props = defineProps<{
targetBranchYdoc: Y.Doc
activeBranchYdoc: Y.Doc
activeBranchHooks: DocumentHook[]
targetBranchHooks: DocumentHook[]
contentEditor: Editor
}>()
@ -22,6 +25,7 @@ const commentRendererElem = useTemplateRef("diff-comment-renderer")
const nodeCommentState = ref<NodeCommentOverlayState | null>(null)
const textCommentState = ref<TextCommentIndicatorState | null>(null)
const hoveredBlockId = ref<string | null>(null)
const { editor, positionMap, destroy, suppressNextRecompute } = useDiffEditor(
() => props.targetBranchYdoc,
@ -64,6 +68,9 @@ onBeforeUnmount(() => {
/>
<BlockHandle
:editor="editor"
:active-branch-hooks="props.activeBranchHooks"
:target-branch-hooks="props.targetBranchHooks"
@hovered-block-change="(id: string | null) => (hoveredBlockId = id)"
@add-node-comment="
(pos: number) => commentRendererElem?.addNewComment(pos)
"
@ -105,6 +112,12 @@ onBeforeUnmount(() => {
}
"
/>
<BlockHookDiffMarkers
:editor="editor"
:active-branch-hooks="props.activeBranchHooks"
:target-branch-hooks="props.targetBranchHooks"
:hovered-block-id="hoveredBlockId"
/>
<EditorContent :editor="editor" />
</template>
</div>

View File

@ -5,6 +5,7 @@ import { beforeEach, describe, it, vi } from "vitest"
import BlockHandle from "./BlockHandle.vue"
import { DragHandle } from "./MainElement"
import CoreMenu from "./menu-options/CoreMenu.vue"
import HookMenuContent from "../hooks/HookMenuContent.vue"
import { makeEditor } from "../test-helpers/node-view"
import {
clearTeleportedOverlays,
@ -89,6 +90,13 @@ function grabbingCursor(): boolean {
return document.documentElement.classList.contains("cursor-grabbing!")
}
async function openHookMenu(wrapper: VueWrapper) {
const trigger = wrapper.findAll("[data-slot='dropdown-menu-trigger']")[0]
await trigger?.trigger("pointerdown", { button: 0 })
await trigger?.trigger("click")
await nextTick()
}
async function openBlockMenu(wrapper: VueWrapper) {
const triggers = wrapper.findAll("[data-slot='dropdown-menu-trigger']")
const trigger = triggers[triggers.length - 1]
@ -132,13 +140,13 @@ describe("<BlockHandle>", { concurrent: false }, () => {
)
})
it("offers no hook handle in read mode", async ({ expect }) => {
it("keeps the hook handle in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHandle()
expect(wrapper.findAll("[data-slot='dropdown-menu-trigger']")).toHaveLength(
1,
2,
)
})
@ -158,7 +166,7 @@ describe("<BlockHandle>", { concurrent: false }, () => {
expect,
}) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-1", 1)],
activeBranchHooks: [hook("block-1", 1)],
})
hoverNode(wrapper, 10)
await nextTick()
@ -172,7 +180,7 @@ describe("<BlockHandle>", { concurrent: false }, () => {
expect,
}) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-1", 1), hook("block-1", 0)],
activeBranchHooks: [hook("block-1", 1), hook("block-1", 0)],
})
hoverNode(wrapper, 10)
await nextTick()
@ -184,7 +192,7 @@ describe("<BlockHandle>", { concurrent: false }, () => {
it("ignores hooks belonging to other blocks", async ({ expect }) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-2", 0)],
activeBranchHooks: [hook("block-2", 0)],
})
hoverNode(wrapper, 10)
await nextTick()
@ -220,6 +228,44 @@ describe("<BlockHandle>", { concurrent: false }, () => {
expect(wrapper.findComponent(CoreMenu).props("hovered")).toBeNull()
})
it("reports the block it shows on", async ({ expect }) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
expect(wrapper.emitted("hovered-block-change")).toEqual([["block-1"]])
})
it("reports no block once the pointer leaves the document", async ({
expect,
}) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
hoverNode(wrapper, 10, null)
await nextTick()
expect(wrapper.emitted("hovered-block-change")).toEqual([
["block-1"],
[null],
])
})
it("hands the target's hooks to the hook menu", async ({ expect }) => {
const targetBranchHooks = [hook("block-1", 1)]
const wrapper = await mountHandle({ targetBranchHooks: targetBranchHooks })
hoverNode(wrapper, 10)
await nextTick()
await openHookMenu(wrapper)
expect(
wrapper.findComponent(HookMenuContent).props("targetBranchHooks"),
).toBe(targetBranchHooks)
})
it("forgets a block that is no longer in the document", async ({
expect,
}) => {

View File

@ -15,7 +15,9 @@ const MENU_CLOSE_ANIMATION_DURATION = 150
const props = defineProps<{
editor: Editor
documentHooks?: DocumentHook[]
activeBranchHooks?: DocumentHook[]
// given only where the diff can be shown
targetBranchHooks?: DocumentHook[]
dataSyncProvider?: HocuspocusProvider | null
}>()
const emit = defineEmits<{
@ -24,23 +26,21 @@ const emit = defineEmits<{
pos: number,
): void
(e: "open-settings", target: "github"): void
// the uid of the block the handle shows on, null once it hides
(e: "hovered-block-change", blockId: string | null): void
}>()
const { isLocked, updateLock, isEditable } = useEditorMeta()
const { isLocked, updateLock, isReadOnlyOrDiff } = useEditorMeta()
const { isScrolling } = useWindowScroll()
const isMinWidth1024px = useMediaQuery("(min-width: 1024px)")
const editorStore = useEditorStore()
const isEditingDisabled = computed(() => {
return !isEditable.value || editorStore.reviewableDiffActive
})
const { show: showHighlight, hide: hideHighlight } = useHighlightOverlay()
const nodeActionMenuOpen = ref(false)
const hookMenuOpen = ref(false)
const hoveringDrag = ref(false)
const processedDocumentHooks = computed(() => {
return props.documentHooks ?? []
const processedActiveBranchHooks = computed(() => {
return props.activeBranchHooks ?? []
})
const hoveredNodePos = ref<number | null>(null)
const hoveredMetadata = computed(() => {
@ -54,7 +54,7 @@ const hoveredMetadata = computed(() => {
}
const nodeId = node.attrs.uid as string
const hooks = processedDocumentHooks.value.filter((h) => {
const hooks = processedActiveBranchHooks.value.filter((h) => {
return h.blockId === nodeId
})
@ -101,6 +101,12 @@ watch([nodeActionMenuOpen, hookMenuOpen], ([nodeOpen, hookOpen]) => {
whenever(isScrolling, () => {
removeDragOverlay()
})
watch(
() => hoveredMetadata.value?.nodeId ?? null,
(blockId) => {
emit("hovered-block-change", blockId)
},
)
function handleNodeHover(data: { pos: number; node: Node | null }) {
if (!data.node) {
@ -163,7 +169,7 @@ function removeDragOverlay() {
}
function dragStart(e: DragEvent) {
if (isEditingDisabled.value) {
if (isReadOnlyOrDiff.value) {
e.preventDefault()
return
}
@ -188,7 +194,7 @@ function dragEnd() {
:provider="props.dataSyncProvider"
:locked="isLocked"
:on-drag-cancel="dragEnd"
class="z-drag-handle pr-1.5 transition-[opacity,visibility] duration-100"
class="z-drag-handle pr-1 transition-[opacity,visibility] duration-100 lg:pr-1.5"
@node-change="handleNodeHover"
@mouseenter="handleDragHoverEnter"
@mouseleave="handleDragHoverLeave"
@ -197,11 +203,15 @@ function dragEnd() {
@dragstart="dragStart"
@dragend="dragEnd"
>
<!--
the narrow handle keeps a smaller gap to the block, which centres it
over the hook diff marker below it
-->
<div
class="flex flex-col items-center gap-0.25 rounded-md bg-background-translucent lg:flex-row"
>
<ShadcnUiDropdownMenu
v-if="!compactHandles && !isEditingDisabled"
v-if="!compactHandles"
@update:open="(value: boolean) => (hookMenuOpen = value)"
>
<ShadcnUiDropdownMenuTrigger as-child>
@ -233,7 +243,8 @@ function dragEnd() {
</ShadcnUiDropdownMenuTrigger>
<ShadcnUiDropdownMenuContent side="right" align="start" loop>
<HookMenuContent
:document-hooks="processedDocumentHooks"
:active-branch-hooks="processedActiveBranchHooks"
:target-branch-hooks="props.targetBranchHooks"
:node-id="hoveredMetadata?.nodeId || null"
@open-settings="(target: 'github') => emit('open-settings', target)"
/>
@ -249,7 +260,7 @@ function dragEnd() {
cn(
'flex h-5 w-3 items-center justify-center rounded-sm text-foreground/50 lg:h-5.5 lg:w-4 lg:rounded-md',
'hover:bg-sidebar-accent/50 active:bg-sidebar-accent data-menu-open:bg-sidebar-accent/50 data-menu-open:active:bg-sidebar-accent/50',
isEditingDisabled ? 'cursor-pointer' : 'cursor-grab',
isReadOnlyOrDiff ? 'cursor-pointer' : 'cursor-grab',
)
"
>
@ -265,9 +276,10 @@ function dragEnd() {
</span>
</ShadcnUiDropdownMenuTrigger>
<ShadcnUiDropdownMenuContent side="left" align="start" loop>
<template v-if="compactHandles && !isEditingDisabled">
<template v-if="compactHandles">
<HookMenuContent
:document-hooks="processedDocumentHooks"
:active-branch-hooks="processedActiveBranchHooks"
:target-branch-hooks="props.targetBranchHooks"
:node-id="hoveredMetadata?.nodeId || null"
@open-settings="
(target: 'github') => emit('open-settings', target)

View File

@ -390,6 +390,18 @@ describe("DragHandlePlugin", { concurrent: false }, () => {
})
describe("unbind", () => {
it("stops hiding the handle when the pointer leaves it", ({ expect }) => {
const { editor, element, handle } = setup()
hover(editor, handle.plugin, 0)
handle.unbind()
element.dispatchEvent(
new MouseEvent("mouseleave", { relatedTarget: document.body }),
)
expect(element.style.visibility).toBe("")
})
it("stops reacting to drag events on the handle", ({ expect }) => {
const { editor, element, handle, onElementDragStart } = setup()
hover(editor, handle.plugin, 0)
@ -1340,6 +1352,58 @@ describe("DragHandlePlugin", { concurrent: false }, () => {
})
describe("mouseleave", () => {
it("hides the handle when the pointer leaves it for outside the editor", ({
expect,
}) => {
const { editor, element, handle, onNodeChange } = setup()
hover(editor, handle.plugin, 0)
onNodeChange.mockClear()
element.dispatchEvent(
new MouseEvent("mouseleave", { relatedTarget: document.body }),
)
expect(element.style.visibility).toBe("hidden")
expect(onNodeChange).toHaveBeenCalledTimes(1)
expect(onNodeChange).toHaveBeenCalledWith({
editor,
node: null,
pos: -1,
depth: 0,
})
})
it("keeps the handle when the pointer leaves it back into the editor", ({
expect,
}) => {
const { editor, element, handle, onNodeChange } = setup()
hover(editor, handle.plugin, 0)
onNodeChange.mockClear()
element.dispatchEvent(
new MouseEvent("mouseleave", {
relatedTarget: editor.view.dom.firstElementChild,
}),
)
expect(element.style.visibility).toBe("")
expect(onNodeChange).toHaveBeenCalledTimes(0)
})
it("keeps a locked handle when the pointer leaves it", ({ expect }) => {
const { editor, element, handle, onNodeChange } = setup()
hover(editor, handle.plugin, 0)
editor.view.dispatch(editor.state.tr.setMeta("lockDragHandle", true))
onNodeChange.mockClear()
element.dispatchEvent(
new MouseEvent("mouseleave", { relatedTarget: document.body }),
)
expect(element.style.visibility).toBe("")
expect(onNodeChange).toHaveBeenCalledTimes(0)
})
it("hides the handle when the pointer leaves for an outside element", ({
expect,
}) => {

View File

@ -595,12 +595,32 @@ export const DragHandlePlugin = ({
forgetNode()
}
// the handle sits in the margin, outside the editor, so the editor's own
// mouseleave keeps it when the pointer moves onto it. Leaving the handle
// for anywhere but the editor has to take it away instead
function onHandleMouseLeave(e: MouseEvent) {
if (locked) {
return
}
const target = e.relatedTarget as globalThis.Node | null
if (
target &&
(editor.view.dom.contains(target) || wrapper.contains(target))
) {
return
}
forgetNode()
}
function trackMouse(e: MouseEvent) {
lastMouseCoords = { x: e.clientX, y: e.clientY }
}
element.addEventListener("dragstart", onDragStart)
element.addEventListener("dragend", onDragEnd)
element.addEventListener("mouseleave", onHandleMouseLeave)
wrapper.appendChild(element)
hideHandle()
@ -609,6 +629,7 @@ export const DragHandlePlugin = ({
unbind() {
element.removeEventListener("dragstart", onDragStart)
element.removeEventListener("dragend", onDragEnd)
element.removeEventListener("mouseleave", onHandleMouseLeave)
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)

View File

@ -0,0 +1,263 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { Editor } from "@tiptap/core"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import BlockHookDiffMarkers from "./BlockHookDiffMarkers.vue"
import HookDiffMarker from "./HookDiffMarker.vue"
import { makeHook } from "./test-helpers"
const ROOT_RECT = new DOMRect(0, 0, 800, 600)
interface FakeBlock {
uid: string
rect: DOMRect
type?: string
}
// the root's own rect is the frame every marker is placed in, and happy-dom
// lays nothing out, so it is fixed here
describe("<BlockHookDiffMarkers>", { concurrent: false }, () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
ROOT_RECT,
)
})
it("places a marker by each block whose hooks changed", async ({
expect,
}) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
{ uid: "block-2", rect: new DOMRect(50, 160, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [
urlHook("block-1", "https://new.test"),
urlHook("block-2", "https://same.test"),
],
targetBranchHooks: [
urlHook("block-1", "https://old.test"),
urlHook("block-2", "https://same.test"),
],
})
const markers = wrapper.findAllComponents(HookDiffMarker)
expect(markers).toHaveLength(1)
expect(markers[0]?.props()).toEqual({
count: { removed: 1, added: 1 },
hookStatus: "fresh",
lifted: false,
})
// a paragraph's handle sits one pixel above the block's top
expect(markers[0]?.attributes("style")).toBe("top: 99px; right: 750px;")
// the gap to the block centres the marker under the drag dots
expect(markers[0]?.classes()).toEqual(
expect.arrayContaining(["pr-0.5", "lg:pr-1"]),
)
})
it("leaves the page's own hooks to the title", async ({ expect }) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [urlHook(null, "https://new.test")],
})
expect(wrapper.findAllComponents(HookDiffMarker)).toHaveLength(0)
})
it("skips a block the document does not hold", async ({ expect }) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [urlHook("block-gone", "https://new.test")],
})
expect(wrapper.findAllComponents(HookDiffMarker)).toHaveLength(0)
})
it("lifts the marker of the block the handle shows on", 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")],
hoveredBlockId: "block-1",
})
expect(wrapper.getComponent(HookDiffMarker).props("lifted")).toBe(true)
})
it.for([
{ name: "stale for a triggered hook", score: "0", expected: "stale" },
{ name: "neutral for removed hooks alone", score: null, expected: null },
])("colours the hook icon $name", async ({ score, expected }, { expect }) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
])
const triggered = { ...urlHook("block-1", "https://new.test"), score: "0" }
const wrapper = await mountMarkers(
editor,
score === null
? { targetBranchHooks: [urlHook("block-1", "https://old.test")] }
: { activeBranchHooks: [triggered] },
)
expect(wrapper.getComponent(HookDiffMarker).props("hookStatus")).toBe(
expected,
)
})
it("centres the marker on a block whose handle is centred", async ({
expect,
}) => {
const { editor } = fakeEditor([
{
uid: "block-1",
rect: new DOMRect(50, 100, 600, 40),
type: "horizontalRule",
},
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [urlHook("block-1", "https://new.test")],
})
// the middle of the block less half of a 1.375rem handle at 16px
expect(wrapper.getComponent(HookDiffMarker).attributes("style")).toBe(
"top: 109px; right: 750px;",
)
})
it("places the marker on whole pixels", async ({ expect }) => {
const { editor } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50.4, 100.6, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [urlHook("block-1", "https://new.test")],
})
expect(wrapper.getComponent(HookDiffMarker).attributes("style")).toBe(
"top: 100px; right: 750px;",
)
})
it("follows its block when the document changes", async ({ expect }) => {
const { editor, blocks, emitTransaction } = fakeEditor([
{ uid: "block-1", rect: new DOMRect(50, 100, 600, 40) },
])
const wrapper = await mountMarkers(editor, {
activeBranchHooks: [urlHook("block-1", "https://new.test")],
})
blocks.set("block-1", new DOMRect(50, 300, 600, 40))
emitTransaction()
await nextTick()
expect(wrapper.getComponent(HookDiffMarker).attributes("style")).toBe(
"top: 299px; right: 750px;",
)
})
it("stops following the document once unmounted", async ({ expect }) => {
const { editor, off } = fakeEditor([])
const wrapper = await mountMarkers(editor, {})
wrapper.unmount()
expect(off).toHaveBeenCalledTimes(1)
expect(off).toHaveBeenCalledWith("transaction", expect.any(Function))
})
})
function urlHook(blockId: string | null, url: string): DocumentHook {
return makeHook({
id: `${blockId ?? "page"}-${url}`,
blockId: blockId,
settings: { url: url },
})
}
// fakeEditor stands in for the diff editor: its blocks sit at fixed rects
// that a test can move, and a transaction is emitted by hand
function fakeEditor(fakeBlocks: FakeBlock[]) {
const blocks = new Map(fakeBlocks.map((block) => [block.uid, block.rect]))
const nodes = fakeBlocks.map((block, index) => ({
pos: index * 10,
node: {
attrs: { uid: block.uid },
type: { name: block.type ?? "paragraph" },
},
}))
const handlers: (() => void)[] = []
const off = vi.fn()
const editor = {
on: (_event: string, handler: () => void) => {
handlers.push(handler)
},
off: off,
state: {
doc: {
descendants: (fn: (node: unknown, pos: number) => void) => {
nodes.forEach((entry) => {
fn(entry.node, entry.pos)
})
},
nodeAt: (pos: number) =>
nodes.find((entry) => entry.pos === pos)?.node ?? null,
},
},
view: {
dom: document.createElement("div"),
nodeDOM: (pos: number) => {
const uid = nodes.find((entry) => entry.pos === pos)?.node.attrs.uid
const rect = uid ? blocks.get(uid) : undefined
if (!rect) {
return null
}
const el = document.createElement("div")
el.getBoundingClientRect = () => rect
return el
},
},
} as unknown as Editor
return {
editor,
blocks,
off,
emitTransaction: () => {
handlers.forEach((handler) => {
handler()
})
},
}
}
function mountMarkers(
editor: Editor,
props: Record<string, unknown>,
): Promise<VueWrapper> {
return mountSuspended(BlockHookDiffMarkers, {
props: {
editor: editor,
activeBranchHooks: [],
targetBranchHooks: [],
hoveredBlockId: null,
...props,
},
})
}

View File

@ -0,0 +1,141 @@
<script lang="ts" setup>
import type { Editor } from "@tiptap/core"
import HookDiffMarker from "./HookDiffMarker.vue"
import { blockChangeCount, diffHooks, type HookChangeCount } from "./hook-diff"
import { handlePositionByNodeType } from "../drag-handle/config"
// the height of the hook button on a wide screen. A block whose handle is
// centred on it gets its marker centred the same way
const HANDLE_HEIGHT_REM = 1.375
interface MarkerPlacement {
blockId: string
count: HookChangeCount
hookStatus: "fresh" | "stale" | null
top: number
right: number
}
const props = defineProps<{
editor: Editor
activeBranchHooks: DocumentHook[]
targetBranchHooks: DocumentHook[]
// the block the handle shows on
hoveredBlockId: string | null
}>()
const rootElem = useTemplateRef<HTMLElement>("root")
const placements = shallowRef<MarkerPlacement[]>([])
const entries = computed(() =>
diffHooks(props.activeBranchHooks, props.targetBranchHooks),
)
// the page's own hooks are marked by the title, not here
const changedBlocks = computed(() => {
const ids = new Set(
entries.value
.map((entry) => entry.hook.blockId)
.filter((id): id is string => id !== null),
)
return [...ids]
.map((id) => ({ id: id, count: blockChangeCount(entries.value, id) }))
.filter((block) => block.count.removed || block.count.added)
})
onMounted(() => {
props.editor.on("transaction", placeMarkers)
placeMarkers()
})
onBeforeUnmount(() => {
props.editor.off("transaction", placeMarkers)
})
// a block moves when anything above it changes height, which the editor's
// own size reports, images and charts loading included
useResizeObserver(() => props.editor.view.dom, placeMarkers)
watch(changedBlocks, placeMarkers)
// placeMarkers puts each marker where the block's handle shows, with its
// right edge on the handle's right edge. Whole pixels keep the marker from
// drawing between two of them
function placeMarkers() {
const root = rootElem.value
if (!root) {
return
}
const rootRect = root.getBoundingClientRect()
const remPx = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
)
const positions = new Map<string, number>()
props.editor.state.doc.descendants((node, pos) => {
const uid = node.attrs.uid as string | null | undefined
if (uid && !positions.has(uid)) {
positions.set(uid, pos)
}
})
placements.value = changedBlocks.value.flatMap((block) => {
const pos = positions.get(block.id)
if (pos === undefined) {
return []
}
const dom = props.editor.view.nodeDOM(pos)
if (!(dom instanceof HTMLElement)) {
return []
}
const rect = dom.getBoundingClientRect()
const config = handlePositionByNodeType(props.editor, pos)
const anchor = rect.left - rootRect.left + config.xOffset
const top =
config.placement === "left"
? rect.top + rect.height / 2 - (HANDLE_HEIGHT_REM * remPx) / 2
: rect.top + config.yOffset
return [
{
blockId: block.id,
count: block.count,
hookStatus: hookStatus(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>
<div ref="root" class="pointer-events-none absolute inset-0">
<!--
the gap to the block centres the marker under the handle's drag dots
-->
<HookDiffMarker
v-for="placement in placements"
:key="placement.blockId"
class="absolute pr-0.5 lg:pr-1"
:style="{ top: `${placement.top}px`, right: `${placement.right}px` }"
:count="placement.count"
:hook-status="placement.hookStatus"
:lifted="placement.blockId === props.hoveredBlockId"
/>
</div>
</template>

View File

@ -0,0 +1,80 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import HookDiffMarker from "./HookDiffMarker.vue"
import DiffCountPill from "../diff/DiffCountPill.vue"
import { stubViewportMatches } from "~/components/test-helpers"
// the viewport stub is shared, so these tests cannot interleave
describe("<HookDiffMarker>", { concurrent: false }, () => {
beforeEach(() => {
stubViewportMatches(true)
})
it("stacks the counts it is given", async ({ expect }) => {
const wrapper = await mountMarker({ count: { removed: 3, added: 1 } })
expect(pill(wrapper).props()).toEqual({
removed: 3,
added: 1,
vertical: true,
})
})
it("shows the hook icon in the hook's colour at rest", async ({ expect }) => {
const wrapper = await mountMarker({ hookStatus: "stale" })
expect(hookIconSlot(wrapper).classes()).toContain("h-3.75")
expect(hookIconSlot(wrapper).classes()).not.toContain("h-0")
expect(
hookIconSlot(wrapper)
.get(".i-mingcute\\:leaf-line")
.attributes("data-hook-status"),
).toBe("stale")
expect(wrapper.classes()).not.toContain("translate-y-5.5")
})
it("moves below the handle and drops the hook icon once lifted", async ({
expect,
}) => {
const wrapper = await mountMarker({ lifted: true })
expect(wrapper.classes()).toContain("translate-y-5.5")
expect(wrapper.classes()).toContain("lg:translate-y-6")
expect(hookIconSlot(wrapper).classes()).toContain("h-0")
expect(hookIconSlot(wrapper).classes()).toContain("lg:h-0")
expect(hookIconSlot(wrapper).classes()).not.toContain("lg:h-4.25")
expect(hookIconSlot(wrapper).classes()).toContain("opacity-0")
})
it("keeps the hook icon when lifted on a narrow screen", async ({
expect,
}) => {
stubViewportMatches(false)
const wrapper = await mountMarker({ lifted: true })
expect(wrapper.classes()).toContain("translate-y-5.5")
expect(hookIconSlot(wrapper).classes()).not.toContain("h-0")
expect(hookIconSlot(wrapper).classes()).not.toContain("opacity-0")
})
})
function mountMarker(props: Record<string, unknown> = {}) {
return mountSuspended(HookDiffMarker, {
props: {
count: { removed: 1, added: 1 },
hookStatus: "fresh",
lifted: false,
...props,
},
})
}
function pill(wrapper: VueWrapper) {
return wrapper.getComponent(DiffCountPill)
}
function hookIconSlot(wrapper: VueWrapper) {
return pill(wrapper).get("span > span")
}

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import DiffCountPill from "../diff/DiffCountPill.vue"
import type { HookChangeCount } from "./hook-diff"
const props = defineProps<{
count: HookChangeCount
// the hook icon takes the colour the hook button would have
hookStatus: "fresh" | "stale" | null
// the handle shows in the marker's spot, so the marker moves below it
lifted: boolean
}>()
const isMinWidth1024px = useMediaQuery("(min-width: 1024px)")
// the hook icon stands in for the hook button, so it gives way once the
// button shows. The narrow handle has no hook button, so there it stays
const showHookIcon = computed(() => !props.lifted || !isMinWidth1024px.value)
</script>
<template>
<div
:class="
cn(
'pointer-events-none transition-transform duration-100 will-change-transform',
props.lifted && 'translate-y-5.5 lg:translate-y-6',
)
"
>
<!--
the hook icon has to sit on a whole pixel, or it jumps by one when the
move ends. So the marker keeps a layer of its own rather than
getting one only while it moves, and the pill's minimum width is
a whole number of pixels
-->
<DiffCountPill
:removed="props.count.removed"
:added="props.count.added"
vertical
class="min-w-4 lg:min-w-5"
>
<!--
the slot collapses to nothing once lifted, which padding would
stop, so the icon's own margin pads the top
-->
<span
aria-hidden="true"
:class="
cn(
'flex h-3.75 items-start justify-center overflow-hidden transition-[height,opacity] duration-100 lg:h-4.25',
!showHookIcon && 'h-0 opacity-0 lg:h-0',
)
"
>
<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"
/>
</span>
</DiffCountPill>
</div>
</template>

View File

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

View File

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

View File

@ -59,7 +59,7 @@ function mockGitHub(configured: boolean) {
function mountContent(props: Record<string, unknown> = {}) {
return mountHookMenu(HookMenuContent, {
documentHooks: [],
activeBranchHooks: [],
nodeId: "block-1",
...props,
})
@ -73,6 +73,8 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
clearQueryCache()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
@ -94,7 +96,7 @@ 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.time-expiration.title"))
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"),
@ -112,7 +114,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
await vi.waitFor(() => {
expect(menuText()).not.toContain(t("editor.hooks.github-tracking.title"))
}, WAIT_FOR_OPTIONS)
expect(menuText()).toContain(t("editor.hooks.time-expiration.title"))
expect(menuText()).toContain(t("editor.hooks.scheduled-reminder.title"))
})
it("leaves website changes out when the server has no changedetection", async ({
@ -128,13 +130,13 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
expect(menuText()).toContain(t("editor.hooks.github-tracking.title"))
}, WAIT_FOR_OPTIONS)
expect(menuText()).not.toContain(t("editor.hooks.url-watcher.title"))
expect(menuText()).toContain(t("editor.hooks.time-expiration.title"))
expect(menuText()).toContain(t("editor.hooks.scheduled-reminder.title"))
})
it("lists the hooks already on the block", async ({ expect }) => {
mockGitHub(true)
await mountContent({ documentHooks: [reminder(), urlWatcher()] })
await mountContent({ activeBranchHooks: [reminder(), urlWatcher()] })
expect(menuText()).toContain("Remind on")
expect(menuText()).toContain("Watching oxynote.test")
@ -144,7 +146,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
mockGitHub(true)
await mountContent({
documentHooks: [urlWatcher({ blockId: "block-2" })],
activeBranchHooks: [urlWatcher({ blockId: "block-2" })],
})
expect(menuText()).not.toContain("Watching oxynote.test")
@ -156,7 +158,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
mockGitHub(true)
await mountContent({
documentHooks: [reminder(), urlWatcher({ score: "0" })],
activeBranchHooks: [reminder(), urlWatcher({ score: "0" })],
})
const labels = Array.from(
@ -173,7 +175,7 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
mockGitHub(true)
await mountContent({
documentHooks: [
activeBranchHooks: [
urlWatcher({
id: "url-newer",
settings: { url: "https://newer.test" },
@ -204,4 +206,146 @@ describe("<HookMenuContent>", { concurrent: false }, () => {
expect(document.body.querySelectorAll("[role^='menuitem']")).toHaveLength(1)
})
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()).not.toContain(t(ADD_NEW))
})
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,
}) => {
mockGitHub(true)
await mountContent({ targetBranchHooks: [urlWatcher()] })
expect(menuText()).not.toContain("Watching oxynote.test")
expect(menuText()).toContain(t(ADD_NEW))
})
describe("when the diff is shown", { concurrent: false }, () => {
beforeEach(() => {
mockGitHub(true)
useEditorStore().setReviewableDiffActive(true)
})
it("offers no add action", async ({ expect }) => {
await mountContent({ activeBranchHooks: [urlWatcher()] })
expect(menuText()).not.toContain(t(ADD_NEW))
})
it("lists a hook the active branch added", async ({ expect }) => {
await mountContent({
activeBranchHooks: [urlWatcher()],
targetBranchHooks: [],
})
const row = hookRows()[0]
expect(row?.textContent).toContain("Watching oxynote.test")
expect(row?.textContent).toContain(t("editor.hooks.diff.added"))
expect(row?.classList).toContain("bg-diff-added/30")
})
it("lists a hook the active branch removed", async ({ expect }) => {
await mountContent({
activeBranchHooks: [],
targetBranchHooks: [urlWatcher()],
})
const row = hookRows()[0]
expect(row?.textContent).toContain("Watching oxynote.test")
expect(row?.textContent).toContain(t("editor.hooks.diff.removed"))
expect(row?.classList).toContain("bg-diff-removed/30")
})
it("counts the changed settings of a modified hook", async ({ expect }) => {
await mountContent({
activeBranchHooks: [urlWatcher()],
targetBranchHooks: [
urlWatcher({ id: "url-old", settings: { url: "https://old.test" } }),
],
})
const row = hookRows()[0]
expect(row?.textContent).toContain(
t("editor.diff-change-marker.removed", { count: 1 }),
)
expect(row?.textContent).toContain(
t("editor.diff-change-marker.added", { count: 1 }),
)
})
it("keeps a sign column on every row once a hook was added", async ({
expect,
}) => {
await mountContent({
activeBranchHooks: [urlWatcher(), reminder()],
targetBranchHooks: [reminder({ id: "reminder-target" })],
})
expect(hookRows().map((row) => !!row.querySelector(".w-2"))).toEqual([
true,
true,
])
})
it("keeps no sign column when no hook was added or removed", async ({
expect,
}) => {
await mountContent({
activeBranchHooks: [urlWatcher()],
targetBranchHooks: [
urlWatcher({ id: "url-old", settings: { url: "https://old.test" } }),
],
})
expect(hookRows().map((row) => !!row.querySelector(".w-2"))).toEqual([
false,
])
})
it("leaves an unchanged hook unmarked", async ({ expect }) => {
await mountContent({
activeBranchHooks: [urlWatcher()],
targetBranchHooks: [urlWatcher({ id: "url-target" })],
})
const row = hookRows()[0]
expect(row?.textContent).not.toContain(t("editor.hooks.diff.added"))
expect(row?.textContent).not.toContain(t("editor.hooks.diff.removed"))
expect(row?.classList).not.toContain("bg-diff-added/30")
expect(row?.classList).not.toContain("bg-diff-removed/30")
})
})
})
// the rows are teleported with the menu, out of the wrapper's reach
function hookRows(): HTMLElement[] {
return Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
)
}

View File

@ -5,9 +5,13 @@ import ScheduledReminderConfigMenu from "./scheduled-reminder/ConfigMenu.vue"
import URLWatcherConfigMenu from "./url-watcher/ConfigMenu.vue"
import ContainerImageWatcherConfigMenu from "./container-image-watcher/ConfigMenu.vue"
import { compareAsc } from "date-fns"
import { DiffStatus } from "../diff/position-map"
import { diffHooks, type HookDiffEntry } from "./hook-diff"
const props = defineProps<{
documentHooks: DocumentHook[]
activeBranchHooks: DocumentHook[]
// given only where the diff can be shown
targetBranchHooks?: DocumentHook[]
nodeId: string | null
}>()
const emit = defineEmits<{
@ -16,41 +20,65 @@ const emit = defineEmits<{
const { gitHubConfigured } = useGitHubAPI()
const { isChangeDetectionEnabled } = useCapabilitiesAPI()
const { isReadOnlyOrDiff } = useEditorMeta()
const editorStore = useEditorStore()
const isSubOpen = ref(false)
const isDiffShown = computed(
() => editorStore.reviewableDiffActive && !!props.targetBranchHooks,
)
// a sign marks a hook added or removed, so without one the rows need no
// room for it
const hasSigns = computed(
() =>
isDiffShown.value &&
entries.value.some(
(e) => e.status === DiffStatus.Added || e.status === DiffStatus.Removed,
),
)
const matchingActiveHooks = computed(() => {
return props.documentHooks
.filter((h) => {
return h.blockId === props.nodeId && Number(h.score) !== 0
})
const entries = computed<HookDiffEntry[]>(() => {
const activeHooks = props.activeBranchHooks.filter(
(h) => h.blockId === props.nodeId,
)
const targetHooks = props.targetBranchHooks
if (!editorStore.reviewableDiffActive || !targetHooks) {
return activeHooks.map((h) => ({
status: DiffStatus.Unchanged,
hook: h,
targetHook: null,
}))
}
return diffHooks(
activeHooks,
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)),
)
function listedHooks(matching: HookDiffEntry[]) {
return matching
.sort((a, b) =>
compareAsc(a.updatedAt ?? new Date(0), b.updatedAt ?? new Date(0)),
compareAsc(
a.hook.updatedAt ?? new Date(0),
b.hook.updatedAt ?? new Date(0),
),
)
.map((h) => {
.map((e) => {
return {
id: h.id,
comp: hookComponent(h.type),
props: hookProps(h.type, h),
id: e.hook.id,
comp: hookComponent(e.hook.type),
props: hookProps(e),
}
})
})
const matchingTriggeredHooks = computed(() => {
return props.documentHooks
.filter((h) => {
return h.blockId === props.nodeId && Number(h.score) === 0
})
.sort((a, b) =>
compareAsc(a.updatedAt ?? new Date(0), b.updatedAt ?? new Date(0)),
)
.map((h) => {
return {
id: h.id,
comp: hookComponent(h.type),
props: hookProps(h.type, h),
}
})
})
}
function hookComponent(type: DocumentHookType): Component | null {
switch (type) {
@ -71,37 +99,24 @@ function hookComponent(type: DocumentHookType): Component | null {
}
}
function hookProps(
type: DocumentHookType,
hook: DocumentHook,
): ComputedRef<Record<string, unknown>> {
switch (type) {
case DocumentHookType.ScheduledReminder:
return computed(() => ({
nodeId: props.nodeId,
hook: hook,
}))
case DocumentHookType.GitHubTracking:
return computed(() => ({
nodeId: props.nodeId,
hook: hook,
onOpenSettings: (target: "github") => {
emit("open-settings", target)
},
}))
case DocumentHookType.URLWatcher:
return computed(() => ({
nodeId: props.nodeId,
hook: hook,
}))
case DocumentHookType.ContainerImageWatcher:
return computed(() => ({
nodeId: props.nodeId,
hook: hook,
}))
default:
return computed(() => ({}))
}
function hookProps(entry: HookDiffEntry): ComputedRef<Record<string, unknown>> {
return computed(() => ({
nodeId: props.nodeId,
hook: entry.hook,
diff: isDiffShown.value
? {
status: entry.status,
targetHook:
entry.status === DiffStatus.Modified ? entry.targetHook : null,
signColumn: hasSigns.value,
}
: null,
...(entry.hook.type === DocumentHookType.GitHubTracking && {
onOpenSettings: (target: "github") => {
emit("open-settings", target)
},
}),
}))
}
</script>
<template>
@ -113,7 +128,9 @@ function hookProps(
:key="h.id"
v-bind="h.props.value"
/>
<ShadcnUiDropdownMenuSeparator />
<ShadcnUiDropdownMenuSeparator
v-if="matchingActiveHooks.length || !isReadOnlyOrDiff"
/>
</template>
<template v-if="matchingActiveHooks.length">
<component
@ -122,9 +139,19 @@ function hookProps(
:key="h.id"
v-bind="h.props.value"
/>
<ShadcnUiDropdownMenuSeparator />
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
</template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<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>

View File

@ -0,0 +1,41 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookReadonlyField from "./HookReadonlyField.vue"
import { DiffStatus } from "../diff/position-map"
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 },
],
},
})
const inputs = wrapper.findAll("input")
expect(wrapper.text()).toContain("Link")
expect(inputs.map((input) => input.element.value)).toEqual([
"https://new.test",
"https://old.test",
"https://same.test",
])
expect(inputs.map((input) => input.attributes("readonly"))).toEqual([
"",
"",
"",
])
expect(inputs.map((input) => input.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")
})
})

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import { DiffStatus } from "../diff/position-map"
import type { HookFieldRow } from "./hook-diff"
const props = defineProps<{
label: string
rows: HookFieldRow[]
}>()
</script>
<template>
<div class="mt-0.5 flex flex-col gap-1">
<div class="text-left text-2sm font-medium text-foreground">
{{ props.label }}
</div>
<ShadcnUiInput
v-for="(row, index) in props.rows"
:key="index"
:model-value="row.value"
:aria-label="props.label"
transparent-disable
disable-focus-effect
disable-destructive-effect
:class="
cn(
'h-[1.775rem] px-2 text-2sm md:text-2sm',
row.status === DiffStatus.Added && 'bg-diff-field-added',
row.status === DiffStatus.Removed && 'bg-diff-field-removed',
)
"
/>
</div>
</template>

View File

@ -0,0 +1,149 @@
import { beforeEach, describe, it } from "vitest"
import HookSubTrigger from "./HookSubTrigger.vue"
import { makeHook, mountHookMenu } from "./test-helpers"
import type { HookDiffContext } from "./hook-diff"
import { DiffStatus } from "../diff/position-map"
import { DropdownMenuSub } from "~/components/shadcn/ui/dropdown-menu"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
// 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 }) => {
await mountTrigger({})
const row = triggerRow()
expect(row.textContent).toContain("Watching oxynote.test")
expect(signColumn(row)).toBeNull()
})
it.for([
{
status: DiffStatus.Added,
sign: "editor.hooks.diff.added-sign",
label: "editor.hooks.diff.added",
tint: "bg-diff-added/30",
},
{
status: DiffStatus.Removed,
sign: "editor.hooks.diff.removed-sign",
label: "editor.hooks.diff.removed",
tint: "bg-diff-removed/30",
},
])(
"signs and tints a hook the diff marks $status",
async ({ status, sign, label, tint }, { expect }) => {
await mountTrigger({ diff: diffOf(status, true) })
const row = triggerRow()
expect(signColumn(row)?.textContent.trim()).toBe(t(sign))
expect(row.querySelector(".sr-only")?.textContent.trim()).toBe(t(label))
expect(row.classList).toContain(tint)
},
)
it.for([
{ status: DiffStatus.Added, hover: "bg-diff-added/50" },
{ status: DiffStatus.Removed, hover: "bg-diff-removed/50" },
{ status: DiffStatus.Unchanged, hover: "bg-accent/50" },
])(
"deepens the shade of a $status hook on hover",
async ({ status, hover }, { expect }) => {
await mountTrigger({ diff: diffOf(status) })
const classes = Array.from(triggerRow().classList)
expect(classes.filter((c) => c.startsWith("focus:bg-"))).toEqual([
`focus:${hover}`,
])
expect(
classes.filter((c) => c.startsWith("data-[state=open]:bg-")),
).toEqual([`data-[state=open]:${hover}`])
// the row keeps the menu's usual inset and rounding
expect(classes).toContain("rounded")
expect(classes).not.toContain("rounded-none")
},
)
it("strikes through the name of a removed hook", async ({ expect }) => {
await mountTrigger({ diff: diffOf(DiffStatus.Removed) })
expect(triggerRow().querySelector(".line-through")?.textContent).toContain(
"Watching oxynote.test",
)
})
it("counts the changes of a modified hook", async ({ expect }) => {
await mountTrigger({
hook: makeHook({ settings: { url: "https://new.test" } }),
diff: {
status: DiffStatus.Modified,
targetHook: makeHook({ settings: { url: "https://old.test" } }),
signColumn: false,
},
})
const row = triggerRow()
expect(row.textContent).toContain(
t("editor.diff-change-marker.removed", { count: 1 }),
)
expect(row.textContent).toContain(
t("editor.diff-change-marker.added", { count: 1 }),
)
expect(row.classList).not.toContain("bg-diff-added/30")
expect(row.classList).not.toContain("bg-diff-removed/30")
})
it("keeps an empty sign column for an unchanged hook", async ({ expect }) => {
await mountTrigger({ diff: diffOf(DiffStatus.Unchanged, true) })
const row = triggerRow()
expect(signColumn(row)?.textContent.trim()).toBe("")
expect(row.querySelector(".sr-only")).toBeNull()
})
it("leaves out the sign column when the menu has no signs", async ({
expect,
}) => {
await mountTrigger({ diff: diffOf(DiffStatus.Modified) })
expect(signColumn(triggerRow())).toBeNull()
})
})
function mountTrigger(props: Record<string, unknown>) {
return mountHookMenu(
defineComponent(
() => () =>
h(DropdownMenuSub, null, {
default: () =>
h(HookSubTrigger, props, {
default: () => h("span", "Watching oxynote.test"),
}),
}),
),
{},
)
}
function triggerRow(): HTMLElement {
const row = document.body.querySelector<HTMLElement>(
"[data-slot='dropdown-menu-sub-trigger']",
)
if (!row) {
throw new Error("no hook row is rendered")
}
return row
}
// the sign sits in a fixed width column of its own
function signColumn(row: HTMLElement): Element | null {
return row.querySelector(".w-2")
}
function diffOf(status: DiffStatus, signColumn = false): HookDiffContext {
return { status: status, targetHook: null, signColumn: signColumn }
}

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import DiffCountPill from "../diff/DiffCountPill.vue"
import { DiffStatus } from "../diff/position-map"
import { hookChangeCount, type HookDiffContext } from "./hook-diff"
const props = defineProps<{
hook?: DocumentHook | null
// set only while the diff is shown
diff?: HookDiffContext | null
}>()
const diffStatus = computed(() => props.diff?.status ?? null)
const count = computed(() =>
props.hook && props.diff?.targetHook
? hookChangeCount(props.hook, props.diff.targetHook)
: null,
)
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",
// a tinted row deepens its own tint on hover, rather than taking the
// grey the other rows use
diffStatus.value === DiffStatus.Added &&
"bg-diff-added/30 focus:bg-diff-added/50 data-[state=open]:bg-diff-added/50",
diffStatus.value === DiffStatus.Removed &&
"bg-diff-removed/30 focus:bg-diff-removed/50 data-[state=open]:bg-diff-removed/50",
),
)
</script>
<template>
<ShadcnUiDropdownMenuSubTrigger :class="rowClass">
<!-- every row keeps the sign's column so the icons line up -->
<span
v-if="props.diff?.signColumn"
aria-hidden="true"
:class="
cn(
'mr-0.5 w-2 shrink-0 text-center font-semibold',
diffStatus === DiffStatus.Added && 'text-diff-added-foreground',
diffStatus === DiffStatus.Removed && 'text-diff-removed-foreground',
)
"
>
<template v-if="diffStatus === DiffStatus.Added">
{{ $t("editor.hooks.diff.added-sign") }}
</template>
<template v-else-if="diffStatus === DiffStatus.Removed">
{{ $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 />
</span>
<span v-if="diffStatus === DiffStatus.Added" class="sr-only">
{{ $t("editor.hooks.diff.added") }}
</span>
<span v-else-if="diffStatus === DiffStatus.Removed" class="sr-only">
{{ $t("editor.hooks.diff.removed") }}
</span>
<DiffCountPill
v-if="diffStatus === DiffStatus.Modified && count"
:removed="count.removed"
:added="count.added"
class="shrink-0"
/>
</ShadcnUiDropdownMenuSubTrigger>
</template>

View File

@ -2,12 +2,14 @@ import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import { DiffStatus } from "../../diff/position-map"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
readonlyFields,
typeInMenu,
} from "../test-helpers"
import {
@ -59,6 +61,8 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
@ -313,4 +317,85 @@ describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
expect(calls).toHaveLength(0)
})
it("says what a triggered hook did while editing too", async ({ expect }) => {
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu("Updates in postgres:16.4-alpine")
expect(menuText()).toContain(
t(
"editor.hooks.container-image-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 image without a way to change it", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(readonlyFields()).toEqual([["postgres:16.4-alpine", "unchanged"]])
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.container-image-watcher.triggered-item-block-explanation",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.container-image-watcher.triggered-item-full-document-explanation",
},
])(
"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")
expect(menuText()).toContain(t(key))
},
)
})
describe("when the diff is shown", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setReviewableDiffActive(true)
})
it("shows the new image over the old one", async ({ expect }) => {
await mountMenu({
hook: imageHook(),
diff: {
status: DiffStatus.Modified,
targetHook: imageHook({ settings: { image: "redis:7" } }),
signColumn: false,
},
})
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).toContain(
t(
"editor.hooks.container-image-watcher.existing-item-block-explanation",
),
)
expect(readonlyFields()).toEqual([
["postgres:16.4-alpine", "added"],
["redis:7", "removed"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
})
})
})

View File

@ -1,9 +1,15 @@
<script setup lang="ts">
import { showToastMessage } from "~/components/toast"
import HookInputField from "./HookInputField.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
// set only while the diff is shown
diff?: HookDiffContext | null
nodeId: string | null // null means global
}>()
const emit = defineEmits<{
@ -25,6 +31,12 @@ const hookData = computed(() => {
const documentHookAPI = useDocumentHookAPI()
const editorStore = useEditorStore()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsContainerImageWatcher | undefined,
)
const confirmedImage = ref<string | undefined>(
hookData.value ? hookData.value.settings.image : undefined,
)
@ -143,7 +155,7 @@ async function resetHook() {
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<div class="relative h-[0.8125rem] w-[0.8125rem] shrink-0">
<Icon
name="simple-icons:docker"
@ -175,7 +187,7 @@ async function resetHook() {
{{ confirmedImage || "" }}
</template>
</i18n-t>
</ShadcnUiDropdownMenuSubTrigger>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
@ -201,29 +213,29 @@ async function resetHook() {
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template
v-if="
hookData &&
hookData.score !== 0 &&
hookData.state.status === 'active'
"
>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.container-image-watcher.existing-item-block-explanation'
: 'editor.hooks.container-image-watcher.existing-item-full-document-explanation'
"
tag="div"
class="w-full text-center text-xs break-words text-muted-foreground"
/>
</div>
<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')
@ -238,8 +250,8 @@ async function resetHook() {
</template>
</HookInputField>
</div>
<ShadcnUiDropdownMenuSeparator />
<div class="p-0.75">
<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

View File

@ -3,6 +3,7 @@ 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 { DiffStatus } from "../../diff/position-map"
import FileSelectInput from "./FileSelectInput.vue"
import {
makeHook,
@ -10,6 +11,7 @@ import {
menuText,
mountHookMenu,
openHookSubMenu,
readonlyFields,
} from "../test-helpers"
import {
clearQueryCache,
@ -104,6 +106,8 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
@ -432,4 +436,103 @@ describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
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"))
})
describe("when the page is read only", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(false)
})
it("shows the settings without a way to change them", async ({
expect,
}) => {
mockGitHub({ connected: false })
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
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"),
)
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.github-tracking.triggered-item-block-explanation",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.github-tracking.triggered-item-full-document-explanation",
},
])(
"says what a triggered hook on $name did",
async ({ nodeId, key }, { expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook({ score: "0" }), nodeId: nodeId })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
expect(menuText()).toContain(t(key))
},
)
})
describe("when the diff is shown", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setReviewableDiffActive(true)
})
it("shows what changed in each setting", async ({ expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook(),
diff: {
status: DiffStatus.Modified,
targetHook: githubHook({
settings: {
repository: REPOSITORY,
branch: "next",
paths: ["docs/readme.md", "docs/old.md"],
},
}),
signColumn: false,
},
})
await openHookSubMenu(`Watching ${REPOSITORY}`)
expect(menuText()).toContain(
t("editor.hooks.github-tracking.existing-item-block-explanation"),
)
expect(readonlyFields()).toEqual([
[REPOSITORY, "unchanged"],
["main", "added"],
["next", "removed"],
["docs/readme.md", "unchanged"],
["docs/old.md", "removed"],
])
})
})
})

View File

@ -2,9 +2,19 @@
import { showToastMessage } from "~/components/toast"
import FileSelectInput from "./FileSelectInput.vue"
import { cn } from "~/lib/utils"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import {
listFieldRows,
scalarFieldRows,
type HookDiffContext,
} from "../hook-diff"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
// set only while the diff is shown
diff?: HookDiffContext | null
nodeId: string | null // null means global
}>()
const emit = defineEmits<{
@ -13,6 +23,12 @@ const emit = defineEmits<{
}>()
const editorStore = useEditorStore()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsGitHubTracking | undefined,
)
const hookData = computed(() => {
if (!props.hook) {
return null
@ -199,7 +215,7 @@ async function resetHook() {
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<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") }}
@ -226,7 +242,7 @@ async function resetHook() {
{{ confirmedRepository }}
</template>
</i18n-t>
</ShadcnUiDropdownMenuSubTrigger>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
@ -236,10 +252,12 @@ async function resetHook() {
]"
>
<div class="flex w-[14rem] flex-col">
<!-- the setup banners tell an editor what to connect -->
<template
v-if="
!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation'
!isReadOnlyOrDiff &&
(!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation')
"
>
<i18n-t
@ -270,7 +288,12 @@ async function resetHook() {
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template v-else-if="!fetchGitHubRepositories.state.value.data?.length">
<template
v-else-if="
!isReadOnlyOrDiff &&
!fetchGitHubRepositories.state.value.data?.length
"
>
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.no-repositories"
@ -322,35 +345,55 @@ async function resetHook() {
:class="
cn(
'flex flex-col opacity-100 transition-opacity duration-200',
(!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation') &&
!isReadOnlyOrDiff &&
(!fetchGitHubConnectionStatus.data.value?.connected ||
hookData?.state.status === 'missing_installation') &&
'pointer-events-none opacity-60',
)
"
>
<template
v-if="
hookData &&
hookData.score !== 0 &&
hookData.state.status === 'active'
"
>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.github-tracking.existing-item-block-explanation'
: 'editor.hooks.github-tracking.existing-item-full-document-explanation'
"
tag="div"
class="w-full text-center text-xs break-words text-muted-foreground"
/>
</div>
<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.branch-select-label')"
:rows="
scalarFieldRows(
hookData.settings.branch,
targetSettings?.branch ?? null,
)
"
/>
<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 ||
@ -402,6 +445,7 @@ async function resetHook() {
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<ShadcnUiSelect
v-if="!isReadOnlyOrDiff"
v-model="selectedBranch"
:disabled="
!selectedRepository ||
@ -452,6 +496,7 @@ async function resetHook() {
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<FileSelectInput
v-if="!isReadOnlyOrDiff"
v-model="selectedPaths"
class="w-full"
:disabled="
@ -505,9 +550,9 @@ async function resetHook() {
</template>
</FileSelectInput>
</div>
<ShadcnUiDropdownMenuSeparator />
<ShadcnUiDropdownMenuSeparator v-if="!isReadOnlyOrDiff" />
</div>
<div class="p-0.75">
<div v-if="!isReadOnlyOrDiff" class="p-0.75">
<div v-if="hookData" class="flex flex-col gap-1">
<div class="flex gap-1">
<ShadcnUiButton

View File

@ -0,0 +1,358 @@
import { describe, it } from "vitest"
import {
blockChangeCount,
diffHooks,
entryChangeCount,
hookChangeCount,
listFieldRows,
scalarFieldRows,
type HookDiffEntry,
} from "./hook-diff"
import { DiffStatus } from "../diff/position-map"
import {
DocumentHookType,
type DocumentHook,
type DocumentHookSettings,
} from "~/utils/api/document"
describe("diffHooks", () => {
it("pairs hooks with identical settings as unchanged", ({ expect }) => {
const active = urlHook("a1", "block-1", "https://a.com")
const target = urlHook("t1", "block-1", "https://a.com")
const entries = diffHooks([active], [target])
expect(entries).toEqual([
{ status: DiffStatus.Unchanged, hook: active, targetHook: target },
])
})
it("pairs hooks of the same block and type as modified", ({ expect }) => {
const active = urlHook("a1", "block-1", "https://b.com")
const target = urlHook("t1", "block-1", "https://a.com")
const entries = diffHooks([active], [target])
expect(entries).toEqual([
{ status: DiffStatus.Modified, hook: active, targetHook: target },
])
})
it("prefers an identical hook over an earlier changed one", ({ expect }) => {
const changed = urlHook("t1", "block-1", "https://old.com", "2026-01-01")
const same = urlHook("t2", "block-1", "https://a.com", "2026-01-02")
const active = urlHook("a1", "block-1", "https://a.com")
const entries = diffHooks([active], [changed, same])
expect(entries).toEqual([
{ status: DiffStatus.Unchanged, hook: active, targetHook: same },
{ status: DiffStatus.Removed, hook: changed, targetHook: null },
])
})
it("pairs the leftovers in creation order", ({ expect }) => {
const firstTarget = urlHook("t1", "block-1", "https://1.com", "2026-01-01")
const secondTarget = urlHook("t2", "block-1", "https://2.com", "2026-01-02")
const firstActive = urlHook("a1", "block-1", "https://3.com", "2026-02-01")
const secondActive = urlHook("a2", "block-1", "https://4.com", "2026-02-02")
const entries = diffHooks(
[secondActive, firstActive],
[secondTarget, firstTarget],
)
expect(entries).toEqual([
{
status: DiffStatus.Modified,
hook: firstActive,
targetHook: firstTarget,
},
{
status: DiffStatus.Modified,
hook: secondActive,
targetHook: secondTarget,
},
])
})
it.for([
{
name: "a different block",
target: () => urlHook("t1", "block-2", "https://a.com"),
},
{
name: "a different type",
target: () => imageHook("t1", "block-1", "postgres:16"),
},
])(
"keeps a hook on $name apart as added and removed",
({ target }, { expect }) => {
const active = urlHook("a1", "block-1", "https://a.com")
const targetHook = target()
const entries = diffHooks([active], [targetHook])
expect(entries).toEqual([
{ status: DiffStatus.Added, hook: active, targetHook: null },
{ status: DiffStatus.Removed, hook: targetHook, targetHook: null },
])
},
)
it("returns nothing when neither branch has hooks", ({ expect }) => {
expect(diffHooks([], [])).toEqual([])
})
})
describe("hookChangeCount", () => {
it.for([
{
name: "counts a moved reminder date as one change",
input: () => [
reminderHook("2026-03-01T00:00:00Z"),
reminderHook("2026-02-01T00:00:00Z"),
],
expected: { removed: 1, added: 1 },
},
{
name: "counts nothing for the same reminder date in another form",
input: () => [
reminderHook("2026-03-01T00:00:00Z"),
reminderHook("2026-03-01T01:00:00+01:00"),
],
expected: { removed: 0, added: 0 },
},
{
name: "counts a changed url as one change",
input: () => [
urlHook("a1", "block-1", "https://b.com"),
urlHook("t1", "block-1", "https://a.com"),
],
expected: { removed: 1, added: 1 },
},
{
name: "counts a changed image as one change",
input: () => [
imageHook("a1", "block-1", "postgres:17"),
imageHook("t1", "block-1", "postgres:16"),
],
expected: { removed: 1, added: 1 },
},
{
name: "counts the github repository, branch and each file",
input: () => [
githubHook("org/new", "dev", ["a.md", "c.md", "d.md"]),
githubHook("org/old", "main", ["a.md", "b.md"]),
],
expected: { removed: 3, added: 4 },
},
{
name: "counts nothing for identical github settings",
input: () => [
githubHook("org/repo", "main", ["a.md"]),
githubHook("org/repo", "main", ["a.md"]),
],
expected: { removed: 0, added: 0 },
},
])("$name", ({ input, expected }, { expect }) => {
const [hook, targetHook] = input()
expect(hookChangeCount(must(hook), must(targetHook))).toEqual(expected)
})
})
describe("entryChangeCount", () => {
it.for([
{
name: "counts an added hook as one addition",
input: (): HookDiffEntry => ({
status: DiffStatus.Added,
hook: urlHook("a1", "block-1", "https://a.com"),
targetHook: null,
}),
expected: { removed: 0, added: 1 },
},
{
name: "counts a removed hook as one removal",
input: (): HookDiffEntry => ({
status: DiffStatus.Removed,
hook: urlHook("t1", "block-1", "https://a.com"),
targetHook: null,
}),
expected: { removed: 1, added: 0 },
},
{
name: "counts a modified hook by its fields",
input: (): HookDiffEntry => ({
status: DiffStatus.Modified,
hook: githubHook("org/repo", "dev", ["a.md", "b.md"]),
targetHook: githubHook("org/repo", "main", ["a.md"]),
}),
expected: { removed: 1, added: 2 },
},
{
name: "counts nothing for a modified hook without its target version",
input: (): HookDiffEntry => ({
status: DiffStatus.Modified,
hook: urlHook("a1", "block-1", "https://a.com"),
targetHook: null,
}),
expected: { removed: 0, added: 0 },
},
{
name: "counts nothing for an unchanged hook",
input: (): HookDiffEntry => ({
status: DiffStatus.Unchanged,
hook: urlHook("a1", "block-1", "https://a.com"),
targetHook: urlHook("t1", "block-1", "https://a.com"),
}),
expected: { removed: 0, added: 0 },
},
])("$name", ({ input, expected }, { expect }) => {
expect(entryChangeCount(input())).toEqual(expected)
})
})
describe("blockChangeCount", () => {
it("adds up the changes to one block's hooks alone", ({ expect }) => {
const entries = diffHooks(
[
urlHook("a1", "block-1", "https://new.com"),
imageHook("a2", "block-1", "postgres:17"),
urlHook("a3", "block-2", "https://other.com"),
],
[
urlHook("t1", "block-1", "https://old.com"),
reminderHook("2026-03-01T00:00:00Z", "block-1"),
],
)
expect(blockChangeCount(entries, "block-1")).toEqual({
removed: 2,
added: 2,
})
})
it("counts the page's own hooks under a null block", ({ expect }) => {
const entries = diffHooks([urlHook("a1", null, "https://a.com")], [])
expect(blockChangeCount(entries, null)).toEqual({ removed: 0, added: 1 })
expect(blockChangeCount(entries, "block-1")).toEqual({
removed: 0,
added: 0,
})
})
})
describe("scalarFieldRows", () => {
it.for([
{
name: "shows a value alone outside a modified hook",
input: ["https://a.com", null] as const,
expected: [{ value: "https://a.com", status: DiffStatus.Unchanged }],
},
{
name: "shows a value alone when the target holds the same",
input: ["https://a.com", "https://a.com"] as const,
expected: [{ value: "https://a.com", status: DiffStatus.Unchanged }],
},
{
name: "shows the new value over the old one when they differ",
input: ["https://b.com", "https://a.com"] as const,
expected: [
{ value: "https://b.com", status: DiffStatus.Added },
{ value: "https://a.com", status: DiffStatus.Removed },
],
},
])("$name", ({ input, expected }, { expect }) => {
expect(scalarFieldRows(input[0], input[1])).toEqual(expected)
})
})
describe("listFieldRows", () => {
it("shows every value as unchanged outside a modified hook", ({ expect }) => {
expect(listFieldRows(["a.md", "b.md"], null)).toEqual([
{ value: "a.md", status: DiffStatus.Unchanged },
{ value: "b.md", status: DiffStatus.Unchanged },
])
})
it("marks new values and puts the dropped ones last", ({ expect }) => {
expect(listFieldRows(["a.md", "c.md"], ["a.md", "b.md"])).toEqual([
{ value: "a.md", status: DiffStatus.Unchanged },
{ value: "c.md", status: DiffStatus.Added },
{ value: "b.md", status: DiffStatus.Removed },
])
})
})
function makeHook(
id: string,
type: DocumentHookType,
blockId: string | null,
settings: DocumentHookSettings,
createdAt = "2026-01-01",
): DocumentHook {
return {
id: id,
type: type,
documentId: "doc-1",
organizationId: "org-1",
branchId: "branch-1",
blockId: blockId,
settings: settings,
state: { status: "active" },
score: "100",
createdAt: createdAt,
}
}
function urlHook(
id: string,
blockId: string | null,
url: string,
createdAt?: string,
): DocumentHook {
return makeHook(
id,
DocumentHookType.URLWatcher,
blockId,
{ url: url },
createdAt,
)
}
function imageHook(id: string, blockId: string, image: string): DocumentHook {
return makeHook(id, DocumentHookType.ContainerImageWatcher, blockId, {
image: image,
})
}
function reminderHook(schedule: string, blockId = "block-1"): DocumentHook {
return makeHook("r1", DocumentHookType.ScheduledReminder, blockId, {
scale: "linear",
duration: "24h",
schedule: schedule,
})
}
function githubHook(
repository: string,
branch: string,
paths: string[],
): DocumentHook {
return makeHook("g1", DocumentHookType.GitHubTracking, "block-1", {
repository: repository,
branch: branch,
paths: paths,
})
}
function must<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error("the case needs two hooks")
}
return value
}

View File

@ -0,0 +1,235 @@
import { DiffStatus } from "../diff/position-map"
export interface HookChangeCount {
removed: number
added: number
}
// HookFieldRow is one value a read only hook field shows, marked as new
// or old while the diff is shown.
export interface HookFieldRow {
value: string
status: DiffStatus
}
// HookDiffContext is what a hook's menu needs while the diff is shown.
export interface HookDiffContext {
status: DiffStatus
// the target's version of a modified hook
targetHook: DocumentHook | null
// whether the menu's rows keep a column for the + and − signs
signColumn: boolean
}
// HookDiffEntry is one hook as the diff shows it: the active branch's
// hook, or the target's for a hook the active branch removed. A modified
// hook also carries the target's version it replaces.
export interface HookDiffEntry {
status: DiffStatus
hook: DocumentHook
targetHook: DocumentHook | null
}
// diffHooks pairs the active branch's hooks with the target's. A fork or
// a merge copies hooks under new ids, so nothing links a hook to its copy.
// Hooks pair up by block and type instead: identical settings first, then
// the rest in creation order, which makes them modified. Whatever is left
// over was added or removed.
export function diffHooks(
activeHooks: DocumentHook[],
targetHooks: DocumentHook[],
): HookDiffEntry[] {
const entries: HookDiffEntry[] = []
const unpairedTarget = byCreation(targetHooks)
const unpairedActive: DocumentHook[] = []
for (const hook of activeHooks) {
const index = unpairedTarget.findIndex(
(target) => sameSlot(hook, target) && !hasChanges(hook, target),
)
const target = unpairedTarget[index]
if (!target) {
unpairedActive.push(hook)
continue
}
unpairedTarget.splice(index, 1)
entries.push({
status: DiffStatus.Unchanged,
hook: hook,
targetHook: target,
})
}
for (const hook of byCreation(unpairedActive)) {
const index = unpairedTarget.findIndex((target) => sameSlot(hook, target))
const target = unpairedTarget[index]
if (!target) {
entries.push({ status: DiffStatus.Added, hook: hook, targetHook: null })
continue
}
unpairedTarget.splice(index, 1)
entries.push({
status: DiffStatus.Modified,
hook: hook,
targetHook: target,
})
}
for (const target of unpairedTarget) {
entries.push({ status: DiffStatus.Removed, hook: target, targetHook: null })
}
return entries
}
// hookChangeCount counts what changed between a hook and the target's
// version of it. A field counts as one change on each side, and a GitHub
// hook counts its tracked files one by one.
export function hookChangeCount(
hook: DocumentHook,
targetHook: DocumentHook,
): HookChangeCount {
switch (hook.type) {
case DocumentHookType.ScheduledReminder: {
const settings = hook.settings as DocumentHookSettingsScheduledReminder
const target =
targetHook.settings as DocumentHookSettingsScheduledReminder
return fieldCount(
new Date(settings.schedule).getTime() !==
new Date(target.schedule).getTime(),
)
}
case DocumentHookType.GitHubTracking: {
const settings = hook.settings as DocumentHookSettingsGitHubTracking
const target = targetHook.settings as DocumentHookSettingsGitHubTracking
const repository = fieldCount(settings.repository !== target.repository)
const branch = fieldCount(settings.branch !== target.branch)
return {
removed:
repository.removed +
branch.removed +
target.paths.filter((path) => !settings.paths.includes(path)).length,
added:
repository.added +
branch.added +
settings.paths.filter((path) => !target.paths.includes(path)).length,
}
}
case DocumentHookType.URLWatcher:
return fieldCount(
(hook.settings as DocumentHookSettingsURLWatcher).url !==
(targetHook.settings as DocumentHookSettingsURLWatcher).url,
)
case DocumentHookType.ContainerImageWatcher:
return fieldCount(
(hook.settings as DocumentHookSettingsContainerImageWatcher).image !==
(targetHook.settings as DocumentHookSettingsContainerImageWatcher)
.image,
)
}
}
// entryChangeCount counts one entry: a whole hook added or removed is one
// change, and a modified one counts its fields.
export function entryChangeCount(entry: HookDiffEntry): HookChangeCount {
switch (entry.status) {
case DiffStatus.Added:
return { removed: 0, added: 1 }
case DiffStatus.Removed:
return { removed: 1, added: 0 }
case DiffStatus.Modified:
return entry.targetHook
? hookChangeCount(entry.hook, entry.targetHook)
: { removed: 0, added: 0 }
default:
return { removed: 0, added: 0 }
}
}
// blockChangeCount adds up the changes to the hooks of one block, or of
// the whole page when blockId is null.
export function blockChangeCount(
entries: HookDiffEntry[],
blockId: string | null,
): HookChangeCount {
return entries
.filter((entry) => entry.hook.blockId === blockId)
.map(entryChangeCount)
.reduce(
(sum, count) => ({
removed: sum.removed + count.removed,
added: sum.added + count.added,
}),
{ removed: 0, added: 0 },
)
}
// scalarFieldRows lays out a field holding one value. A changed field shows
// its new value over its old one. targetValue is null outside a modified
// hook.
export function scalarFieldRows(
value: string,
targetValue: string | null,
): HookFieldRow[] {
if (targetValue === null || targetValue === value) {
return [{ value: value, status: DiffStatus.Unchanged }]
}
return [
{ value: value, status: DiffStatus.Added },
{ value: targetValue, status: DiffStatus.Removed },
]
}
// listFieldRows lays out a field holding a list. The values the active
// branch dropped follow the ones it holds. targetValues is null outside a
// modified hook.
export function listFieldRows(
values: string[],
targetValues: string[] | null,
): HookFieldRow[] {
if (!targetValues) {
return values.map((value) => ({
value: value,
status: DiffStatus.Unchanged,
}))
}
return [
...values.map((value) => ({
value: value,
status: targetValues.includes(value)
? DiffStatus.Unchanged
: DiffStatus.Added,
})),
...targetValues
.filter((value) => !values.includes(value))
.map((value) => ({ value: value, status: DiffStatus.Removed })),
]
}
function sameSlot(hook: DocumentHook, other: DocumentHook): boolean {
return hook.blockId === other.blockId && hook.type === other.type
}
function hasChanges(hook: DocumentHook, targetHook: DocumentHook): boolean {
const count = hookChangeCount(hook, targetHook)
return count.removed > 0 || count.added > 0
}
function byCreation(hooks: DocumentHook[]): DocumentHook[] {
return [...hooks].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
}
function fieldCount(changed: boolean): HookChangeCount {
return changed ? { removed: 1, added: 1 } : { removed: 0, added: 0 }
}

View File

@ -3,12 +3,14 @@ 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 { DiffStatus } from "../../diff/position-map"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
readonlyFields,
} from "../test-helpers"
import {
clearQueryCache,
@ -16,6 +18,7 @@ import {
ANY_STRING,
makeXid,
mockEndpoint,
runInApp,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
@ -33,7 +36,7 @@ const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const NEW_HOOK_LABEL = "editor.hooks.time-expiration.title"
const NEW_HOOK_LABEL = "editor.hooks.scheduled-reminder.title"
const SCHEDULE = new Date("2026-09-01T10:00:00Z")
function reminderHook(overrides: Partial<DocumentHook> = {}) {
@ -66,6 +69,8 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
@ -76,16 +81,26 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
})
it("names the date an active reminder will fire on", async ({ expect }) => {
it("names the date and time an active reminder will fire on", async ({
expect,
}) => {
await mountMenu({ hook: reminderHook() })
expect(menuText()).toContain("Remind on")
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.existing-item", {
value: formatSchedule(SCHEDULE),
}),
)
})
it("reports a reminder that has already fired", async ({ expect }) => {
it("reports when a triggered reminder went out", async ({ expect }) => {
await mountMenu({ hook: reminderHook({ score: "0" }) })
expect(menuText()).toContain("Reminded on")
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.triggered-item", {
value: formatSchedule(SCHEDULE),
}),
)
})
it("offers the preset durations", async ({ expect }) => {
@ -94,10 +109,10 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
await openHookSubMenu(t(NEW_HOOK_LABEL))
expect(menuText()).toContain(
t("editor.hooks.time-expiration.duration-label"),
t("editor.hooks.scheduled-reminder.duration-label"),
)
expect(menuText()).toContain(
t("editor.hooks.time-expiration.select-placeholder"),
t("editor.hooks.scheduled-reminder.select-placeholder"),
)
})
@ -147,7 +162,7 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
await pickDuration(wrapper, "custom")
expect(menuText()).toContain(
t("editor.hooks.time-expiration.calendar-placeholder"),
t("editor.hooks.scheduled-reminder.calendar-placeholder"),
)
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
@ -290,4 +305,132 @@ describe("<ScheduledReminderConfigMenu>", { concurrent: false }, () => {
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.for([
{
name: "an upcoming reminder on a block",
score: "100",
nodeId: "block-1",
label: "Remind on",
key: "editor.hooks.scheduled-reminder.existing-item-block-explanation",
},
{
name: "an upcoming reminder on the page",
score: "100",
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",
},
])(
"explains $name without a way to change it",
async ({ score, nodeId, label, key }, { expect }) => {
await mountMenu({
hook: reminderHook({ score: score }),
nodeId: nodeId,
})
await openHookSubMenu(label)
expect(menuText()).toContain(
t(key, { value: formatSchedule(SCHEDULE) }),
)
expect(readonlyFields()).toEqual([])
expect(menuText()).not.toContain(
t("editor.hooks.scheduled-reminder.duration-label-triggered"),
)
expect(menuText()).not.toContain(t("editor.hooks.renew"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
},
)
})
describe("when the diff is shown", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setReviewableDiffActive(true)
})
it("shows the new date over the old one", async ({ expect }) => {
const oldSchedule = new Date("2026-08-01T10:00:00Z")
await mountMenu({
hook: reminderHook(),
diff: {
status: DiffStatus.Modified,
targetHook: reminderHook({
settings: {
scale: "linear",
duration: "24h",
schedule: oldSchedule,
},
}),
signColumn: false,
},
})
await openHookSubMenu("Remind on")
expect(menuText()).toContain(
t("editor.hooks.scheduled-reminder.existing-item-block-explanation", {
value: formatSchedule(SCHEDULE),
}),
)
expect(readonlyFields()).toEqual([
[formatSchedule(SCHEDULE), "added"],
[formatSchedule(oldSchedule), "removed"],
])
})
})
})
function formatSchedule(date: Date): string {
return runInApp(() => useNuxtApp().$i18n.d(date, "short-with-time"))
}

View File

@ -2,9 +2,15 @@
import { type DateValue, getLocalTimeZone } from "@internationalized/date"
import { presetDurations } from "./durations"
import { showToastMessage } from "~/components/toast"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
// set only while the diff is shown
diff?: HookDiffContext | null
nodeId: string | null // null means global
}>()
const emit = defineEmits<{
@ -25,6 +31,12 @@ const hookData = computed(() => {
const { t } = useI18n({ useScope: "global" })
const editorStore = useEditorStore()
const documentHookAPI = useDocumentHookAPI()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = 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>(
@ -131,7 +143,7 @@ async function deleteHook() {
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<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"
@ -143,31 +155,31 @@ async function deleteHook() {
/>
</div>
<span v-if="!hookData">
{{ $t("editor.hooks.time-expiration.title") }}
{{ $t("editor.hooks.scheduled-reminder.title") }}
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.time-expiration.existing-item"
keypath="editor.hooks.scheduled-reminder.existing-item"
tag="span"
class="truncate"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short") }}
{{ $d(new Date(hookData.settings.schedule), "short-with-time") }}
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.time-expiration.triggered-item"
keypath="editor.hooks.scheduled-reminder.triggered-item"
tag="span"
class="truncate"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short") }}
{{ $d(new Date(hookData.settings.schedule), "short-with-time") }}
</template>
</i18n-t>
</ShadcnUiDropdownMenuSubTrigger>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
@ -177,76 +189,95 @@ async function deleteHook() {
]"
>
<div class="flex w-[12rem] flex-col">
<div class="flex flex-col gap-1 px-0.75 pb-0.75">
<template v-if="!hookData || hookData.score === 0">
<ShadcnUiSelect v-model="selectedDuration">
<ShadcnUiSelectLabel>
<span class="text-2sm">
{{
!hookData || hookData.score !== 0
? $t("editor.hooks.time-expiration.duration-label")
: $t(
"editor.hooks.time-expiration.duration-label-triggered",
)
}}
</span>
</ShadcnUiSelectLabel>
<ShadcnUiSelectTrigger class="w-full" size="custom">
<ShadcnUiSelectValue
class="text-2sm"
:placeholder="
$t(`editor.hooks.time-expiration.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.time-expiration.duration-options.${durationKey}`,
)
}}
</ShadcnUiSelectItem>
</ShadcnUiSelectContent>
</ShadcnUiSelect>
<CalendarInput
v-if="selectedDuration === 'custom'"
v-model="selectedSchedule"
:placeholder="
$t('editor.hooks.time-expiration.calendar-placeholder')
"
class="w-full"
available-from-tomorrow
/>
</template>
<template v-else>
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.time-expiration.existing-item-block-explanation'
: 'editor.hooks.time-expiration.existing-item-full-document-explanation'
"
tag="div"
class="w-full text-center text-xs text-muted-foreground"
<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"
>
<template #value>
{{ $d(new Date(hookData.settings.schedule), "short") }}
</template>
</i18n-t>
</template>
<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>
<ShadcnUiDropdownMenuSeparator />
<div class="p-0.75">
<!--
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"

View File

@ -86,3 +86,21 @@ export async function typeInMenu(value: string, index = 0) {
input.dispatchEvent(new Event("input", { bubbles: true }))
await nextTick()
}
// the read only fields in the menu bodies, each with the side of the diff
// its tint marks
export function readonlyFields(): [
string,
"added" | "removed" | "unchanged",
][] {
return Array.from(
document.body.querySelectorAll<HTMLInputElement>("input[readonly]"),
).map((input) => [
input.value,
input.classList.contains("bg-diff-field-added")
? "added"
: input.classList.contains("bg-diff-field-removed")
? "removed"
: "unchanged",
])
}

View File

@ -2,12 +2,14 @@ import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import { DiffStatus } from "../../diff/position-map"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
readonlyFields,
typeInMenu,
} from "../test-helpers"
import {
@ -57,6 +59,8 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorMeta().setEditable(true)
})
afterEach(disposeMockEndpoints)
@ -307,4 +311,83 @@ describe("<URLWatcherConfigMenu>", { concurrent: false }, () => {
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 }) => {
await mountMenu({ hook: urlHook() })
await openHookSubMenu("Watching oxynote.test")
expect(readonlyFields()).toEqual([
["https://oxynote.test/docs", "unchanged"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
expect(menuText()).not.toContain(t("editor.hooks.delete"))
})
it.for([
{
name: "a block",
nodeId: "block-1",
key: "editor.hooks.url-watcher.triggered-item-block-explanation",
},
{
name: "the page",
nodeId: null,
key: "editor.hooks.url-watcher.triggered-item-full-document-explanation",
},
])(
"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")
expect(menuText()).toContain(t(key))
},
)
})
describe("when the diff is shown", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setReviewableDiffActive(true)
})
it("shows the new url over the old one", async ({ expect }) => {
await mountMenu({
hook: urlHook(),
diff: {
status: DiffStatus.Modified,
targetHook: urlHook({ settings: { url: "https://old.test" } }),
signColumn: false,
},
})
await openHookSubMenu("Watching oxynote.test")
expect(menuText()).toContain(
t("editor.hooks.url-watcher.existing-item-block-explanation"),
)
expect(readonlyFields()).toEqual([
["https://oxynote.test/docs", "added"],
["https://old.test", "removed"],
])
expect(menuText()).not.toContain(t("editor.hooks.update"))
})
})
})

View File

@ -1,9 +1,15 @@
<script setup lang="ts">
import { showToastMessage } from "~/components/toast"
import HookInputField from "./HookInputField.vue"
import HookReadonlyField from "../HookReadonlyField.vue"
import HookSubTrigger from "../HookSubTrigger.vue"
import HookExplanation from "../HookExplanation.vue"
import { scalarFieldRows, type HookDiffContext } from "../hook-diff"
const props = defineProps<{
hook?: DocumentHook | null | undefined // null/undefined means creating new
// set only while the diff is shown
diff?: HookDiffContext | null
nodeId: string | null // null means global
}>()
const emit = defineEmits<{
@ -11,6 +17,12 @@ const emit = defineEmits<{
}>()
const editorStore = useEditorStore()
const { isReadOnlyOrDiff } = useEditorMeta()
const targetSettings = computed(
() =>
props.diff?.targetHook?.settings as
DocumentHookSettingsURLWatcher | undefined,
)
const { t } = useI18n({ useScope: "global" })
const hookData = computed(() => {
if (!props.hook) {
@ -143,7 +155,7 @@ async function resetHook() {
</script>
<template>
<ShadcnUiDropdownMenuSub v-model:open="isSubOpen">
<ShadcnUiDropdownMenuSubTrigger>
<HookSubTrigger :hook="props.hook" :diff="props.diff">
<div class="relative h-[0.8125rem] w-[0.8125rem] shrink-0">
<Icon
name="mingcute:earth-2-line"
@ -175,7 +187,7 @@ async function resetHook() {
{{ extractDomain(confirmedURL || "") }}
</template>
</i18n-t>
</ShadcnUiDropdownMenuSubTrigger>
</HookSubTrigger>
<ShadcnUiDropdownMenuSubContent
side="right"
align="start"
@ -201,29 +213,27 @@ async function resetHook() {
</i18n-t>
<ShadcnUiDropdownMenuSeparator />
</template>
<template
v-if="
hookData &&
hookData.score !== 0 &&
hookData.state.status === 'active'
"
>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.url-watcher.existing-item-block-explanation'
: 'editor.hooks.url-watcher.existing-item-full-document-explanation'
"
tag="div"
class="w-full text-center text-xs break-words text-muted-foreground"
/>
</div>
<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')"
>
@ -234,8 +244,8 @@ async function resetHook() {
</template>
</HookInputField>
</div>
<ShadcnUiDropdownMenuSeparator />
<div class="p-0.75">
<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

View File

@ -9,6 +9,7 @@ describe("useEditorMeta", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setActiveBranchProtected(false)
useEditorStore().updateMainAreaWidth(1400)
useEditorStore().setReviewableDiffActive(false)
})
it("toggles the editable flag", ({ expect }) => {
@ -51,6 +52,27 @@ describe("useEditorMeta", { concurrent: false }, () => {
expect(meta.isBranchProtected.value).toBe(false)
})
it.for([
{
name: "editable outside the diff",
editable: true,
diff: false,
expected: false,
},
{ name: "read only", editable: false, diff: false, expected: true },
{ name: "showing the diff", editable: true, diff: true, expected: true },
])(
"disables editing when $name is $expected",
({ editable, diff, expected }, { expect }) => {
const meta = useEditorMeta()
meta.setEditable(editable)
useEditorStore().setReviewableDiffActive(diff)
expect(meta.isReadOnlyOrDiff.value).toBe(expected)
},
)
it("locks the editor through the store", ({ expect }) => {
const meta = useEditorMeta()

View File

@ -38,6 +38,11 @@ export default function () {
return {
isEditable,
// read only, or showing the diff, where two branches render at once
// and neither can be edited
isReadOnlyOrDiff: computed(
() => !isEditable.value || editorStore.reviewableDiffActive,
),
toggleIsEditable,
setEditable,
isCompactView: computed(

View File

@ -146,7 +146,8 @@ export default withNuxt([
// never reported unused.
ignores: [
// dynamically built keys: t(`ns.${x}`)
"/^editor\\.hooks\\.time-expiration\\.duration-options\\./",
"/^editor\\.hooks\\.[a-z-]+\\.(existing|triggered)-item-(block|full-document)-explanation$/",
"/^editor\\.hooks\\.scheduled-reminder\\.duration-options\\./",
"/^editor\\.metrics\\.config\\.modal-title-diff-/",
"/^editor\\.metrics\\.config\\.query-placeholder\\./",
"/^editor\\.metrics\\.config\\.refresh-interval-options(-short)?\\./",
@ -160,7 +161,6 @@ export default withNuxt([
// literal keys kept in data structures and passed to
// t() later (the rule only tracks direct call sites)
"/^editor\\.ai-chat\\.tool-status\\./",
"/^editor\\.hooks\\.[a-z-]+\\.existing-item-(block|full-document)-explanation$/",
"/^editor\\.slash-commands\\.items\\./",
"/^onboarding\\.signup\\.conditions\\.(main|terms-only|privacy-only)$/",
"/^shortcuts\\.groups\\./",

View File

@ -342,6 +342,14 @@
},
"hooks": {
"add-new": "New freshness hook",
"empty-block": "This block has no freshness hooks",
"empty-document": "There are no page-wide freshness hooks",
"diff": {
"added": "added",
"removed": "removed",
"added-sign": "+",
"removed-sign": "−"
},
"create": "Confirm",
"update": "Update",
"renew": "Renew",
@ -354,16 +362,19 @@
"reset-failed": "Failed to approve the hook. Please try again.",
"renew-failed": "Failed to renew the hook. Please try again."
},
"time-expiration": {
"scheduled-reminder": {
"title": "Scheduled Reminder",
"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.",
"duration-options": {
"0": "Instant (dev)",
"1m": "In 1 minute (dev)",
@ -383,6 +394,8 @@
"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.",
"not-connected": {
"main": "{icon}To use this hook, connect your GitHub organization or personal account in the {placeholder}.",
"placeholder": "settings"
@ -414,6 +427,8 @@
"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.",
"url-input-label": "Link to Website",
"url-input-placeholder": "https://example.com"
@ -424,6 +439,8 @@
"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.",
"image-input-label": "Image Reference",
"image-input-placeholder": "postgres:16.4-alpine"