mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add hook diff
This commit is contained in:
parent
766bef1cab
commit
48e6652d4b
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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="{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
106
web/app/components/editor/diff/DiffCountPill.nuxt.test.ts
Normal file
106
web/app/components/editor/diff/DiffCountPill.nuxt.test.ts
Normal 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())
|
||||
}
|
||||
66
web/app/components/editor/diff/DiffCountPill.vue
Normal file
66
web/app/components/editor/diff/DiffCountPill.vue
Normal 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>
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
141
web/app/components/editor/hooks/BlockHookDiffMarkers.vue
Normal file
141
web/app/components/editor/hooks/BlockHookDiffMarkers.vue
Normal 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>
|
||||
80
web/app/components/editor/hooks/HookDiffMarker.nuxt.test.ts
Normal file
80
web/app/components/editor/hooks/HookDiffMarker.nuxt.test.ts
Normal 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")
|
||||
}
|
||||
63
web/app/components/editor/hooks/HookDiffMarker.vue
Normal file
63
web/app/components/editor/hooks/HookDiffMarker.vue
Normal 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>
|
||||
60
web/app/components/editor/hooks/HookExplanation.nuxt.test.ts
Normal file
60
web/app/components/editor/hooks/HookExplanation.nuxt.test.ts
Normal 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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
29
web/app/components/editor/hooks/HookExplanation.vue
Normal file
29
web/app/components/editor/hooks/HookExplanation.vue
Normal 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>
|
||||
|
|
@ -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']"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
34
web/app/components/editor/hooks/HookReadonlyField.vue
Normal file
34
web/app/components/editor/hooks/HookReadonlyField.vue
Normal 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>
|
||||
149
web/app/components/editor/hooks/HookSubTrigger.nuxt.test.ts
Normal file
149
web/app/components/editor/hooks/HookSubTrigger.nuxt.test.ts
Normal 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 }
|
||||
}
|
||||
79
web/app/components/editor/hooks/HookSubTrigger.vue
Normal file
79
web/app/components/editor/hooks/HookSubTrigger.vue
Normal 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>
|
||||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
358
web/app/components/editor/hooks/hook-diff.test.ts
Normal file
358
web/app/components/editor/hooks/hook-diff.test.ts
Normal 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
|
||||
}
|
||||
235
web/app/components/editor/hooks/hook-diff.ts
Normal file
235
web/app/components/editor/hooks/hook-diff.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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\\./",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user