mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add diff popover
This commit is contained in:
parent
20d87cf8a3
commit
998fb29c46
|
|
@ -93,6 +93,13 @@ there when none exists.
|
|||
(custom nodes; `upload-handler.ts` routes dropped files), `comments/`,
|
||||
`diff/`, `drag-handle/`, `slash/`, `link/`, `ai/`, `hooks/`. Editor-wide
|
||||
state is [app/stores/editor.ts](app/stores/editor.ts).
|
||||
- **Diff view**: adding or changing a block or node includes its branch
|
||||
diff. Expose `diffStatus` on its node view and style it as added, removed
|
||||
and modified. A block changed through its attributes renders
|
||||
`DiffChangeMarker` with a `<Block>DiffDetails.vue` card in its own
|
||||
directory. Attributes that count as one change, count per list item or
|
||||
are never set by a user are declared in
|
||||
[diff/change-count.ts](app/components/editor/diff/change-count.ts).
|
||||
- **Routing**: one dynamic page,
|
||||
`app/pages/[[organizationSlug]]/[[documentSlug]].vue`;
|
||||
[app/middleware/01.redirect.global.ts](app/middleware/01.redirect.global.ts)
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@
|
|||
// editor modules
|
||||
|
||||
export const NODE_COMMENT_ID_ATTR = "nodeCommentId"
|
||||
|
||||
// set on image and file blocks while their upload is in flight
|
||||
export const UPLOADING_ATTR = "uploading"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { computePosition, flip, offset, shift } from "@floating-ui/dom"
|
|||
import { cn } from "~/lib/utils"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
|
||||
import FigmaDiffDetails from "./FigmaDiffDetails.vue"
|
||||
import { isFigmaUrl, convertToEmbedUrl } from "./index"
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
|
@ -262,7 +263,9 @@ onBeforeUnmount(() => {
|
|||
:style="{ height: `${displayHeight}px` }"
|
||||
allowfullscreen
|
||||
/>
|
||||
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5" />
|
||||
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5">
|
||||
<FigmaDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<div
|
||||
v-if="!isEditingDisabled"
|
||||
aria-hidden="true"
|
||||
|
|
@ -295,7 +298,9 @@ onBeforeUnmount(() => {
|
|||
<DiffChangeMarker
|
||||
:node="props.node"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
/>
|
||||
>
|
||||
<FigmaDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<span class="mt-0.25 text-2sm text-muted-foreground">
|
||||
{{
|
||||
isEditingDisabled
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import FigmaDiffDetails from "./FigmaDiffDetails.vue"
|
||||
import { makeNode } from "../../test-helpers/node-view"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import { t } from "~/components/test-helpers"
|
||||
|
||||
const OLD_URL = "https://www.figma.com/design/abc123/Checkout-Flow"
|
||||
const NEW_URL = "https://www.figma.com/design/xyz789/Sign%20up-Screen"
|
||||
|
||||
describe("<FigmaDiffDetails>", () => {
|
||||
it("names the old and the new file from their URLs", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({ src: OLD_URL }, { src: NEW_URL })
|
||||
|
||||
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
|
||||
"Checkout Flow",
|
||||
"Sign up Screen",
|
||||
])
|
||||
expect(wrapper.text()).toContain(OLD_URL)
|
||||
})
|
||||
|
||||
it("keeps the raw text of a URL it cannot parse", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({}, { src: "not a url" })
|
||||
|
||||
expect(wrapper.get(".font-medium").text()).toBe("not a url")
|
||||
expect(wrapper.text()).toContain(t("editor.figma.empty"))
|
||||
})
|
||||
|
||||
it("shows auto for a size that was not set before", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ src: OLD_URL },
|
||||
{ src: OLD_URL, height: 300 },
|
||||
)
|
||||
|
||||
expect(wrapper.get(".line-through").text()).toBe(
|
||||
t("editor.diff-change-marker.auto"),
|
||||
)
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
|
||||
t("editor.diff-change-marker.pixels", { value: "300" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("lists a resize as rows", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ src: OLD_URL, width: 400, height: 300 },
|
||||
{ src: OLD_URL, width: 600, height: 300 },
|
||||
)
|
||||
|
||||
expect(wrapper.findAll("dt").map((dt) => dt.text())).toEqual([
|
||||
t("editor.figma.diff.width"),
|
||||
])
|
||||
expect(wrapper.find(".font-medium").exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function mountDetails(
|
||||
oldAttrs: Record<string, unknown>,
|
||||
newAttrs: Record<string, unknown>,
|
||||
) {
|
||||
return mountSuspended(FigmaDiffDetails, {
|
||||
props: {
|
||||
node: makeNode({
|
||||
...newAttrs,
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: { attrs: oldAttrs },
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
94
web/app/components/editor/blocks/figma/FigmaDiffDetails.vue
Normal file
94
web/app/components/editor/blocks/figma/FigmaDiffDetails.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import {
|
||||
collectAttributeChanges,
|
||||
displayValue,
|
||||
formatChange,
|
||||
type FieldChange,
|
||||
} from "~/components/editor/diff/change-count"
|
||||
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
|
||||
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
|
||||
const changes = computed(() => collectAttributeChanges(props.node))
|
||||
const srcChange = computed(() =>
|
||||
changes.value.find((change) => change.name === "src"),
|
||||
)
|
||||
const rows = computed<FieldChange[]>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
width: t("editor.figma.diff.width"),
|
||||
height: t("editor.figma.diff.height"),
|
||||
}
|
||||
|
||||
return changes.value.flatMap((change) => {
|
||||
const label = labels[change.name]
|
||||
|
||||
return label
|
||||
? [
|
||||
{
|
||||
label: label,
|
||||
...formatChange(
|
||||
change,
|
||||
pixels,
|
||||
t("editor.diff-change-marker.auto"),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
})
|
||||
})
|
||||
|
||||
function pixels(value: unknown): string {
|
||||
return t("editor.diff-change-marker.pixels", { value: displayValue(value) })
|
||||
}
|
||||
|
||||
// a figma URL ends in a slug of the file's name, such as
|
||||
// /design/<key>/Checkout-Flow. The raw URL stands in when it has none.
|
||||
function describe(src: unknown) {
|
||||
if (typeof src !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let name = src
|
||||
try {
|
||||
const slug = new URL(src).pathname.split("/").filter(Boolean)[2]
|
||||
if (slug) {
|
||||
name = decodeURIComponent(slug).replaceAll("-", " ")
|
||||
}
|
||||
} catch {
|
||||
// an unparsable URL keeps its raw text as the name
|
||||
}
|
||||
|
||||
return { name: name, src: src }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-80 flex-col gap-3">
|
||||
<DiffBeforeAfter
|
||||
v-if="srcChange"
|
||||
v-slot="{ value }"
|
||||
:before="describe(srcChange.oldValue)"
|
||||
:after="describe(srcChange.newValue)"
|
||||
:empty-label="t('editor.figma.empty')"
|
||||
>
|
||||
<div class="flex w-full min-w-0 items-center gap-2 p-2">
|
||||
<Icon name="simple-icons:figma" class="size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="line-clamp-6 text-xs font-medium break-words">
|
||||
{{ value.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs text-muted-foreground">
|
||||
{{ value.src }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</DiffBeforeAfter>
|
||||
<DiffFieldChanges v-if="rows.length" :rows="rows" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -381,10 +381,14 @@ describe("<FileBlock>", { concurrent: false }, () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const label = t("editor.diff-change-marker.label", {
|
||||
removed: 1,
|
||||
added: 1,
|
||||
})
|
||||
expect(wrapper.find(".diff-overlay").exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain(
|
||||
t("editor.diff-change-marker.label", { removed: 1, added: 1 }),
|
||||
)
|
||||
expect(wrapper.text()).toContain(label)
|
||||
// hovering a marker inside the card would set off the card's hover
|
||||
expect(wrapper.get("a").text()).not.toContain(label)
|
||||
})
|
||||
|
||||
it("shows no diff overlay on an unchanged file", async ({ expect }) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { ProgressToast } from "~/components/toast"
|
|||
import { cn } from "~/lib/utils"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
|
||||
import FileDiffDetails from "./FileDiffDetails.vue"
|
||||
import {
|
||||
fileKind,
|
||||
fileKindStyle,
|
||||
|
|
@ -175,7 +176,7 @@ async function downloadOnDesktop() {
|
|||
<NodeViewWrapper
|
||||
:id="props.node.attrs.uid"
|
||||
as="figure"
|
||||
class="not-prose flex caret-transparent"
|
||||
class="not-prose relative flex caret-transparent"
|
||||
data-type="fileBlock"
|
||||
:data-uid="props.node.attrs.uid"
|
||||
:data-node-comment-id="props.node.attrs.nodeCommentId"
|
||||
|
|
@ -233,10 +234,6 @@ async function downloadOnDesktop() {
|
|||
{{ size }}
|
||||
</span>
|
||||
</span>
|
||||
<DiffChangeMarker
|
||||
:node="props.node"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
/>
|
||||
</component>
|
||||
<button
|
||||
v-else
|
||||
|
|
@ -252,10 +249,6 @@ async function downloadOnDesktop() {
|
|||
@click="openFilePicker"
|
||||
>
|
||||
<Icon name="mingcute:attachment-line" class="size-4 text-foreground" />
|
||||
<DiffChangeMarker
|
||||
:node="props.node"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
/>
|
||||
<div class="mt-0.25 text-2sm text-muted-foreground">
|
||||
{{
|
||||
uploading
|
||||
|
|
@ -275,5 +268,13 @@ async function downloadOnDesktop() {
|
|||
@change="handleFileChange"
|
||||
/>
|
||||
</button>
|
||||
<!-- a sibling of the card rather than its child, so hovering the
|
||||
marker does not trigger the card's hover style -->
|
||||
<DiffChangeMarker
|
||||
:node="props.node"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
>
|
||||
<FileDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import FileDiffDetails from "./FileDiffDetails.vue"
|
||||
import { makeNode } from "../../test-helpers/node-view"
|
||||
import { FILE_BLOCK_NAME } from "../node-names"
|
||||
import { formatFileSize } from "./file-kind"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import { t } from "~/components/test-helpers"
|
||||
|
||||
describe("<FileDiffDetails>", () => {
|
||||
it("shows the old and the new file side by side", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(storedFile("a.zip"), storedFile("b.pdf"))
|
||||
|
||||
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
|
||||
"a.zip",
|
||||
"b.pdf",
|
||||
])
|
||||
expect(wrapper.text()).toContain(formatFileSize(1024))
|
||||
})
|
||||
|
||||
it("shows an empty side for a newly uploaded file", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({}, storedFile("b.pdf"))
|
||||
|
||||
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
|
||||
"b.pdf",
|
||||
])
|
||||
expect(wrapper.text()).toContain(t("editor.file.empty"))
|
||||
})
|
||||
|
||||
it("renders nothing when the file itself is unchanged", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ ...storedFile("a.zip"), uploading: true },
|
||||
{ ...storedFile("a.zip"), uploading: false },
|
||||
)
|
||||
|
||||
expect(wrapper.html()).toBe("<!--v-if-->")
|
||||
})
|
||||
})
|
||||
|
||||
function storedFile(name: string): Record<string, unknown> {
|
||||
return {
|
||||
src: `/files/${name}`,
|
||||
name: name,
|
||||
size: 1024,
|
||||
contentType: "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
function mountDetails(
|
||||
oldAttrs: Record<string, unknown>,
|
||||
newAttrs: Record<string, unknown>,
|
||||
) {
|
||||
return mountSuspended(FileDiffDetails, {
|
||||
props: {
|
||||
node: makeNode(
|
||||
{
|
||||
...newAttrs,
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: { attrs: oldAttrs },
|
||||
},
|
||||
{ typeName: FILE_BLOCK_NAME },
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
64
web/app/components/editor/blocks/file/FileDiffDetails.vue
Normal file
64
web/app/components/editor/blocks/file/FileDiffDetails.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import { collectAttributeChanges } from "~/components/editor/diff/change-count"
|
||||
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
|
||||
import { fileKind, fileKindStyle, formatFileSize } from "./file-kind"
|
||||
|
||||
interface StoredFile {
|
||||
name?: string
|
||||
size?: number
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
|
||||
// the file's attributes change together, so they arrive as one group
|
||||
const fileChange = computed(() =>
|
||||
collectAttributeChanges(props.node).find((change) => change.name === "file"),
|
||||
)
|
||||
|
||||
function describe(value: unknown) {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const file = value as StoredFile
|
||||
|
||||
return {
|
||||
name: file.name ?? "",
|
||||
size: formatFileSize(file.size),
|
||||
icon: fileKindStyle(fileKind(file.name ?? "", file.contentType ?? "")).icon,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiffBeforeAfter
|
||||
v-if="fileChange"
|
||||
v-slot="{ value }"
|
||||
:before="describe(fileChange.oldValue)"
|
||||
:after="describe(fileChange.newValue)"
|
||||
:empty-label="t('editor.file.empty')"
|
||||
class="w-80"
|
||||
>
|
||||
<div class="flex w-full min-w-0 items-center gap-2 p-2">
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<Icon :name="value.icon" class="size-4" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="line-clamp-6 text-xs font-medium break-words">
|
||||
{{ value.name }}
|
||||
</span>
|
||||
<span v-if="value.size" class="text-xs text-muted-foreground">
|
||||
{{ value.size }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</DiffBeforeAfter>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@ import { showToastMessage } from "~/components/toast"
|
|||
import { cn } from "~/lib/utils"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
|
||||
import ImageDiffDetails from "./ImageDiffDetails.vue"
|
||||
|
||||
const MIN_WIDTH = 128
|
||||
|
||||
|
|
@ -248,7 +249,9 @@ onBeforeUnmount(() => {
|
|||
]"
|
||||
draggable="false"
|
||||
/>
|
||||
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5" />
|
||||
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5">
|
||||
<ImageDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<div
|
||||
v-if="showResizeHandle"
|
||||
aria-hidden="true"
|
||||
|
|
@ -275,7 +278,9 @@ onBeforeUnmount(() => {
|
|||
<DiffChangeMarker
|
||||
:node="props.node"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
/>
|
||||
>
|
||||
<ImageDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<div class="mt-0.25 text-2sm text-muted-foreground">
|
||||
{{
|
||||
uploading
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import ImageDiffDetails from "./ImageDiffDetails.vue"
|
||||
import { makeNode } from "../../test-helpers/node-view"
|
||||
import { IMAGE_BLOCK_NAME } from "../node-names"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import { t } from "~/components/test-helpers"
|
||||
|
||||
describe("<ImageDiffDetails>", () => {
|
||||
it("shows the old and the new image side by side", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({ src: "a.png" }, { src: "b.png" })
|
||||
|
||||
expect(wrapper.findAll("img").map((img) => img.attributes("src"))).toEqual([
|
||||
"a.png",
|
||||
"b.png",
|
||||
])
|
||||
})
|
||||
|
||||
it("shows an empty side for a newly uploaded image", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({}, { src: "b.png" })
|
||||
|
||||
expect(wrapper.findAll("img").map((img) => img.attributes("src"))).toEqual([
|
||||
"b.png",
|
||||
])
|
||||
expect(wrapper.text()).toContain(t("editor.image.empty"))
|
||||
})
|
||||
|
||||
it("lists a resize as a row", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ src: "a.png", width: 300 },
|
||||
{ src: "a.png", width: 480 },
|
||||
)
|
||||
|
||||
expect(wrapper.get("dt").text()).toBe(t("editor.image.diff.width"))
|
||||
expect(wrapper.get(".line-through").text()).toBe(
|
||||
t("editor.diff-change-marker.pixels", { value: "300" }),
|
||||
)
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
|
||||
t("editor.diff-change-marker.pixels", { value: "480" }),
|
||||
)
|
||||
expect(wrapper.find("img").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("shows auto for a width that was not set before", async ({ expect }) => {
|
||||
const wrapper = await mountDetails({}, { src: "b.png", width: 492 })
|
||||
|
||||
expect(wrapper.get(".line-through").text()).toBe(
|
||||
t("editor.diff-change-marker.auto"),
|
||||
)
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
|
||||
t("editor.diff-change-marker.pixels", { value: "492" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("shows nothing for attributes no one can change", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ src: "a.png", alt: "a cat", uploading: true },
|
||||
{ src: "a.png", alt: "a dog", uploading: false },
|
||||
)
|
||||
|
||||
expect(wrapper.find("dl").exists()).toBe(false)
|
||||
expect(wrapper.find("img").exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function mountDetails(
|
||||
oldAttrs: Record<string, unknown>,
|
||||
newAttrs: Record<string, unknown>,
|
||||
) {
|
||||
return mountSuspended(ImageDiffDetails, {
|
||||
props: {
|
||||
node: makeNode(
|
||||
{
|
||||
...newAttrs,
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: { attrs: oldAttrs },
|
||||
},
|
||||
{ typeName: IMAGE_BLOCK_NAME },
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
57
web/app/components/editor/blocks/image/ImageDiffDetails.vue
Normal file
57
web/app/components/editor/blocks/image/ImageDiffDetails.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import {
|
||||
collectAttributeChanges,
|
||||
displayValue,
|
||||
formatChange,
|
||||
type FieldChange,
|
||||
} from "~/components/editor/diff/change-count"
|
||||
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
|
||||
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
|
||||
const changes = computed(() => collectAttributeChanges(props.node))
|
||||
const srcChange = computed(() =>
|
||||
changes.value.find((change) => change.name === "src"),
|
||||
)
|
||||
const rows = computed<FieldChange[]>(() =>
|
||||
changes.value
|
||||
.filter((change) => change.name === "width")
|
||||
.map((change) => ({
|
||||
label: t("editor.image.diff.width"),
|
||||
...formatChange(change, pixels, t("editor.diff-change-marker.auto")),
|
||||
})),
|
||||
)
|
||||
|
||||
function pixels(value: unknown): string {
|
||||
return t("editor.diff-change-marker.pixels", { value: displayValue(value) })
|
||||
}
|
||||
|
||||
function imageSrc(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-72 flex-col gap-3">
|
||||
<DiffBeforeAfter
|
||||
v-if="srcChange"
|
||||
v-slot="{ value }"
|
||||
:before="imageSrc(srcChange.oldValue)"
|
||||
:after="imageSrc(srcChange.newValue)"
|
||||
:empty-label="t('editor.image.empty')"
|
||||
>
|
||||
<img
|
||||
:src="value"
|
||||
:alt="t('editor.image.diff.image')"
|
||||
class="h-20 w-full object-cover"
|
||||
/>
|
||||
</DiffBeforeAfter>
|
||||
<DiffFieldChanges v-if="rows.length" :rows="rows" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -4,6 +4,7 @@ import { cn } from "~/lib/utils"
|
|||
import { explicitContentPlaceholder } from "../../placeholder"
|
||||
import { DiffStatus } from "../../diff/position-map"
|
||||
import DiffChangeMarker from "../../diff/DiffChangeMarker.vue"
|
||||
import MermaidDiffDetails from "./MermaidDiffDetails.vue"
|
||||
import MermaidPreview from "./MermaidPreview.vue"
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
|
@ -119,7 +120,9 @@ watch(showCode, () => {
|
|||
{{ lastOtherEditingUser?.name }}
|
||||
</div>
|
||||
<div class="absolute top-1 right-1.5 z-1 flex items-center gap-1">
|
||||
<DiffChangeMarker :node="props.node" />
|
||||
<DiffChangeMarker :node="props.node">
|
||||
<MermaidDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<ShadcnUiButton
|
||||
variant="ghost-plain"
|
||||
size="icon-sm"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { Schema, type Node as PMNode } from "@tiptap/pm/model"
|
||||
import { describe, it } from "vitest"
|
||||
import MermaidDiffDetails from "./MermaidDiffDetails.vue"
|
||||
import {
|
||||
DIFF_TEXT_ADDED_MARK_NAME,
|
||||
DIFF_TEXT_REMOVED_MARK_NAME,
|
||||
} from "~/components/editor/mark-names"
|
||||
import { t } from "~/components/test-helpers"
|
||||
|
||||
const ADDED = DIFF_TEXT_ADDED_MARK_NAME
|
||||
const REMOVED = DIFF_TEXT_REMOVED_MARK_NAME
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "code" },
|
||||
code: { content: "text*", marks: "_" },
|
||||
text: {},
|
||||
},
|
||||
marks: {
|
||||
[ADDED]: {},
|
||||
[REMOVED]: {},
|
||||
},
|
||||
})
|
||||
|
||||
describe("<MermaidDiffDetails>", () => {
|
||||
it("shows only the changed lines, numbered, with their changes marked", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountDetails([
|
||||
["graph TD\nA-->"],
|
||||
["B", REMOVED],
|
||||
["C", ADDED],
|
||||
["\nC-->D"],
|
||||
])
|
||||
|
||||
expect(numbers(wrapper)).toEqual(["2"])
|
||||
// the raw text content, since text() would trim a stray space away
|
||||
expect(wrapper.get(".break-all").element.textContent).toBe("A-->BC")
|
||||
expect(wrapper.get(".line-through").text()).toBe("B")
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe("C")
|
||||
})
|
||||
|
||||
it("strips the indentation the shown lines share", async ({ expect }) => {
|
||||
const wrapper = await mountDetails([
|
||||
["flowchart TD\n A-->"],
|
||||
["B", REMOVED],
|
||||
["C", ADDED],
|
||||
])
|
||||
|
||||
expect(wrapper.get(".break-all").element.textContent).toBe("A-->BC")
|
||||
})
|
||||
|
||||
it("separates changed lines that are not neighbours", async ({ expect }) => {
|
||||
const wrapper = await mountDetails([
|
||||
["one\n"],
|
||||
["two", ADDED],
|
||||
["\nthree\n"],
|
||||
["four", ADDED],
|
||||
])
|
||||
|
||||
expect(numbers(wrapper)).toEqual(["2", "4"])
|
||||
expect(wrapper.findAll(".border-dashed")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("counts the changed lines it leaves out", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
Array.from({ length: 12 }, (_, index) => [`\nline ${index}`, ADDED]),
|
||||
)
|
||||
|
||||
expect(numbers(wrapper)).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain(
|
||||
t("editor.mermaid.diff.more-lines", { count: 3 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function numbers(wrapper: Awaited<ReturnType<typeof mountDetails>>) {
|
||||
return wrapper.findAll(".select-none").map((cell) => cell.text())
|
||||
}
|
||||
|
||||
function mountDetails(parts: string[][]) {
|
||||
const node: PMNode = schema.nodes.code.create(
|
||||
null,
|
||||
parts.map(([text, ...marks]) =>
|
||||
schema.text(
|
||||
text ?? "",
|
||||
marks.map((mark) => schema.mark(mark)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return mountSuspended(MermaidDiffDetails, { props: { node: node } })
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import { cn } from "~/lib/utils"
|
||||
import {
|
||||
TextSegmentKind,
|
||||
textSegments,
|
||||
} from "~/components/editor/diff/change-count"
|
||||
import { dedentLines, diffLines } from "./diff-lines"
|
||||
|
||||
const MAX_LINES = 10
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
||||
const changedLines = computed(() =>
|
||||
diffLines(textSegments(props.node))
|
||||
.map((line, index) => ({ ...line, index: index }))
|
||||
.filter((line) => line.changed),
|
||||
)
|
||||
const shownLines = computed(() =>
|
||||
dedentLines(changedLines.value.slice(0, MAX_LINES)).map(
|
||||
(line, position, shown) => ({
|
||||
...line,
|
||||
// a gap sits between two changed lines that are not neighbours
|
||||
gapBefore:
|
||||
position > 0 && line.index - (shown[position - 1]?.index ?? 0) > 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const hiddenCount = computed(() =>
|
||||
Math.max(changedLines.value.length - MAX_LINES, 0),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex max-w-[26rem] flex-col gap-1.5">
|
||||
<!-- the number column is as wide as its widest number -->
|
||||
<div
|
||||
class="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-1.5 gap-y-1.5 font-mono text-xs"
|
||||
>
|
||||
<template v-for="line in shownLines" :key="line.index">
|
||||
<!-- the negative margin reaches the popover's padding, so the
|
||||
separator runs from one edge of the card to the other -->
|
||||
<hr
|
||||
v-if="line.gapBefore"
|
||||
class="col-span-2 -mx-3 my-1.5 border-t border-dashed border-border"
|
||||
/>
|
||||
<span class="text-muted-foreground select-none">
|
||||
{{ line.number }}
|
||||
</span>
|
||||
<!-- only the segments keep their whitespace, so the template's own
|
||||
line breaks around them do not show as spaces -->
|
||||
<span class="break-all">
|
||||
<span
|
||||
v-for="(segment, i) in line.segments"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'whitespace-pre-wrap',
|
||||
segment.kind === TextSegmentKind.Removed &&
|
||||
'bg-diff-text-removed line-through',
|
||||
segment.kind === TextSegmentKind.Added && 'bg-diff-text-added',
|
||||
)
|
||||
"
|
||||
v-text="segment.text"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span v-if="hiddenCount" class="text-xs text-muted-foreground">
|
||||
{{ $t("editor.mermaid.diff.more-lines", { count: hiddenCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
137
web/app/components/editor/blocks/mermaid/diff-lines.test.ts
Normal file
137
web/app/components/editor/blocks/mermaid/diff-lines.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it } from "vitest"
|
||||
import { dedentLines, diffLines } from "./diff-lines"
|
||||
import { TextSegmentKind } from "~/components/editor/diff/change-count"
|
||||
|
||||
const { Equal, Removed, Added } = TextSegmentKind
|
||||
|
||||
describe("diffLines", () => {
|
||||
it("splits segments into numbered lines and flags the changed ones", ({
|
||||
expect,
|
||||
}) => {
|
||||
const lines = diffLines([
|
||||
{ kind: Equal, text: "graph TD\nA-->" },
|
||||
{ kind: Removed, text: "B" },
|
||||
{ kind: Added, text: "C" },
|
||||
{ kind: Equal, text: "\nC-->D" },
|
||||
])
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
number: 1,
|
||||
changed: false,
|
||||
segments: [{ kind: Equal, text: "graph TD" }],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
changed: true,
|
||||
segments: [
|
||||
{ kind: Equal, text: "A-->" },
|
||||
{ kind: Removed, text: "B" },
|
||||
{ kind: Added, text: "C" },
|
||||
],
|
||||
},
|
||||
{ number: 3, changed: false, segments: [{ kind: Equal, text: "C-->D" }] },
|
||||
])
|
||||
})
|
||||
|
||||
it("leaves a line of only removed text unnumbered", ({ expect }) => {
|
||||
const lines = diffLines([
|
||||
{ kind: Equal, text: "graph TD" },
|
||||
{ kind: Removed, text: "\nA-->B" },
|
||||
{ kind: Equal, text: "\nB-->C" },
|
||||
])
|
||||
|
||||
expect(lines.map((line) => [line.number, line.changed])).toEqual([
|
||||
[1, true],
|
||||
[null, true],
|
||||
[2, false],
|
||||
])
|
||||
})
|
||||
|
||||
it("numbers an added line by its place in the new text", ({ expect }) => {
|
||||
const lines = diffLines([
|
||||
{ kind: Equal, text: "graph TD" },
|
||||
{ kind: Added, text: "\nA-->B" },
|
||||
])
|
||||
|
||||
expect(lines.map((line) => [line.number, line.changed])).toEqual([
|
||||
[1, true],
|
||||
[2, true],
|
||||
])
|
||||
})
|
||||
|
||||
it("flags the line whose newline was removed", ({ expect }) => {
|
||||
const lines = diffLines([
|
||||
{ kind: Equal, text: "A-->B" },
|
||||
{ kind: Removed, text: "\n" },
|
||||
{ kind: Equal, text: "B-->C" },
|
||||
])
|
||||
|
||||
expect(lines.map((line) => [line.number, line.changed])).toEqual([
|
||||
[1, true],
|
||||
[1, false],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("dedentLines", () => {
|
||||
it("strips the indentation every line shares", ({ expect }) => {
|
||||
const lines = dedentLines(
|
||||
diffLines([
|
||||
{ kind: Equal, text: " B-->" },
|
||||
{ kind: Removed, text: "C" },
|
||||
{ kind: Equal, text: "\n D-->" },
|
||||
{ kind: Added, text: "E" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(lines.map((line) => line.segments)).toEqual([
|
||||
[
|
||||
{ kind: Equal, text: "B-->" },
|
||||
{ kind: Removed, text: "C" },
|
||||
],
|
||||
[
|
||||
{ kind: Equal, text: " D-->" },
|
||||
{ kind: Added, text: "E" },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps an indent that is itself a change", ({ expect }) => {
|
||||
const lines = diffLines([
|
||||
{ kind: Equal, text: " B-->C\n" },
|
||||
{ kind: Added, text: " D-->E" },
|
||||
])
|
||||
|
||||
expect(dedentLines(lines)).toEqual(lines)
|
||||
})
|
||||
|
||||
it("ignores empty lines when finding the shared indent", ({ expect }) => {
|
||||
const lines = dedentLines(
|
||||
diffLines([
|
||||
{ kind: Equal, text: " A" },
|
||||
{ kind: Added, text: "\n" },
|
||||
{ kind: Equal, text: "\n B" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(lines.map((line) => line.segments)).toEqual([
|
||||
[{ kind: Equal, text: "A" }],
|
||||
[],
|
||||
[{ kind: Equal, text: "B" }],
|
||||
])
|
||||
})
|
||||
|
||||
it("drops a segment that was only indentation", ({ expect }) => {
|
||||
const lines = dedentLines(
|
||||
diffLines([
|
||||
{ kind: Equal, text: " " },
|
||||
{ kind: Added, text: "A" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(lines.map((line) => line.segments)).toEqual([
|
||||
[{ kind: Added, text: "A" }],
|
||||
])
|
||||
})
|
||||
})
|
||||
89
web/app/components/editor/blocks/mermaid/diff-lines.ts
Normal file
89
web/app/components/editor/blocks/mermaid/diff-lines.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
TextSegmentKind,
|
||||
type TextSegment,
|
||||
} from "~/components/editor/diff/change-count"
|
||||
|
||||
const INDENT_RE = /^[ \t]*/
|
||||
|
||||
export interface DiffLine {
|
||||
// the line's number in the new text. Null for a line that holds only
|
||||
// removed text, since the new text has no such line.
|
||||
number: number | null
|
||||
segments: TextSegment[]
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
// split the segments of a diffed text into lines. A newline inside removed
|
||||
// text is gone from the new text, so it does not move the numbering on.
|
||||
export function diffLines(segments: TextSegment[]): DiffLine[] {
|
||||
let number = 1
|
||||
const lines: DiffLine[] = [{ number: number, segments: [], changed: false }]
|
||||
|
||||
for (const segment of segments) {
|
||||
for (const [index, text] of segment.text.split("\n").entries()) {
|
||||
if (index > 0) {
|
||||
// a newline that was added or removed changes the line it ends
|
||||
const ended = lines[lines.length - 1]
|
||||
if (ended && segment.kind !== TextSegmentKind.Equal) {
|
||||
ended.changed = true
|
||||
}
|
||||
|
||||
if (segment.kind !== TextSegmentKind.Removed) {
|
||||
number++
|
||||
}
|
||||
|
||||
lines.push({ number: number, segments: [], changed: false })
|
||||
}
|
||||
|
||||
const line = lines[lines.length - 1]
|
||||
if (line && text) {
|
||||
line.segments.push({ kind: segment.kind, text: text })
|
||||
line.changed ||= segment.kind !== TextSegmentKind.Equal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const onlyRemoved =
|
||||
line.segments.length > 0 &&
|
||||
line.segments.every((s) => s.kind === TextSegmentKind.Removed)
|
||||
if (onlyRemoved) {
|
||||
line.number = null
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// strip the indentation all lines share, so a snippet from deep inside the
|
||||
// code starts at the left edge. Only unchanged text counts as indentation,
|
||||
// so a changed indent still shows. An empty line has no say.
|
||||
export function dedentLines<T extends DiffLine>(lines: T[]): T[] {
|
||||
const indents = lines
|
||||
.filter((line) => line.segments.length > 0)
|
||||
.map((line) => {
|
||||
const first = line.segments[0]
|
||||
|
||||
return first?.kind === TextSegmentKind.Equal
|
||||
? (INDENT_RE.exec(first.text)?.[0].length ?? 0)
|
||||
: 0
|
||||
})
|
||||
const shared = indents.length > 0 ? Math.min(...indents) : 0
|
||||
if (shared === 0) {
|
||||
return lines
|
||||
}
|
||||
|
||||
return lines.map((line) => {
|
||||
const [first, ...rest] = line.segments
|
||||
if (!first) {
|
||||
return line
|
||||
}
|
||||
|
||||
const text = first.text.slice(shared)
|
||||
|
||||
return {
|
||||
...line,
|
||||
segments: text ? [{ ...first, text: text }, ...rest] : rest,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "./utils"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
|
||||
import MetricDiffDetails from "./MetricDiffDetails.vue"
|
||||
|
||||
// legacy blobs may miss fields entirely and may still use the old "type"
|
||||
// field name, so Partial reflects the actual runtime shape.
|
||||
|
|
@ -371,7 +372,9 @@ function disableVisualizationRefreshTemporarily() {
|
|||
<div
|
||||
class="absolute top-1 right-1.5 z-1 flex items-center rounded-md bg-background pl-0.5"
|
||||
>
|
||||
<DiffChangeMarker :node="props.node" class="mr-1" />
|
||||
<DiffChangeMarker :node="props.node" class="mr-1">
|
||||
<MetricDiffDetails :node="props.node" />
|
||||
</DiffChangeMarker>
|
||||
<ShadcnUiTooltip v-if="simulationVisible">
|
||||
<ShadcnUiTooltipTrigger as-child>
|
||||
<Icon
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { afterEach, beforeEach, describe, it } from "vitest"
|
||||
import MetricDiffDetails from "./MetricDiffDetails.vue"
|
||||
import { makeNode } from "../../test-helpers/node-view"
|
||||
import { METRIC_BLOCK_NAME } from "../node-names"
|
||||
import {
|
||||
RefreshInterval,
|
||||
TimeRangePreset,
|
||||
VisualizationTimeUnit,
|
||||
} from "./utils"
|
||||
import { DiffStatus } from "~/components/editor/diff/position-map"
|
||||
import { settleMutations, t } from "~/components/test-helpers"
|
||||
import {
|
||||
clearQueryCache,
|
||||
disposeMockEndpoints,
|
||||
mockEndpoint,
|
||||
} from "~/composables/api/test-helpers"
|
||||
|
||||
// the data source list lives in the app-wide query cache
|
||||
describe("<MetricDiffDetails>", { concurrent: false }, () => {
|
||||
beforeEach(() => {
|
||||
clearQueryCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
disposeMockEndpoints()
|
||||
})
|
||||
|
||||
it("labels each field of an added query by the query's number", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ queries: [query("up", "")] },
|
||||
{ queries: [query("up", ""), query("rate(x[5m])", "{{job}}")] },
|
||||
)
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t("editor.metrics.config.query-name-default-format", { index: 2 }),
|
||||
null,
|
||||
"rate(x[5m])",
|
||||
],
|
||||
[t("editor.metrics.diff.legend", { index: 2 }), null, "{{job}}"],
|
||||
])
|
||||
expect(wrapper.findAll("dd").every((dd) => dd.classes("font-mono"))).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("shows an empty legend as auto", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ queries: [query("up", "{{job}}")] },
|
||||
{ queries: [query("up", "")] },
|
||||
)
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t("editor.metrics.diff.legend", { index: 1 }),
|
||||
"{{job}}",
|
||||
t("editor.diff-change-marker.auto"),
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "decimal places", input: "decimals", label: "decimals-label" },
|
||||
{ name: "min value", input: "axisBoundsMin", label: "bounds-min-label" },
|
||||
{ name: "max value", input: "axisBoundsMax", label: "bounds-max-label" },
|
||||
])(
|
||||
"shows auto as the old $name when it was not set",
|
||||
async ({ input, label }, { expect }) => {
|
||||
const wrapper = await mountDetails({}, { [input]: 1 })
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t(`editor.metrics.config.${label}`),
|
||||
t("editor.diff-change-marker.auto"),
|
||||
"1",
|
||||
],
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it("shows a setting that was not set before as empty", async ({ expect }) => {
|
||||
const wrapper = await mountDetails(
|
||||
{},
|
||||
{ unitType: VisualizationTimeUnit.Seconds },
|
||||
)
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t("editor.metrics.config.unit-label"),
|
||||
t("editor.diff-change-marker.empty"),
|
||||
t("editor.metrics.config.unit-options.time.options.seconds"),
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("shows the settings by the names the config modal uses", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountDetails(
|
||||
{
|
||||
timeRange: TimeRangePreset.Last15Minutes,
|
||||
refreshInterval: RefreshInterval.M1,
|
||||
thresholds: [{ value: 1 }],
|
||||
},
|
||||
{
|
||||
timeRange: TimeRangePreset.Last5Minutes,
|
||||
refreshInterval: RefreshInterval.M5,
|
||||
thresholds: [{ value: 1 }, { value: 2 }],
|
||||
},
|
||||
)
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t("editor.metrics.config.time-range-label"),
|
||||
t("editor.metrics.config.time-range-options.last_15_minutes"),
|
||||
t("editor.metrics.config.time-range-options.last_5_minutes"),
|
||||
],
|
||||
[
|
||||
t("editor.metrics.config.refresh-interval-label"),
|
||||
t("editor.metrics.config.refresh-interval-options.1m"),
|
||||
t("editor.metrics.config.refresh-interval-options.5m"),
|
||||
],
|
||||
[t("editor.metrics.config.thresholds-label"), "1", "2"],
|
||||
])
|
||||
})
|
||||
|
||||
it("names a data source instead of showing its id", async ({ expect }) => {
|
||||
mockEndpoint("GET", "/api/data-sources", () => [
|
||||
{ id: "ds-1", name: "Demo", type: "prometheus", url: "demo://x" },
|
||||
])
|
||||
|
||||
const wrapper = await mountDetails(
|
||||
{ dataSourceId: "ds-gone" },
|
||||
{ dataSourceId: "ds-1" },
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
[
|
||||
t("editor.metrics.config.data-source-label"),
|
||||
t("editor.metrics.config.data-source-label-deleted"),
|
||||
"Demo",
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("shows an attribute it has no label for by its name", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountDetails(
|
||||
{ config: { title: "CPU" } },
|
||||
{ config: { title: "RAM" } },
|
||||
)
|
||||
|
||||
expect(rows(wrapper)).toEqual([
|
||||
["config", '{"title":"CPU"}', '{"title":"RAM"}'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function query(expr: string, legendFormat: string) {
|
||||
return { name: "Query", query: expr, legendFormat: legendFormat }
|
||||
}
|
||||
|
||||
// each row as its label, its old value and its new value, null for a side
|
||||
// the row does not show
|
||||
function rows(wrapper: Awaited<ReturnType<typeof mountDetails>>) {
|
||||
const labels = wrapper.findAll("dt")
|
||||
|
||||
return wrapper
|
||||
.findAll("dd")
|
||||
.map((dd, index) => [
|
||||
labels[index]?.text(),
|
||||
dd.find(".line-through").exists() ? dd.get(".line-through").text() : null,
|
||||
dd.find(".bg-diff-text-added").exists()
|
||||
? dd.get(".bg-diff-text-added").text()
|
||||
: null,
|
||||
])
|
||||
}
|
||||
|
||||
function mountDetails(
|
||||
oldAttrs: Record<string, unknown>,
|
||||
newAttrs: Record<string, unknown>,
|
||||
) {
|
||||
return mountSuspended(MetricDiffDetails, {
|
||||
props: {
|
||||
node: makeNode(
|
||||
{
|
||||
...newAttrs,
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: { attrs: oldAttrs },
|
||||
},
|
||||
{ typeName: METRIC_BLOCK_NAME },
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
161
web/app/components/editor/blocks/metrics/MetricDiffDetails.vue
Normal file
161
web/app/components/editor/blocks/metrics/MetricDiffDetails.vue
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import {
|
||||
collectAttributeChanges,
|
||||
displayValue,
|
||||
formatChange,
|
||||
type AttributeChange,
|
||||
type FieldChange,
|
||||
} from "~/components/editor/diff/change-count"
|
||||
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
|
||||
import {
|
||||
VisualizationDataUnit,
|
||||
VisualizationMiscUnit,
|
||||
VisualizationTimeUnit,
|
||||
} from "./utils"
|
||||
|
||||
// matches the per-field units of queries, such as "queries.1.legendFormat"
|
||||
const QUERY_FIELD_RE = /^queries\.(\d+)\.(query|legendFormat)$/
|
||||
|
||||
const UNIT_CATEGORIES: Record<string, string> = Object.fromEntries([
|
||||
...Object.values(VisualizationTimeUnit).map(unitCategory("time")),
|
||||
...Object.values(VisualizationDataUnit).map(unitCategory("data")),
|
||||
...Object.values(VisualizationMiscUnit).map(unitCategory("misc")),
|
||||
])
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
||||
const { t } = useI18n({ useScope: "global" })
|
||||
const { fetchDataSources } = useDataSourceAPI()
|
||||
|
||||
const rows = computed<FieldChange[]>(() =>
|
||||
collectAttributeChanges(props.node).map(toRow),
|
||||
)
|
||||
|
||||
function toRow(change: AttributeChange): FieldChange {
|
||||
const queryField = QUERY_FIELD_RE.exec(change.name)
|
||||
if (queryField) {
|
||||
const index = Number(queryField[1]) + 1
|
||||
const label =
|
||||
queryField[2] === "query"
|
||||
? t("editor.metrics.config.query-name-default-format", { index })
|
||||
: t("editor.metrics.diff.legend", { index })
|
||||
|
||||
// an empty legend falls back to the series labels, which the config
|
||||
// modal calls auto
|
||||
const format = (value: unknown) =>
|
||||
value === "" && queryField[2] === "legendFormat"
|
||||
? t("editor.diff-change-marker.auto")
|
||||
: displayValue(value)
|
||||
|
||||
return { label: label, ...formatChange(change, format), code: true }
|
||||
}
|
||||
|
||||
switch (change.name) {
|
||||
case "title":
|
||||
return row(t("editor.metrics.config.title-label"), change)
|
||||
case "dataSourceId":
|
||||
return row(
|
||||
t("editor.metrics.config.data-source-label"),
|
||||
change,
|
||||
dataSourceName,
|
||||
)
|
||||
case "visualizationType":
|
||||
return row(t("editor.metrics.diff.visualization"), change, (value) =>
|
||||
t(
|
||||
`editor.metrics.config.type-options.${displayValue(value).replace("_", "-")}.title`,
|
||||
),
|
||||
)
|
||||
case "timeRange":
|
||||
return row(t("editor.metrics.config.time-range-label"), change, (value) =>
|
||||
t(`editor.metrics.config.time-range-options.${displayValue(value)}`),
|
||||
)
|
||||
case "refreshInterval":
|
||||
return row(
|
||||
t("editor.metrics.config.refresh-interval-label"),
|
||||
change,
|
||||
(value) =>
|
||||
t(
|
||||
`editor.metrics.config.refresh-interval-options.${displayValue(value)}`,
|
||||
),
|
||||
)
|
||||
case "thresholds":
|
||||
return row(t("editor.metrics.config.thresholds-label"), change, (value) =>
|
||||
String((value as unknown[]).length),
|
||||
)
|
||||
case "baseThresholdColor":
|
||||
return row(t("editor.metrics.diff.base-threshold-color"), change)
|
||||
case "decimals":
|
||||
return autoRow(t("editor.metrics.config.decimals-label"), change)
|
||||
case "unitType":
|
||||
return row(t("editor.metrics.config.unit-label"), change, unitName)
|
||||
case "unitCustom":
|
||||
return row(t("editor.metrics.diff.custom-unit"), change)
|
||||
case "axisBoundsMin":
|
||||
return autoRow(t("editor.metrics.config.bounds-min-label"), change)
|
||||
case "axisBoundsMax":
|
||||
return autoRow(t("editor.metrics.config.bounds-max-label"), change)
|
||||
case "simulationPreset":
|
||||
return row(t("editor.metrics.simulation.preset-label"), change, (value) =>
|
||||
t(`editor.metrics.simulation.preset-options.${displayValue(value)}`),
|
||||
)
|
||||
case "width":
|
||||
return row(t("editor.metrics.diff.size"), change, (value) =>
|
||||
t(
|
||||
`editor.drag-handle.options.metric-block.width-options.${displayValue(value)}`,
|
||||
),
|
||||
)
|
||||
default:
|
||||
// an attribute this card has no label for still shows, so the
|
||||
// card lists every change the marker counts
|
||||
return row(change.name, change)
|
||||
}
|
||||
}
|
||||
|
||||
// a setting always has a value, so its unset side shows as empty
|
||||
function row(
|
||||
label: string,
|
||||
change: AttributeChange,
|
||||
format?: (value: unknown) => string,
|
||||
): FieldChange {
|
||||
return { label: label, ...formatChange(change, format, "") }
|
||||
}
|
||||
|
||||
// a setting whose unset value lets the chart pick one, as its placeholder
|
||||
// in the config modal says
|
||||
function autoRow(label: string, change: AttributeChange): FieldChange {
|
||||
return {
|
||||
label: label,
|
||||
...formatChange(change, displayValue, t("editor.diff-change-marker.auto")),
|
||||
}
|
||||
}
|
||||
|
||||
function unitCategory(category: string) {
|
||||
return (unit: string): [string, string] => [unit, category]
|
||||
}
|
||||
|
||||
function dataSourceName(id: unknown): string {
|
||||
const dataSource = fetchDataSources.state.value.data?.find(
|
||||
(source) => source.id === id,
|
||||
)
|
||||
|
||||
return (
|
||||
dataSource?.name ?? t("editor.metrics.config.data-source-label-deleted")
|
||||
)
|
||||
}
|
||||
|
||||
function unitName(value: unknown): string {
|
||||
const unit = displayValue(value)
|
||||
const category = UNIT_CATEGORIES[unit]
|
||||
|
||||
return category
|
||||
? t(`editor.metrics.config.unit-options.${category}.options.${unit}`)
|
||||
: t(`editor.metrics.config.unit-options.${unit}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiffFieldChanges class="w-80" :rows="rows" />
|
||||
</template>
|
||||
52
web/app/components/editor/diff/DiffBeforeAfter.nuxt.test.ts
Normal file
52
web/app/components/editor/diff/DiffBeforeAfter.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import { h } from "vue"
|
||||
import DiffBeforeAfter from "./DiffBeforeAfter.vue"
|
||||
|
||||
describe("<DiffBeforeAfter>", () => {
|
||||
it("renders each set side through the slot", async ({ expect }) => {
|
||||
const wrapper = await mountSides("a.png", "b.png")
|
||||
|
||||
expect(wrapper.findAll(".side").map((side) => side.text())).toEqual([
|
||||
"a.png",
|
||||
"b.png",
|
||||
])
|
||||
expect(wrapper.text()).not.toContain("nothing")
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "shows the empty label for a side that was not set before",
|
||||
input: [undefined, "b.png"],
|
||||
expected: ["nothing", "b.png"],
|
||||
},
|
||||
{
|
||||
name: "shows the empty label for a side that is not set after",
|
||||
input: ["a.png", undefined],
|
||||
expected: ["a.png", "nothing"],
|
||||
},
|
||||
])("$name", async ({ input, expected }, { expect }) => {
|
||||
const wrapper = await mountSides(input[0], input[1])
|
||||
const boxes = wrapper.findAll(".border-2").map((box) => box.text())
|
||||
|
||||
expect(boxes).toEqual(expected)
|
||||
})
|
||||
|
||||
it("borders the old side red and the new side green", async ({ expect }) => {
|
||||
const wrapper = await mountSides("a.png", "b.png")
|
||||
const boxes = wrapper.findAll(".border-2")
|
||||
|
||||
expect(boxes[0]?.classes()).toContain("border-diff-removed")
|
||||
expect(boxes[1]?.classes()).toContain("border-diff-added")
|
||||
})
|
||||
})
|
||||
|
||||
function mountSides(before: string | undefined, after: string | undefined) {
|
||||
return mountSuspended(DiffBeforeAfter, {
|
||||
props: { before: before, after: after, emptyLabel: "nothing" },
|
||||
slots: {
|
||||
default: ({ value }: { value: unknown }) =>
|
||||
h("span", { class: "side" }, String(value)),
|
||||
},
|
||||
})
|
||||
}
|
||||
48
web/app/components/editor/diff/DiffBeforeAfter.vue
Normal file
48
web/app/components/editor/diff/DiffBeforeAfter.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts" setup generic="T">
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// an undefined side had nothing set, so it shows emptyLabel instead. Both
|
||||
// sides take the same width, and the taller one sets the height of both.
|
||||
const props = defineProps<{
|
||||
before?: T
|
||||
after?: T
|
||||
emptyLabel: string
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { value: T }) => unknown
|
||||
}>()
|
||||
|
||||
const sides = computed(() => [
|
||||
{ key: "before", value: props.before, borderClass: "border-diff-removed" },
|
||||
{ key: "after", value: props.after, borderClass: "border-diff-added" },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2">
|
||||
<template v-for="(side, index) in sides" :key="side.key">
|
||||
<Icon
|
||||
v-if="index > 0"
|
||||
name="mingcute:arrow-right-line"
|
||||
class="size-3.5 self-center text-muted-foreground"
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-14 items-center justify-center overflow-hidden rounded-md border-2',
|
||||
side.borderClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot v-if="side.value !== undefined" :value="side.value" />
|
||||
<span
|
||||
v-else
|
||||
class="px-2 text-center text-xs text-muted-foreground italic"
|
||||
>
|
||||
{{ props.emptyLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import { beforeEach, describe, it, vi } from "vitest"
|
||||
import { flushPromises } from "@vue/test-utils"
|
||||
import { defineComponent, h } from "vue"
|
||||
import DiffChangeMarker from "./DiffChangeMarker.vue"
|
||||
import { DiffStatus } from "./position-map"
|
||||
import { makeNode } from "../test-helpers/node-view"
|
||||
import { t } from "~/components/test-helpers"
|
||||
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
|
||||
|
||||
const MODIFIED_IMAGE = {
|
||||
src: "b.png",
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: { attrs: { src: "a.png" } },
|
||||
}
|
||||
|
||||
describe("DiffChangeMarker", () => {
|
||||
it("shows the removals and additions of a modified node", async ({
|
||||
|
|
@ -63,10 +71,88 @@ describe("DiffChangeMarker", () => {
|
|||
expect(wrapper.html()).toBe("<!--v-if-->")
|
||||
},
|
||||
)
|
||||
|
||||
// the card lands in <body>, which every test in the file shares
|
||||
describe("when clicked", { concurrent: false }, () => {
|
||||
beforeEach(() => {
|
||||
clearTeleportedOverlays()
|
||||
})
|
||||
|
||||
it("opens the details from its slot", async ({ expect }) => {
|
||||
const wrapper = await mountMarker(MODIFIED_IMAGE, () => "image details")
|
||||
|
||||
await wrapper.get("span").trigger("click")
|
||||
|
||||
expect(document.body.textContent).toContain("image details")
|
||||
})
|
||||
|
||||
it("closes the details on a click elsewhere", async ({ expect }) => {
|
||||
const wrapper = await mountMarker(MODIFIED_IMAGE, () => "image details")
|
||||
await wrapper.get("span").trigger("click")
|
||||
// the popover's outside-click listener is attached a task after it
|
||||
// opens
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
document.body.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).not.toContain("image details")
|
||||
})
|
||||
|
||||
it("looks clickable only when it has details", async ({ expect }) => {
|
||||
const withDetails = await mountMarker(MODIFIED_IMAGE, () => "details")
|
||||
const withoutDetails = await mountMarker(MODIFIED_IMAGE)
|
||||
|
||||
expect(withDetails.get("span").classes()).toEqual(
|
||||
expect.arrayContaining(["cursor-pointer", "active:opacity-90"]),
|
||||
)
|
||||
expect(withoutDetails.get("span").classes()).not.toContain(
|
||||
"cursor-pointer",
|
||||
)
|
||||
expect(withoutDetails.get("span").classes()).not.toContain(
|
||||
"active:opacity-90",
|
||||
)
|
||||
})
|
||||
|
||||
it("opens no card when it has no details", async ({ expect }) => {
|
||||
const wrapper = await mountMarker(MODIFIED_IMAGE)
|
||||
|
||||
await wrapper.get("span").trigger("click")
|
||||
|
||||
expect(
|
||||
document.body.querySelector("[data-slot='popover-content']"),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("inside a link", () => {
|
||||
it("keeps a click on the pill from reaching the element around it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const onClick = vi.fn()
|
||||
const wrapper = await mountSuspended(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h("a", { href: "#file", onClick: onClick }, [
|
||||
h(DiffChangeMarker, { node: makeNode(MODIFIED_IMAGE) }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true })
|
||||
wrapper.get("a > span").element.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function mountMarker(attrs: Record<string, unknown>) {
|
||||
function mountMarker(attrs: Record<string, unknown>, details?: () => string) {
|
||||
return mountSuspended(DiffChangeMarker, {
|
||||
props: { node: makeNode(attrs) },
|
||||
slots: details ? { default: details } : {},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { countNodeChanges } from "./change-count"
|
||||
import { DiffStatus } from "./position-map"
|
||||
|
||||
// the classes that place the marker belong on the pill, not on the popover
|
||||
// root, which renders no element of its own. The pill sits inside the file
|
||||
// card's link and the empty blocks' buttons, so a click on it stops there.
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
node: PMNode
|
||||
}>()
|
||||
|
|
@ -15,27 +23,48 @@ const count = computed(() =>
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count && (count.removed || count.added)"
|
||||
contenteditable="false"
|
||||
class="pointer-events-none flex overflow-hidden rounded-full border border-border bg-background text-xs font-semibold select-none"
|
||||
>
|
||||
<span class="sr-only">
|
||||
{{ $t("editor.diff-change-marker.label", { ...count }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="count.removed"
|
||||
aria-hidden="true"
|
||||
class="bg-diff-removed px-2 py-0.5 text-diff-removed-foreground"
|
||||
<ShadcnUiPopover v-if="count && (count.removed || count.added)">
|
||||
<ShadcnUiPopoverTrigger as-child :disabled="!$slots.default">
|
||||
<span
|
||||
v-bind="$attrs"
|
||||
contenteditable="false"
|
||||
:class="
|
||||
cn(
|
||||
'flex overflow-hidden rounded-full border border-border bg-background text-xs font-semibold transition-all select-none',
|
||||
$slots.default &&
|
||||
'cursor-pointer active:opacity-90 [&:hover:not(:active)]:opacity-70',
|
||||
)
|
||||
"
|
||||
@click.stop.prevent
|
||||
>
|
||||
<span class="sr-only">
|
||||
{{ $t("editor.diff-change-marker.label", { ...count }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="count.removed"
|
||||
aria-hidden="true"
|
||||
class="bg-diff-removed px-2 py-0.5 text-diff-removed-foreground"
|
||||
>
|
||||
{{
|
||||
$t("editor.diff-change-marker.removed", { count: count.removed })
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="count.added"
|
||||
aria-hidden="true"
|
||||
class="bg-diff-added px-2 py-0.5 text-diff-added-foreground"
|
||||
>
|
||||
{{ $t("editor.diff-change-marker.added", { count: count.added }) }}
|
||||
</span>
|
||||
</span>
|
||||
</ShadcnUiPopoverTrigger>
|
||||
<ShadcnUiPopoverContent
|
||||
v-if="$slots.default"
|
||||
align="end"
|
||||
class="w-auto max-w-[min(28rem,90dvw)] p-3"
|
||||
>
|
||||
{{ $t("editor.diff-change-marker.removed", { count: count.removed }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="count.added"
|
||||
aria-hidden="true"
|
||||
class="bg-diff-added px-2 py-0.5 text-diff-added-foreground"
|
||||
>
|
||||
{{ $t("editor.diff-change-marker.added", { count: count.added }) }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- the details of the changes, from the block around the marker -->
|
||||
<slot />
|
||||
</ShadcnUiPopoverContent>
|
||||
</ShadcnUiPopover>
|
||||
</template>
|
||||
|
|
|
|||
65
web/app/components/editor/diff/DiffFieldChanges.nuxt.test.ts
Normal file
65
web/app/components/editor/diff/DiffFieldChanges.nuxt.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { mountSuspended } from "@nuxt/test-utils/runtime"
|
||||
import { describe, it } from "vitest"
|
||||
import DiffFieldChanges from "./DiffFieldChanges.vue"
|
||||
import type { FieldChange } from "./change-count"
|
||||
import { t } from "~/components/test-helpers"
|
||||
|
||||
describe("<DiffFieldChanges>", () => {
|
||||
it("shows a changed field's label, old value and new value", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const wrapper = await mountFields([
|
||||
{ label: "Title", oldValue: "CPU", newValue: "CPU usage" },
|
||||
])
|
||||
|
||||
expect(wrapper.get("dt").text()).toBe("Title")
|
||||
expect(wrapper.get(".line-through").text()).toBe("CPU")
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe("CPU usage")
|
||||
expect(wrapper.find(".iconify").exists()).toBe(true)
|
||||
})
|
||||
|
||||
it("shows only the new value of a newly set field", async ({ expect }) => {
|
||||
const wrapper = await mountFields([
|
||||
{ label: "Unit", oldValue: null, newValue: "percent" },
|
||||
])
|
||||
|
||||
expect(wrapper.find(".line-through").exists()).toBe(false)
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe("percent")
|
||||
expect(wrapper.find(".iconify").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("shows only the old value of a cleared field", async ({ expect }) => {
|
||||
const wrapper = await mountFields([
|
||||
{ label: "Unit", oldValue: "percent", newValue: null },
|
||||
])
|
||||
|
||||
expect(wrapper.get(".line-through").text()).toBe("percent")
|
||||
expect(wrapper.find(".bg-diff-text-added").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("names an empty value instead of leaving it blank", async ({ expect }) => {
|
||||
const wrapper = await mountFields([
|
||||
{ label: "Legend 2", oldValue: null, newValue: "" },
|
||||
])
|
||||
|
||||
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
|
||||
t("editor.diff-change-marker.empty"),
|
||||
)
|
||||
})
|
||||
|
||||
it("sets code values in a monospace font", async ({ expect }) => {
|
||||
const wrapper = await mountFields([
|
||||
{ label: "Query 2", oldValue: null, newValue: "up", code: true },
|
||||
{ label: "Title", oldValue: null, newValue: "CPU" },
|
||||
])
|
||||
|
||||
expect(wrapper.findAll("dd").map((dd) => dd.classes("font-mono"))).toEqual([
|
||||
true,
|
||||
false,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function mountFields(rows: FieldChange[]) {
|
||||
return mountSuspended(DiffFieldChanges, { props: { rows: rows } })
|
||||
}
|
||||
44
web/app/components/editor/diff/DiffFieldChanges.vue
Normal file
44
web/app/components/editor/diff/DiffFieldChanges.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts" setup>
|
||||
import { cn } from "~/lib/utils"
|
||||
import type { FieldChange } from "./change-count"
|
||||
|
||||
const props = defineProps<{
|
||||
rows: FieldChange[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-4 gap-y-1.5 text-xs"
|
||||
>
|
||||
<template v-for="(row, index) in props.rows" :key="index">
|
||||
<dt class="whitespace-nowrap text-muted-foreground">{{ row.label }}</dt>
|
||||
<dd
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-wrap items-baseline gap-1',
|
||||
row.code && 'font-mono',
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="row.oldValue !== null"
|
||||
class="rounded-sm bg-diff-text-removed px-1 break-all line-through"
|
||||
>
|
||||
{{ row.oldValue || $t("editor.diff-change-marker.empty") }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="row.oldValue !== null && row.newValue !== null"
|
||||
name="mingcute:arrow-right-line"
|
||||
class="size-3 shrink-0 self-center text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="row.newValue !== null"
|
||||
class="rounded-sm bg-diff-text-added px-1 break-all"
|
||||
>
|
||||
{{ row.newValue || $t("editor.diff-change-marker.empty") }}
|
||||
</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { Schema, type Node as PMNode } from "@tiptap/pm/model"
|
||||
import { describe, it } from "vitest"
|
||||
import { countNodeChanges } from "./change-count"
|
||||
import {
|
||||
collectAttributeChanges,
|
||||
countNodeChanges,
|
||||
displayValue,
|
||||
formatChange,
|
||||
TextSegmentKind,
|
||||
textSegments,
|
||||
} from "./change-count"
|
||||
import { DiffStatus } from "./position-map"
|
||||
import {
|
||||
COMMENT_MARK_NAME,
|
||||
|
|
@ -11,6 +18,7 @@ import { NODE_COMMENT_ID_ATTR } from "~/components/editor/attribute-names"
|
|||
import { SIMULATION_ACTIVE_ATTR } from "~/components/editor/blocks/metrics/simulation"
|
||||
import {
|
||||
FILE_BLOCK_NAME,
|
||||
IMAGE_BLOCK_NAME,
|
||||
METRIC_BLOCK_NAME,
|
||||
} from "~/components/editor/blocks/node-names"
|
||||
|
||||
|
|
@ -39,6 +47,19 @@ const schema = new Schema({
|
|||
oldNode: { default: null },
|
||||
},
|
||||
},
|
||||
[IMAGE_BLOCK_NAME]: {
|
||||
group: "block",
|
||||
atom: true,
|
||||
attrs: {
|
||||
uid: { default: null },
|
||||
src: { default: null },
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
diffStatus: { default: null },
|
||||
oldNode: { default: null },
|
||||
},
|
||||
},
|
||||
[FILE_BLOCK_NAME]: {
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
|
@ -143,6 +164,24 @@ describe("countNodeChanges", () => {
|
|||
expect(countNodeChanges(node)).toEqual(expected)
|
||||
})
|
||||
|
||||
it("ignores the image attributes no one can change from the editor", ({
|
||||
expect,
|
||||
}) => {
|
||||
const node = schema.nodes[IMAGE_BLOCK_NAME].create({
|
||||
uid: "image-1",
|
||||
src: "a.png",
|
||||
alt: "a dog",
|
||||
title: "Dog",
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: {
|
||||
type: IMAGE_BLOCK_NAME,
|
||||
attrs: { uid: "image-1", src: "a.png", alt: "a cat" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(countNodeChanges(node)).toEqual({ removed: 0, added: 0 })
|
||||
})
|
||||
|
||||
it("counts no attribute changes on a node without its original", ({
|
||||
expect,
|
||||
}) => {
|
||||
|
|
@ -172,12 +211,12 @@ describe("countNodeChanges", () => {
|
|||
expected: { removed: 1, added: 0 },
|
||||
},
|
||||
{
|
||||
name: "counts the attributes outside the file group on their own",
|
||||
name: "ignores the flag of an upload in flight",
|
||||
input: {
|
||||
old: storedFile("a.zip"),
|
||||
new: { ...storedFile("a.zip"), uploading: true },
|
||||
},
|
||||
expected: { removed: 1, added: 1 },
|
||||
expected: { removed: 0, added: 0 },
|
||||
},
|
||||
])("$name", ({ input, expected }, { expect }) => {
|
||||
const node = schema.nodes[FILE_BLOCK_NAME].create({
|
||||
|
|
@ -294,6 +333,134 @@ describe("countNodeChanges", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("collectAttributeChanges", () => {
|
||||
it("lists each changed unit with both of its sides", ({ expect }) => {
|
||||
const node = schema.nodes.atom.create({
|
||||
uid: "block-1",
|
||||
src: "b.png",
|
||||
alt: "a cat",
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: {
|
||||
type: "atom",
|
||||
attrs: { uid: "block-1", src: "a.png", width: 300 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(collectAttributeChanges(node)).toEqual([
|
||||
{ name: "src", oldValue: "a.png", newValue: "b.png" },
|
||||
{ name: "width", oldValue: 300, newValue: undefined },
|
||||
{ name: "alt", oldValue: undefined, newValue: "a cat" },
|
||||
])
|
||||
})
|
||||
|
||||
it("gives a group's side as the record of its set attributes", ({
|
||||
expect,
|
||||
}) => {
|
||||
const node = schema.nodes[FILE_BLOCK_NAME].create({
|
||||
uid: "file-1",
|
||||
...storedFile("b.zip"),
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: {
|
||||
type: FILE_BLOCK_NAME,
|
||||
attrs: { uid: "file-1", uploading: false },
|
||||
},
|
||||
})
|
||||
|
||||
expect(collectAttributeChanges(node)).toEqual([
|
||||
{ name: "file", oldValue: undefined, newValue: storedFile("b.zip") },
|
||||
])
|
||||
})
|
||||
|
||||
it("names a list item's field by its list, index and field", ({ expect }) => {
|
||||
const node = schema.nodes[METRIC_BLOCK_NAME].create({
|
||||
uid: "metric-1",
|
||||
queries: [query("up", ""), query("rate", "")],
|
||||
diffStatus: DiffStatus.Modified,
|
||||
oldNode: {
|
||||
type: METRIC_BLOCK_NAME,
|
||||
attrs: { uid: "metric-1", queries: [query("up", "")] },
|
||||
},
|
||||
})
|
||||
|
||||
expect(collectAttributeChanges(node)).toEqual([
|
||||
{ name: "queries.1.query", oldValue: undefined, newValue: "rate" },
|
||||
{ name: "queries.1.legendFormat", oldValue: undefined, newValue: "" },
|
||||
])
|
||||
})
|
||||
|
||||
it("lists nothing for a node without its original", ({ expect }) => {
|
||||
const node = schema.nodes.atom.create({
|
||||
src: "a.png",
|
||||
diffStatus: DiffStatus.Modified,
|
||||
})
|
||||
|
||||
expect(collectAttributeChanges(node)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("displayValue", () => {
|
||||
it.for([
|
||||
{ name: "keeps a string as it is", input: "a cat", expected: "a cat" },
|
||||
{ name: "prints a number", input: 480, expected: "480" },
|
||||
{ name: "prints a boolean", input: true, expected: "true" },
|
||||
{
|
||||
name: "prints an object as stable JSON",
|
||||
input: { b: 1, a: 2 },
|
||||
expected: '{"a":2,"b":1}',
|
||||
},
|
||||
])("$name", ({ input, expected }, { expect }) => {
|
||||
expect(displayValue(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatChange", () => {
|
||||
it("formats both sides with the given format", ({ expect }) => {
|
||||
expect(
|
||||
formatChange(
|
||||
{ name: "width", oldValue: 300, newValue: 480 },
|
||||
(value) => `${displayValue(value)}!`,
|
||||
),
|
||||
).toEqual({ oldValue: "300!", newValue: "480!" })
|
||||
})
|
||||
|
||||
it("shows the unset text for a side that is not set", ({ expect }) => {
|
||||
expect(
|
||||
formatChange(
|
||||
{ name: "width", oldValue: undefined, newValue: 480 },
|
||||
displayValue,
|
||||
"auto",
|
||||
),
|
||||
).toEqual({ oldValue: "auto", newValue: "480" })
|
||||
})
|
||||
|
||||
it("gives null for a side that is not set", ({ expect }) => {
|
||||
expect(
|
||||
formatChange({ name: "alt", oldValue: undefined, newValue: "a cat" }),
|
||||
).toEqual({ oldValue: null, newValue: "a cat" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("textSegments", () => {
|
||||
it("joins neighbouring text of the same kind into one segment", ({
|
||||
expect,
|
||||
}) => {
|
||||
const node = schema.nodes.code.create({ uid: "code-1" }, [
|
||||
textNode("A-->", []),
|
||||
textNode("B", [REMOVED]),
|
||||
textNode("C", [ADDED]),
|
||||
textNode("D", [ADDED, COMMENT_MARK_NAME]),
|
||||
textNode(" end", []),
|
||||
])
|
||||
|
||||
expect(textSegments(node)).toEqual([
|
||||
{ kind: TextSegmentKind.Equal, text: "A-->" },
|
||||
{ kind: TextSegmentKind.Removed, text: "B" },
|
||||
{ kind: TextSegmentKind.Added, text: "CD" },
|
||||
{ kind: TextSegmentKind.Equal, text: " end" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function query(expr: string, legendFormat: string): Record<string, unknown> {
|
||||
return { name: "Query", query: expr, legendFormat: legendFormat }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ import {
|
|||
DIFF_TEXT_ADDED_MARK_NAME,
|
||||
DIFF_TEXT_REMOVED_MARK_NAME,
|
||||
} from "~/components/editor/mark-names"
|
||||
import { NODE_COMMENT_ID_ATTR } from "~/components/editor/attribute-names"
|
||||
import {
|
||||
NODE_COMMENT_ID_ATTR,
|
||||
UPLOADING_ATTR,
|
||||
} from "~/components/editor/attribute-names"
|
||||
import { SIMULATION_ACTIVE_ATTR } from "~/components/editor/blocks/metrics/simulation"
|
||||
import {
|
||||
FILE_BLOCK_NAME,
|
||||
IMAGE_BLOCK_NAME,
|
||||
METRIC_BLOCK_NAME,
|
||||
} from "~/components/editor/blocks/node-names"
|
||||
|
||||
|
|
@ -29,8 +33,15 @@ const IGNORED_ATTRIBUTES = new Set([
|
|||
...DIFF_META_ATTRIBUTES,
|
||||
NODE_COMMENT_ID_ATTR,
|
||||
SIMULATION_ACTIVE_ATTR,
|
||||
UPLOADING_ATTR,
|
||||
])
|
||||
|
||||
// attributes no one can change from the editor, per node type. They only
|
||||
// arrive with pasted HTML, so they are not counted.
|
||||
const UNTRACKED_ATTRIBUTES: Record<string, string[]> = {
|
||||
[IMAGE_BLOCK_NAME]: ["alt", "title"],
|
||||
}
|
||||
|
||||
// attributes that change together and count as one change, per node
|
||||
// type. Each attribute maps to the name of its group.
|
||||
const ATTRIBUTE_GROUPS: Record<string, Record<string, string>> = {
|
||||
|
|
@ -50,47 +61,60 @@ const ITEM_FIELDS: Record<string, Record<string, string[]>> = {
|
|||
},
|
||||
}
|
||||
|
||||
export interface AttributeChange {
|
||||
// the attribute's name, its group's name, or "<list>.<index>.<field>"
|
||||
// for a field of a list item
|
||||
name: string
|
||||
// undefined on the side where the unit is not set. A group's value is
|
||||
// the record of its set attributes.
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
// one row of a card that lists changed fields
|
||||
export interface FieldChange {
|
||||
label: string
|
||||
// null on the side where the field is not set
|
||||
oldValue: string | null
|
||||
newValue: string | null
|
||||
// code such as a query reads better in a monospace font
|
||||
code?: boolean
|
||||
}
|
||||
|
||||
export enum TextSegmentKind {
|
||||
Equal = "equal",
|
||||
Removed = "removed",
|
||||
Added = "added",
|
||||
}
|
||||
|
||||
export interface TextSegment {
|
||||
kind: TextSegmentKind
|
||||
text: string
|
||||
}
|
||||
|
||||
// count the changes of a modified node against its original. A changed
|
||||
// attribute counts as one removal and one addition, a newly set one as an
|
||||
// addition and a cleared one as a removal. A group of attributes counts
|
||||
// like one attribute. Changed text counts by run.
|
||||
export function countNodeChanges(node: PMNode): ChangeCount {
|
||||
// an expanded textblock drops its oldNode, since its marks carry the
|
||||
// original instead
|
||||
const oldNode = node.attrs.oldNode as JSONContent | null
|
||||
const attributes = oldNode
|
||||
? countAttributeChanges(node.type.name, oldNode.attrs ?? {}, node.attrs)
|
||||
: { removed: 0, added: 0 }
|
||||
const text = countTextChanges(node)
|
||||
|
||||
return {
|
||||
removed: attributes.removed + text.removed,
|
||||
added: attributes.added + text.added,
|
||||
}
|
||||
}
|
||||
|
||||
function countAttributeChanges(
|
||||
typeName: string,
|
||||
oldAttrs: Record<string, unknown>,
|
||||
newAttrs: Record<string, unknown>,
|
||||
): ChangeCount {
|
||||
const count: ChangeCount = { removed: 0, added: 0 }
|
||||
const oldUnits = attributeUnits(typeName, oldAttrs)
|
||||
const newUnits = attributeUnits(typeName, newAttrs)
|
||||
const names = new Set([...oldUnits.keys(), ...newUnits.keys()])
|
||||
|
||||
for (const name of names) {
|
||||
const oldUnit = oldUnits.get(name)
|
||||
const newUnit = newUnits.get(name)
|
||||
if (jsonStableStringify(oldUnit) === jsonStableStringify(newUnit)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (oldUnit) {
|
||||
for (const change of collectAttributeChanges(node)) {
|
||||
if (change.oldValue !== undefined) {
|
||||
count.removed++
|
||||
}
|
||||
|
||||
if (newUnit) {
|
||||
if (change.newValue !== undefined) {
|
||||
count.added++
|
||||
}
|
||||
}
|
||||
|
||||
for (const segment of textSegments(node)) {
|
||||
if (segment.kind === TextSegmentKind.Removed) {
|
||||
count.removed++
|
||||
}
|
||||
|
||||
if (segment.kind === TextSegmentKind.Added) {
|
||||
count.added++
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +122,88 @@ function countAttributeChanges(
|
|||
return count
|
||||
}
|
||||
|
||||
export function collectAttributeChanges(node: PMNode): AttributeChange[] {
|
||||
// an expanded textblock drops its oldNode, since its marks carry the
|
||||
// original instead
|
||||
const oldNode = node.attrs.oldNode as JSONContent | null
|
||||
if (!oldNode) {
|
||||
return []
|
||||
}
|
||||
|
||||
const oldUnits = attributeUnits(node.type.name, oldNode.attrs ?? {})
|
||||
const newUnits = attributeUnits(node.type.name, node.attrs)
|
||||
const names = new Set([...oldUnits.keys(), ...newUnits.keys()])
|
||||
const changes: AttributeChange[] = []
|
||||
|
||||
for (const name of names) {
|
||||
const oldValue = oldUnits.get(name)
|
||||
const newValue = newUnits.get(name)
|
||||
if (jsonStableStringify(oldValue) !== jsonStableStringify(newValue)) {
|
||||
changes.push({ name: name, oldValue: oldValue, newValue: newValue })
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// the text a card shows for an attribute value
|
||||
export function displayValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return jsonStableStringify(value)
|
||||
}
|
||||
|
||||
// both sides of a change as text. A side that is not set shows unset, or
|
||||
// is left out when unset is null.
|
||||
export function formatChange(
|
||||
change: AttributeChange,
|
||||
format: (value: unknown) => string = displayValue,
|
||||
unset: string | null = null,
|
||||
): { oldValue: string | null; newValue: string | null } {
|
||||
return {
|
||||
oldValue: change.oldValue === undefined ? unset : format(change.oldValue),
|
||||
newValue: change.newValue === undefined ? unset : format(change.newValue),
|
||||
}
|
||||
}
|
||||
|
||||
// a modified textblock holds both sides of its text, told apart by the
|
||||
// added and removed marks. Each unbroken run of one kind becomes one
|
||||
// segment, so several changes can sit on the same line.
|
||||
export function textSegments(node: PMNode): TextSegment[] {
|
||||
const segments: TextSegment[] = []
|
||||
|
||||
node.descendants((child) => {
|
||||
if (!child.isText) {
|
||||
return true
|
||||
}
|
||||
|
||||
const marks = child.marks.map((mark) => mark.type.name)
|
||||
let kind = TextSegmentKind.Equal
|
||||
if (marks.includes(DIFF_TEXT_REMOVED_MARK_NAME)) {
|
||||
kind = TextSegmentKind.Removed
|
||||
} else if (marks.includes(DIFF_TEXT_ADDED_MARK_NAME)) {
|
||||
kind = TextSegmentKind.Added
|
||||
}
|
||||
|
||||
const last = segments.at(-1)
|
||||
if (last?.kind === kind) {
|
||||
last.text += child.text ?? ""
|
||||
} else {
|
||||
segments.push({ kind: kind, text: child.text ?? "" })
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// sort the set attributes into units, keyed by their group name, or by
|
||||
// their own name when they have no group. A list attribute with item fields
|
||||
// becomes one unit per field of each item instead. A unit whose attributes
|
||||
|
|
@ -105,13 +211,19 @@ function countAttributeChanges(
|
|||
function attributeUnits(
|
||||
typeName: string,
|
||||
attrs: Record<string, unknown>,
|
||||
): Map<string, Record<string, unknown>> {
|
||||
): Map<string, unknown> {
|
||||
const groups = ATTRIBUTE_GROUPS[typeName] ?? {}
|
||||
const itemFields = ITEM_FIELDS[typeName] ?? {}
|
||||
const units = new Map<string, Record<string, unknown>>()
|
||||
const untracked = UNTRACKED_ATTRIBUTES[typeName] ?? []
|
||||
const units = new Map<string, unknown>()
|
||||
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (IGNORED_ATTRIBUTES.has(key) || value === null || value === undefined) {
|
||||
if (
|
||||
IGNORED_ATTRIBUTES.has(key) ||
|
||||
untracked.includes(key) ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -121,15 +233,19 @@ function attributeUnits(
|
|||
continue
|
||||
}
|
||||
|
||||
const name = groups[key] ?? key
|
||||
units.set(name, { ...units.get(name), [key]: value })
|
||||
const group = groups[key]
|
||||
if (group) {
|
||||
units.set(group, { ...(units.get(group) as object), [key]: value })
|
||||
} else {
|
||||
units.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return units
|
||||
}
|
||||
|
||||
function addItemUnits(
|
||||
units: Map<string, Record<string, unknown>>,
|
||||
units: Map<string, unknown>,
|
||||
key: string,
|
||||
items: Record<string, unknown>[],
|
||||
fields: string[],
|
||||
|
|
@ -138,44 +254,8 @@ function addItemUnits(
|
|||
for (const field of fields) {
|
||||
const value = item[field]
|
||||
if (value !== null && value !== undefined) {
|
||||
units.set(`${key}.${index}.${field}`, { [field]: value })
|
||||
units.set(`${key}.${index}.${field}`, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a modified textblock holds both sides of its text, told apart by the
|
||||
// added and removed marks. Each unbroken run of one mark is one change,
|
||||
// so several can sit on the same line.
|
||||
function countTextChanges(node: PMNode): ChangeCount {
|
||||
const count: ChangeCount = { removed: 0, added: 0 }
|
||||
let previous: string | null = null
|
||||
|
||||
node.descendants((child) => {
|
||||
if (!child.isText) {
|
||||
return true
|
||||
}
|
||||
|
||||
const marks = child.marks.map((mark) => mark.type.name)
|
||||
let current: string | null = null
|
||||
if (marks.includes(DIFF_TEXT_REMOVED_MARK_NAME)) {
|
||||
current = DIFF_TEXT_REMOVED_MARK_NAME
|
||||
} else if (marks.includes(DIFF_TEXT_ADDED_MARK_NAME)) {
|
||||
current = DIFF_TEXT_ADDED_MARK_NAME
|
||||
}
|
||||
|
||||
if (current === DIFF_TEXT_REMOVED_MARK_NAME && previous !== current) {
|
||||
count.removed++
|
||||
}
|
||||
|
||||
if (current === DIFF_TEXT_ADDED_MARK_NAME && previous !== current) {
|
||||
count.added++
|
||||
}
|
||||
|
||||
previous = current
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return count
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,13 +136,21 @@
|
|||
"more-indicator": "+{count}"
|
||||
},
|
||||
"figma": {
|
||||
"diff": {
|
||||
"width": "Width",
|
||||
"height": "Height"
|
||||
},
|
||||
"description": "Embed Figma",
|
||||
"placeholder": "https://www.figma.com/... + Enter",
|
||||
"edit": "Edit URL",
|
||||
"empty": "No Figma embed",
|
||||
"empty": "No Figma file",
|
||||
"iframe-title": "Figma embed"
|
||||
},
|
||||
"image": {
|
||||
"diff": {
|
||||
"image": "Image",
|
||||
"width": "Width"
|
||||
},
|
||||
"description": "Upload an image",
|
||||
"empty": "Image not uploaded",
|
||||
"uploading": "Uploading...",
|
||||
|
|
@ -164,6 +172,9 @@
|
|||
}
|
||||
},
|
||||
"mermaid": {
|
||||
"diff": {
|
||||
"more-lines": "+{count} more"
|
||||
},
|
||||
"preview": {
|
||||
"loading": "The diagram is loading...",
|
||||
"empty": "Add Mermaid code to see a diagram",
|
||||
|
|
@ -776,6 +787,13 @@
|
|||
"1d": "1d"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
"visualization": "Visualization",
|
||||
"legend": "Legend {index}",
|
||||
"base-threshold-color": "Base threshold colour",
|
||||
"custom-unit": "Custom unit",
|
||||
"size": "Size"
|
||||
},
|
||||
"simulation": {
|
||||
"tooltip": "Showing simulated data until real data is available",
|
||||
"section-title": "Simulated data",
|
||||
|
|
@ -842,7 +860,10 @@
|
|||
"diff-change-marker": {
|
||||
"label": "{removed} removed, {added} added",
|
||||
"removed": "−{count}",
|
||||
"added": "+{count}"
|
||||
"added": "+{count}",
|
||||
"empty": "empty",
|
||||
"auto": "auto",
|
||||
"pixels": "{value} px"
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user