diff --git a/public/locales/en/models.json b/public/locales/en/models.json index c7bde4a5..3a277a96 100644 --- a/public/locales/en/models.json +++ b/public/locales/en/models.json @@ -5,6 +5,28 @@ "set_default": "Set default" }, "versions_list": "Versions List", + "version_diff": { + "changes": "Changes", + "changed_files": "Files", + "initial_version": "Initial version", + "no_file_changes": "No file changes", + "no_line_changes": "No line changes", + "compared_to_previous": "Line changes from the previous version", + "added": "Added", + "removed": "Removed", + "modified": "Modified", + "unchanged": "Unchanged", + "compare": "Compare", + "compare_from": "From (base)", + "compare_to": "To", + "compare_caption": "Line changes between the selected versions", + "same_version": "Select two different versions", + "restore_preview_title": "Restore this version?", + "restore_preview_caption": "Line changes versus the current version", + "confirm_restore": "Restore", + "unknown_user": "Unknown user", + "restored_from": "Restored from {{time}}" + }, "alerts": { "no_changes": "There is no changes.", "branch_removed": "Branch removed", diff --git a/public/locales/ru/models.json b/public/locales/ru/models.json index 99713dd6..cc0cb01a 100644 --- a/public/locales/ru/models.json +++ b/public/locales/ru/models.json @@ -5,6 +5,28 @@ "set_default": "Установить по умолчанию" }, "versions_list": "Список версий", + "version_diff": { + "changes": "Изменения", + "changed_files": "Файлы", + "initial_version": "Начальная версия", + "no_file_changes": "Нет изменений в файлах", + "no_line_changes": "Нет изменений строк", + "compared_to_previous": "Изменения строк относительно предыдущей версии", + "added": "Добавлен", + "removed": "Удалён", + "modified": "Изменён", + "unchanged": "Без изменений", + "compare": "Сравнить", + "compare_from": "От (база)", + "compare_to": "До", + "compare_caption": "Изменения строк между выбранными версиями", + "same_version": "Выберите две разные версии", + "restore_preview_title": "Восстановить эту версию?", + "restore_preview_caption": "Изменения строк относительно текущей версии", + "confirm_restore": "Восстановить", + "unknown_user": "Неизвестный пользователь", + "restored_from": "Восстановлено из {{time}}" + }, "alerts": { "no_changes": "Нет изменений.", "branch_removed": "Ветка удалена", diff --git a/public/locales/zh/models.json b/public/locales/zh/models.json index 6c9cfc20..42555d84 100644 --- a/public/locales/zh/models.json +++ b/public/locales/zh/models.json @@ -5,6 +5,28 @@ "set_default": "设为默认" }, "versions_list": "版本列表", + "version_diff": { + "changes": "变更", + "changed_files": "文件", + "initial_version": "初始版本", + "no_file_changes": "文件无变更", + "no_line_changes": "无行级变更", + "compared_to_previous": "相对上一版本的行变更", + "added": "新增", + "removed": "删除", + "modified": "修改", + "unchanged": "未变更", + "compare": "比较", + "compare_from": "从(基准)", + "compare_to": "到", + "compare_caption": "所选版本之间的行变更", + "same_version": "请选择两个不同的版本", + "restore_preview_title": "恢复此版本?", + "restore_preview_caption": "相对当前版本的行变更", + "confirm_restore": "恢复", + "unknown_user": "未知用户", + "restored_from": "恢复自 {{time}}" + }, "alerts": { "no_changes": "没有更改。", "branch_removed": "分支已移除", diff --git a/src/components/VersionDiff/index.module.less b/src/components/VersionDiff/index.module.less new file mode 100644 index 00000000..d70e56db --- /dev/null +++ b/src/components/VersionDiff/index.module.less @@ -0,0 +1,73 @@ +.wrapper { + overflow: auto; + max-height: 420px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + background: #fff; +} + +.empty { + color: rgba(0, 0, 0, 0.45); + font-weight: 600; + font-size: 13px; +} + +.hunkHeader { + padding: 4px 8px; + color: #3f6587; + font-weight: 600; + font-size: 12px; + font-family: monospace; + background: #f0f4f8; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.line { + display: grid; + grid-template-columns: 46px 46px 16px minmax(0, 1fr); + align-items: stretch; + min-height: 20px; + font-size: 12px; + font-family: monospace; + line-height: 20px; +} + +.oldNo, +.newNo { + padding: 0 6px; + color: rgba(0, 0, 0, 0.35); + text-align: right; + user-select: none; +} + +.sign { + text-align: center; + user-select: none; +} + +.text { + min-width: 0; + padding-right: 8px; + white-space: pre-wrap; + word-break: break-word; +} + +.equal { + background: #fff; +} + +.add { + background: #e6ffec; + + .sign { + color: #22863a; + } +} + +.remove { + background: #ffebe9; + + .sign { + color: #cb2431; + } +} diff --git a/src/components/VersionDiff/index.stories.tsx b/src/components/VersionDiff/index.stories.tsx new file mode 100644 index 00000000..323b1f20 --- /dev/null +++ b/src/components/VersionDiff/index.stories.tsx @@ -0,0 +1,28 @@ +import RootLayout from "@/layouts/RootLayout"; +import { diffLines, toHunks } from "@/utils/helpers/versionDiff"; + +import VersionDiff from "."; + +import type { StoryFn, Meta } from "@storybook/react"; + +export default { + title: "Components/Models/VersionDiff", + component: VersionDiff, +} as Meta; + +const Template: StoryFn = (args) => ( + + + +); + +export const Default = Template.bind({}); + +Default.args = { + hunks: toHunks( + diffLines( + "cubes:\n - name: Orders\n sql: SELECT * FROM old_orders\n joins: []\n", + "cubes:\n - name: Orders\n sql: SELECT * FROM orders\n joins: []\n" + ) + ), +}; diff --git a/src/components/VersionDiff/index.test.tsx b/src/components/VersionDiff/index.test.tsx new file mode 100644 index 00000000..0bdf14b4 --- /dev/null +++ b/src/components/VersionDiff/index.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, describe } from "vitest"; + +import { diffLines, toHunks } from "@/utils/helpers/versionDiff"; + +import VersionDiff from "./"; + +describe("VersionDiff Component", () => { + test("renders an empty state when there are no hunks", () => { + render(); + expect(screen.getByText("version_diff.no_line_changes")).toBeDefined(); + }); + + test("renders changed lines with line numbers", () => { + const hunks = toHunks( + diffLines("cubes:\n sql: SELECT 1", "cubes:\n sql: SELECT 2") + ); + + render(); + + expect(screen.getByText(" sql: SELECT 1")).toBeDefined(); + expect(screen.getByText(" sql: SELECT 2")).toBeDefined(); + expect(screen.getByText("@@ -1,2 +1,2 @@")).toBeDefined(); + }); +}); diff --git a/src/components/VersionDiff/index.tsx b/src/components/VersionDiff/index.tsx new file mode 100644 index 00000000..1ab0a213 --- /dev/null +++ b/src/components/VersionDiff/index.tsx @@ -0,0 +1,65 @@ +import cn from "classnames"; +import { useTranslation } from "react-i18next"; + +import type { DiffHunk, DiffLineType } from "@/utils/helpers/versionDiff"; + +import styles from "./index.module.less"; + +import type { FC } from "react"; + +interface VersionDiffProps { + hunks: DiffHunk[]; +} + +const signForType = (type: DiffLineType): string => { + if (type === "add") { + return "+"; + } + + if (type === "remove") { + return "-"; + } + + return " "; +}; + +const VersionDiff: FC = ({ hunks }) => { + const { t } = useTranslation(["models"]); + + if (!hunks.length) { + return ( +
{t("version_diff.no_line_changes")}
+ ); + } + + return ( +
+ {hunks.map((hunk) => ( +
+
+ {`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`} +
+ {hunk.lines.map((line) => ( +
+ {line.oldNumber ?? ""} + {line.newNumber ?? ""} + {signForType(line.type)} + {line.text} +
+ ))} +
+ ))} +
+ ); +}; + +export default VersionDiff; diff --git a/src/components/VersionsList/index.module.less b/src/components/VersionsList/index.module.less index e2a71328..ec0c43ff 100644 --- a/src/components/VersionsList/index.module.less +++ b/src/components/VersionsList/index.module.less @@ -10,15 +10,54 @@ border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important; } +.checksumBlock { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + .checksum { font-weight: 700; font-size: 14px; + font-family: monospace; + word-break: break-all; +} + +.restoredChecksum { + color: #1677ff; +} + +.restoreTag { + margin-inline-end: 0 !important; +} + +.author { + color: rgba(0, 0, 0, 0.85); + font-weight: 600; +} + +.authorName { + color: rgba(0, 0, 0, 0.85); + font-weight: 700; + font-size: 13px; +} + +.createdBlock { + display: flex; + flex-direction: column; + gap: 2px; } -.author, .createdAt { color: rgba(0, 0, 0, 0.5); font-weight: 600; + font-size: 12px; + white-space: nowrap; +} + +.relativeTime { + color: rgba(0, 0, 0, 0.4); } .actions { @@ -47,6 +86,61 @@ font-size: 14px; } +.changeStats { + white-space: nowrap; + font-variant-numeric: tabular-nums; + font-weight: 700; + font-size: 13px; +} + +.addedCount { + color: #22863a; +} + +.removedCount { + margin-left: 8px; + color: #cb2431; +} + +.filesHint { + display: block; + margin-top: 2px; + color: rgba(0, 0, 0, 0.45); + font-weight: 600; + font-size: 12px; +} + +.initialVersion, +.noChanges { + color: rgba(0, 0, 0, 0.45); + font-weight: 600; + font-size: 13px; +} + +.diffSection { + width: 100%; +} + +.diffCaption { + color: rgba(0, 0, 0, 0.45); + font-weight: 600; + font-size: 12px; +} + +.compareBar { + width: 100%; +} + +.compareLabel { + color: rgba(0, 0, 0, 0.65); + font-weight: 700; + font-size: 13px; +} + +.compareSelect { + min-width: 240px; +} + .save { min-width: 141px; } diff --git a/src/components/VersionsList/index.test.tsx b/src/components/VersionsList/index.test.tsx index 249b3705..b863eab2 100644 --- a/src/components/VersionsList/index.test.tsx +++ b/src/components/VersionsList/index.test.tsx @@ -3,29 +3,38 @@ import { expect, vi, test, describe } from "vitest"; import VersionsList from "./"; -// Mock data const mockVersions = [ { id: "1", checksum: "checksum1", user: { avatarUrl: "url1", display_name: "user1" }, - created_at: "2022-01-01", - dataschemas: [{ name: "schema1", code: "code1" }], + created_at: "2022-01-02", + dataschemas: [ + { + name: "Orders.yml", + code: "cubes:\n - name: Orders\n sql: SELECT 2", + }, + ], }, { id: "2", checksum: "checksum2", user: { avatarUrl: "url2", display_name: "user2" }, - created_at: "2022-01-02", - dataschemas: [{ name: "schema2", code: "code2" }], + created_at: "2022-01-01", + dataschemas: [ + { + name: "Orders.yml", + code: "cubes:\n - name: Orders\n sql: SELECT 1", + }, + ], }, ]; -// Mock hooks vi.mock("@/hooks/useVersions", () => ({ __esModule: true, default: () => ({ versions: mockVersions, + currentVersion: mockVersions[0], totalCount: 2, queries: { allData: { fetching: false } }, }), @@ -39,6 +48,11 @@ vi.mock("@/hooks/useTableState", () => ({ }), })); +vi.mock("@/hooks/useVersionCompare", () => ({ + useVersionOptions: () => ({ options: mockVersions, fetching: false }), + useVersionsWithCode: () => ({ versions: mockVersions, fetching: false }), +})); + describe("VersionsList Component", () => { test("renders the VersionsList component", () => { render( {}} />); @@ -46,20 +60,54 @@ describe("VersionsList Component", () => { expect(titleElement).toBeDefined(); }); - test("renders the Button component for each version", () => { + test("hides restore on the current version", () => { render( {}} />); const buttonElements = screen.getAllByText("common:words.restore"); - expect(buttonElements.length).toBe(mockVersions.length); + expect(buttonElements.length).toBe(1); + expect(screen.getByText("common:words.current")).toBeDefined(); + }); + + test("shows author, timestamp, and relative time", () => { + render( {}} />); + expect(screen.getByText("user1")).toBeDefined(); + expect(screen.getByText("user2")).toBeDefined(); + expect(screen.getByText(/2022-01-02/)).toBeDefined(); + expect(screen.getByText(/2022-01-01/)).toBeDefined(); }); - test("calls onRestore when the restore button is clicked", () => { + test("shows line-change totals against the previous version", () => { + render( {}} />); + expect(screen.getByText("+1")).toBeDefined(); + expect(screen.getByText("-1")).toBeDefined(); + expect(screen.getByText("version_diff.initial_version")).toBeDefined(); + }); + + test("previews restore then confirms", () => { const mockOnRestore = vi.fn(); render(); - const buttonElement = screen.getAllByText("common:words.restore")[0]; - fireEvent.click(buttonElement); + fireEvent.click(screen.getByText("common:words.restore")); + expect(mockOnRestore).not.toHaveBeenCalled(); + expect( + screen.getByText("version_diff.restore_preview_title") + ).toBeDefined(); + fireEvent.click(screen.getByTestId("confirm-restore")); expect(mockOnRestore).toHaveBeenCalledWith( - mockVersions[0].checksum, - mockVersions[0].dataschemas + mockVersions[1].checksum, + mockVersions[1].dataschemas + ); + }); + + test("shows changed lines when a version row is expanded", () => { + const { container } = render( {}} />); + const expandButton = container.querySelector( + "button.ant-table-row-expand-icon" ); + + expect(expandButton).toBeTruthy(); + fireEvent.click(expandButton as Element); + + expect(screen.getByText("Orders.yml")).toBeDefined(); + expect(screen.getByText(" sql: SELECT 1")).toBeDefined(); + expect(screen.getByText(" sql: SELECT 2")).toBeDefined(); }); }); diff --git a/src/components/VersionsList/index.tsx b/src/components/VersionsList/index.tsx index 9b591478..6a14824f 100644 --- a/src/components/VersionsList/index.tsx +++ b/src/components/VersionsList/index.tsx @@ -1,14 +1,28 @@ -import { Space, Table, Typography } from "antd"; +import { useMemo, useState } from "react"; +import { Modal, Select, Space, Table, Tag, Tooltip, Typography } from "antd"; import { useTranslation } from "react-i18next"; +import cn from "classnames"; import Avatar from "@/components/Avatar"; import Button from "@/components/Button"; -import Copy from "@/components/Copy"; -import formatTime from "@/utils/helpers/formatTime"; +import VersionDiff from "@/components/VersionDiff"; +import formatTime, { formatRelativeTime } from "@/utils/helpers/formatTime"; +import { + diffSchemaVersions, + restoreRelations, + shortChecksum, + type FileChange, + type NamedCode, + type SchemaDiff, +} from "@/utils/helpers/versionDiff"; import type { Dataschema } from "@/types/dataschema"; import type { Version } from "@/types/version"; import useVersions from "@/hooks/useVersions"; import useTableState from "@/hooks/useTableState"; +import { + useVersionOptions, + useVersionsWithCode, +} from "@/hooks/useVersionCompare"; import DocsIcon from "@/assets/docs.svg"; import YAMLIcon from "@/assets/yml-flie.svg"; @@ -19,14 +33,97 @@ import type { FC } from "react"; import type { TableProps } from "antd"; const { Title } = Typography; +const EMPTY_IDS: string[] = []; + +type VersionUser = { + display_name?: string | null; + avatarUrl?: string | null; + account?: { email?: string | null } | null; +}; + +const authorLabel = (user?: VersionUser | null, fallback = "") => + user?.display_name?.trim() || user?.account?.email || fallback; interface VersionsListProps { branch?: string; onRestore: (checksum: string, dataschemas: Dataschema[]) => void; } +const kindTagColor: Record = { + added: "success", + removed: "error", + modified: "processing", + unchanged: "default", +}; + +const filesFromVersion = ( + version?: { dataschemas?: NamedCode[] | null } | null +): NamedCode[] => version?.dataschemas ?? []; + +const VersionFileChanges: FC<{ + diff?: SchemaDiff; + caption: string; + loading?: boolean; +}> = ({ diff, caption, loading }) => { + const { t } = useTranslation(["models"]); + const files = diff?.changedFiles ?? []; + + if (!files.length) { + return ( +
+ {t("version_diff.no_file_changes")} +
+ ); + } + + const expandedColumns: TableProps["columns"] = [ + { + key: "name", + dataIndex: "name", + render: (value, file) => ( + + + {value} + + {t(`version_diff.${file.kind}`)} + + + ), + }, + { + key: "lines", + render: (_, file) => ( + + +{file.added} + -{file.removed} + + ), + }, + ]; + + return ( + + {caption} + file.name} + expandable={{ + expandedRowRender: (file) => , + defaultExpandedRowKeys: files.length === 1 ? [files[0].name] : [], + }} + pagination={false} + loading={loading} + /> + + ); +}; + const VersionsList: FC = ({ onRestore, branch }) => { const { t } = useTranslation(["models", "common"]); + const [restoreTarget, setRestoreTarget] = useState(null); + const [compareFromId, setCompareFromId] = useState(); + const [compareToId, setCompareToId] = useState(); const { tableState: { paginationVars, pageSize, currentPage }, @@ -35,97 +132,346 @@ const VersionsList: FC = ({ onRestore, branch }) => { const { versions, + currentVersion, totalCount, queries: { allData: { fetching }, }, } = useVersions({ branchId: branch, - pagination: paginationVars, + pagination: { + ...paginationVars, + limit: pageSize + 1, + }, + }); + + const { options: versionOptions } = useVersionOptions(branch); + const fetchCompareIds = useMemo(() => { + if (!compareFromId || !compareToId || compareFromId === compareToId) { + return EMPTY_IDS; + } + + return [compareFromId, compareToId]; + }, [compareFromId, compareToId]); + const { versions: fetchedCompareVersions, fetching: compareFetching } = + useVersionsWithCode(fetchCompareIds); + + const displayedVersions = useMemo( + () => versions.slice(0, pageSize), + [versions, pageSize] + ); + + const isFirstPage = (paginationVars.offset ?? 0) === 0; + const liveVersion = + currentVersion ?? (isFirstPage ? displayedVersions[0] : undefined); + + const versionsById = useMemo(() => { + const map = new Map(); + + versions.forEach((version) => map.set(version.id, version)); + if (liveVersion) { + map.set(liveVersion.id, liveVersion); + } + fetchedCompareVersions.forEach((version) => map.set(version.id, version)); + if (restoreTarget) { + map.set(restoreTarget.id, restoreTarget); + } + + return map; + }, [fetchedCompareVersions, liveVersion, restoreTarget, versions]); + + const diffsById = useMemo(() => { + const diffs = new Map(); + + displayedVersions.forEach((version, index) => { + diffs.set( + version.id, + diffSchemaVersions( + version.dataschemas, + versions[index + 1]?.dataschemas + ) + ); + }); + + return diffs; + }, [displayedVersions, versions]); + + const restoreMetaById = useMemo(() => { + const catalog = [ + ...versionOptions, + ...versions, + ...displayedVersions, + liveVersion, + ].flatMap((version) => { + if (!version?.id || !version.checksum || !version.created_at) { + return []; + } + + return [ + { + id: version.id, + checksum: version.checksum, + created_at: version.created_at, + }, + ]; + }); + + return restoreRelations(catalog); + }, [displayedVersions, liveVersion, versionOptions, versions]); + + const isLiveVersion = (record: Version) => + Boolean(liveVersion?.id && record.id === liveVersion.id); + + const matchesLiveChecksum = (record: Version) => + Boolean( + liveVersion?.checksum && + record.checksum && + record.checksum === liveVersion.checksum + ); + + const restorePreviewDiff = useMemo(() => { + if (!restoreTarget) { + return undefined; + } + + return diffSchemaVersions( + filesFromVersion(restoreTarget), + filesFromVersion(liveVersion) + ); + }, [liveVersion, restoreTarget]); + + const compareDiff = useMemo(() => { + if (!compareFromId || !compareToId || compareFromId === compareToId) { + return undefined; + } + + const fromVersion = versionsById.get(compareFromId); + const toVersion = versionsById.get(compareToId); + + if (!fromVersion || !toVersion) { + return undefined; + } + + return diffSchemaVersions( + filesFromVersion(toVersion), + filesFromVersion(fromVersion) + ); + }, [compareFromId, compareToId, versionsById]); + + const compareSelectOptions = ( + versionOptions.length ? versionOptions : displayedVersions + ).map((version) => { + const name = authorLabel(version.user as VersionUser | null | undefined); + return { + value: version.id, + label: `${shortChecksum(version.checksum)} · ${formatTime( + version.created_at, + "YYYY-MM-DD HH:mm" + )} (${formatRelativeTime(version.created_at)})${ + name ? ` · ${name}` : "" + }`, + }; }); + const renderChangeSummary = (diff?: SchemaDiff) => { + if (!diff) { + return null; + } + + if (diff.isInitial) { + return ( + + {t("version_diff.initial_version")} + + ); + } + + if (!diff.changedFiles.length) { + return ( + + {t("version_diff.no_file_changes")} + + ); + } + + return ( +
+ +{diff.addedLines} + -{diff.removedLines} + + {t("version_diff.changed_files")}: {diff.changedFiles.length} + +
+ ); + }; + const columns: TableProps["columns"] = [ { title: t("common:words.checksum"), dataIndex: "checksum", key: "checksum", - render: (value) => {value}, - }, - { - title: t("common:words.author"), - dataIndex: "user", - key: "user", - render: (value) => ( - - {value && ( - - )} - {value?.display_name} - - ), + render: (value, record) => { + const restoreMeta = restoreMetaById.get(record.id); + const restoredFrom = restoreMeta?.restoredFrom; + + return ( +
+ + + {value} + + {isLiveVersion(record) && ( + {t("common:words.current")} + )} + + {restoredFrom && ( + + + {t("models:version_diff.restored_from", { + defaultValue: "Restored from {{time}}", + time: formatTime( + restoredFrom.created_at, + "YYYY-MM-DD HH:mm" + ), + })} + + + )} +
+ ); + }, }, { title: t("common:words.created_at"), dataIndex: "created_at", key: "created_at", - render: (value) => ( - {formatTime(value)} - ), + render: (value, record) => { + const user = record.user as VersionUser | null | undefined; + const name = authorLabel(user, t("version_diff.unknown_user")); + + return ( +
+ + + {name} + + + {formatTime(value, "YYYY-MM-DD HH:mm")}{" "} + + ({formatRelativeTime(value)}) + + +
+ ); + }, + }, + { + title: t("version_diff.changes"), + key: "changes", + render: (_, record) => renderChangeSummary(diffsById.get(record.id)), }, { title:
{t("common:words.actions")}
, dataIndex: "actions", key: "actions", - render: (_, record) => ( -
- -
- ), + render: (_, record) => + isLiveVersion(record) || matchesLiveChecksum(record) ? ( +
+ ) : ( +
+ +
+ ), }, ]; - const renderFileValue = (record: Dataschema) => { - return ; - }; - const expandedRowRender = (record: Version) => { - const expandedColumns: TableProps["columns"] = [ - { - key: "name", - dataIndex: "name", - render: (value) => ( - - {value} - - ), - }, - ]; + const diff = diffsById.get(record.id); return ( -
rec.name} - expandable={{ expandedRowRender: renderFileValue }} - pagination={false} + ); }; + const confirmRestore = () => { + if (!restoreTarget) { + return; + } + + const target = restoreTarget; + setRestoreTarget(null); + onRestore(target.checksum, target.dataschemas); + }; + return ( {t("versions_list")} + {totalCount > 1 && ( + + + {t("version_diff.compare")} + + + + )} + {compareFromId && compareToId && compareFromId === compareToId && ( +
{t("version_diff.same_version")}
+ )} + {compareDiff && ( + + )}
record.id} expandable={{ expandedRowRender }} pagination={{ @@ -137,6 +483,31 @@ const VersionsList: FC = ({ onRestore, branch }) => { }} loading={fetching} /> + setRestoreTarget(null)} + zIndex={2000} + width={920} + footer={[ + , + , + ]} + > + + ); }; diff --git a/src/graphql/gql/versions.gql b/src/graphql/gql/versions.gql index 3bab1fed..b18767f1 100644 --- a/src/graphql/gql/versions.gql +++ b/src/graphql/gql/versions.gql @@ -17,6 +17,9 @@ query versionByBranchId($branch_id: uuid!, $limit: Int, $offset: Int) { created_at user { display_name + account { + email + } } dataschemas(order_by: { name: asc }) { created_at @@ -29,6 +32,9 @@ query versionByBranchId($branch_id: uuid!, $limit: Int, $offset: Int) { checksum user { display_name + account { + email + } } datasource { name @@ -53,6 +59,9 @@ query CurrentVersion($branch_id: uuid!) { checksum user { display_name + account { + email + } } dataschemas(order_by: { name: asc }) { created_at @@ -65,6 +74,9 @@ query CurrentVersion($branch_id: uuid!) { checksum user { display_name + account { + email + } } datasource { name @@ -95,7 +107,46 @@ query FirstDataschemaAppearance($branch_id: uuid!, $name: String!) { user_id user { display_name + account { + email + } + } + } + } +} + +query VersionOptionsByBranchId($branch_id: uuid!) { + versions( + order_by: { created_at: desc } + where: { branch_id: { _eq: $branch_id } } + limit: 100 + ) { + id + checksum + created_at + user { + display_name + account { + email } } } } + +query VersionsWithCodeByIds($ids: [uuid!]!) { + versions(where: { id: { _in: $ids } }) { + id + checksum + created_at + user { + display_name + account { + email + } + } + dataschemas(order_by: { name: asc }) { + name + code + } + } +} diff --git a/src/hooks/useVersionCompare.ts b/src/hooks/useVersionCompare.ts new file mode 100644 index 00000000..00cff0f5 --- /dev/null +++ b/src/hooks/useVersionCompare.ts @@ -0,0 +1,81 @@ +import { gql, useQuery } from "urql"; + +export interface VersionOption { + id: string; + checksum: string; + created_at: string; + user?: { + display_name?: string | null; + account?: { email?: string | null } | null; + } | null; +} + +export interface VersionWithCode extends VersionOption { + dataschemas: Array<{ name: string; code: string }>; +} + +const VERSION_OPTIONS_QUERY = gql` + query VersionOptionsByBranchId($branch_id: uuid!) { + versions( + order_by: { created_at: desc } + where: { branch_id: { _eq: $branch_id } } + limit: 100 + ) { + id + checksum + created_at + user { + display_name + account { + email + } + } + } + } +`; + +const VERSIONS_WITH_CODE_QUERY = gql` + query VersionsWithCodeByIds($ids: [uuid!]!) { + versions(where: { id: { _in: $ids } }) { + id + checksum + created_at + user { + display_name + account { + email + } + } + dataschemas(order_by: { name: asc }) { + name + code + } + } + } +`; + +export const useVersionOptions = (branchId?: string) => { + const [result] = useQuery<{ versions: VersionOption[] }>({ + query: VERSION_OPTIONS_QUERY, + variables: { branch_id: branchId }, + pause: !branchId, + }); + + return { + options: result.data?.versions ?? [], + fetching: result.fetching, + }; +}; + +export const useVersionsWithCode = (ids: string[]) => { + const [result] = useQuery<{ versions: VersionWithCode[] }>({ + query: VERSIONS_WITH_CODE_QUERY, + variables: { ids }, + pause: ids.length === 0, + }); + + return { + versions: result.data?.versions ?? [], + fetching: result.fetching, + }; +}; diff --git a/src/hooks/useVersions.ts b/src/hooks/useVersions.ts index 67d3bb28..e12c64ee 100644 --- a/src/hooks/useVersions.ts +++ b/src/hooks/useVersions.ts @@ -1,11 +1,55 @@ import { useEffect, useMemo } from "react"; +import { gql, useQuery } from "urql"; -import { useVersionByBranchIdQuery } from "@/graphql/generated"; +import { useCurrentVersionQuery } from "@/graphql/generated"; import type { VersionByBranchIdQueryVariables } from "@/graphql/generated"; import type { Version } from "@/types/version"; type Pagination = Omit; +const VERSION_BY_BRANCH_ID = gql` + query versionByBranchId($branch_id: uuid!, $limit: Int, $offset: Int) { + versions( + limit: $limit + offset: $offset + order_by: { created_at: desc } + where: { branch_id: { _eq: $branch_id } } + ) { + id + checksum + updated_at + created_at + user { + display_name + account { + email + } + } + dataschemas(order_by: { name: asc }) { + created_at + updated_at + datasource_id + id + user_id + name + code + checksum + user { + display_name + } + datasource { + name + } + } + } + versions_aggregate(where: { branch_id: { _eq: $branch_id } }) { + aggregate { + count + } + } + } +`; + const getListVariables = ( branchId?: string, pagination?: Partial @@ -30,17 +74,27 @@ interface Props { } export default ({ branchId, pagination }: Props) => { - const [allData, execQueryAll] = useVersionByBranchIdQuery({ + const [allData, execQueryAll] = useQuery({ + query: VERSION_BY_BRANCH_ID, variables: getListVariables(branchId, pagination), pause: true, requestPolicy: "cache-and-network", }); + const [currentData] = useCurrentVersionQuery({ + variables: { branch_id: branchId as string }, + pause: !branchId, + requestPolicy: "cache-and-network", + }); + + const paginationLimit = pagination?.limit; + const paginationOffset = pagination?.offset; + useEffect(() => { if (branchId) { execQueryAll(); } - }, [branchId, execQueryAll]); + }, [branchId, paginationLimit, paginationOffset, execQueryAll]); const versions = useMemo( () => allData.data?.versions || ([] as Version[]), @@ -50,9 +104,11 @@ export default ({ branchId, pagination }: Props) => { () => allData.data?.versions_aggregate.aggregate?.count || 0, [allData.data?.versions_aggregate.aggregate?.count] ); + const currentVersion = currentData.data?.versions?.[0] ?? null; return { versions, + currentVersion, totalCount, queries: { allData, diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index 12cbbafe..b8c757b4 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -334,7 +334,7 @@ export const Models: React.FC = ({ )} diff --git a/src/utils/helpers/__tests__/versionDiff.test.ts b/src/utils/helpers/__tests__/versionDiff.test.ts new file mode 100644 index 00000000..a342a0e6 --- /dev/null +++ b/src/utils/helpers/__tests__/versionDiff.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { + diffLines, + diffSchemaVersions, + restoreRelations, + shortChecksum, + splitLines, + toHunks, +} from "../versionDiff"; + +describe("splitLines", () => { + it("returns an empty array for blank input", () => { + expect(splitLines("")).toEqual([]); + }); + + it("normalizes Windows newlines", () => { + expect(splitLines("a\r\nb")).toEqual(["a", "b"]); + }); +}); + +describe("diffLines", () => { + it("marks identical files as equal", () => { + const lines = diffLines( + "cubes:\n - name: Orders", + "cubes:\n - name: Orders" + ); + + expect(lines).toEqual([ + { type: "equal", text: "cubes:", oldNumber: 1, newNumber: 1 }, + { type: "equal", text: " - name: Orders", oldNumber: 2, newNumber: 2 }, + ]); + }); + + it("reports a changed line with surrounding equals", () => { + const previous = "cubes:\n - name: Orders\n sql: SELECT 1"; + const current = "cubes:\n - name: Orders\n sql: SELECT 2"; + const lines = diffLines(previous, current); + + expect(lines).toEqual([ + { type: "equal", text: "cubes:", oldNumber: 1, newNumber: 1 }, + { type: "equal", text: " - name: Orders", oldNumber: 2, newNumber: 2 }, + { + type: "remove", + text: " sql: SELECT 1", + oldNumber: 3, + newNumber: null, + }, + { type: "add", text: " sql: SELECT 2", oldNumber: null, newNumber: 3 }, + ]); + }); + + it("treats a new file as added lines", () => { + const lines = diffLines("", "a\nb"); + + expect(lines.map((line) => line.type)).toEqual(["add", "add"]); + expect(lines[0]).toMatchObject({ + text: "a", + newNumber: 1, + oldNumber: null, + }); + }); + + it("falls back to replace when the LCS table would be too large", () => { + const lines = diffLines("a\nb", "c\nd", 1); + + expect(lines.map((line) => line.type)).toEqual([ + "remove", + "remove", + "add", + "add", + ]); + }); +}); + +describe("toHunks", () => { + it("returns no hunks when nothing changed", () => { + expect(toHunks(diffLines("a\nb", "a\nb"))).toEqual([]); + }); + + it("keeps a few unchanged lines around a change", () => { + const previous = ["a", "b", "c", "d", "old", "e", "f", "g", "h"].join("\n"); + const current = ["a", "b", "c", "d", "new", "e", "f", "g", "h"].join("\n"); + const hunks = toHunks(diffLines(previous, current), 2); + + expect(hunks).toHaveLength(1); + expect(hunks[0].lines.map((line) => line.text)).toEqual([ + "c", + "d", + "old", + "new", + "e", + "f", + ]); + expect(hunks[0].oldStart).toBe(3); + expect(hunks[0].newStart).toBe(3); + }); +}); + +describe("diffSchemaVersions", () => { + it("treats a missing previous snapshot as the initial version", () => { + const diff = diffSchemaVersions([ + { name: "Orders.yml", code: "cubes:\n - name: Orders" }, + ]); + + expect(diff.isInitial).toBe(true); + expect(diff.changedFiles).toHaveLength(1); + expect(diff.changedFiles[0].kind).toBe("added"); + expect(diff.addedLines).toBe(2); + expect(diff.removedLines).toBe(0); + }); + + it("classifies added, removed, and modified files", () => { + const diff = diffSchemaVersions( + [ + { name: "Orders.yml", code: "sql: SELECT 2" }, + { name: "Users.yml", code: "sql: SELECT 1" }, + ], + [ + { name: "Orders.yml", code: "sql: SELECT 1" }, + { name: "LineItems.yml", code: "sql: SELECT 1" }, + ] + ); + + expect(diff.isInitial).toBe(false); + expect(diff.changedFiles.map((file) => [file.name, file.kind])).toEqual([ + ["LineItems.yml", "removed"], + ["Orders.yml", "modified"], + ["Users.yml", "added"], + ]); + expect(diff.addedLines).toBe(2); + expect(diff.removedLines).toBe(2); + }); + + it("ignores newline-only differences", () => { + const diff = diffSchemaVersions( + [{ name: "Orders.yml", code: "cubes:\r\n - name: Orders" }], + [{ name: "Orders.yml", code: "cubes:\n - name: Orders" }] + ); + + expect(diff.changedFiles).toEqual([]); + expect(diff.files[0].kind).toBe("unchanged"); + }); +}); + +describe("shortChecksum", () => { + it("shortens long hashes and keeps short values", () => { + expect(shortChecksum("abcdefghijklmnop")).toBe("abcdefgh"); + expect(shortChecksum("abc")).toBe("abc"); + expect(shortChecksum("")).toBe(""); + }); +}); + +describe("restoreRelations", () => { + it("marks later identical checksums as restores of the older snapshot", () => { + const relations = restoreRelations([ + { id: "old", checksum: "aaa", created_at: "2026-04-28T13:54:00Z" }, + { id: "edit", checksum: "bbb", created_at: "2026-09-14T13:43:00Z" }, + { id: "new", checksum: "aaa", created_at: "2026-09-14T13:44:00Z" }, + ]); + + expect(relations.get("new")?.restoredFrom?.id).toBe("old"); + expect(relations.get("old")?.isRestoreSource).toBe(true); + expect(relations.get("edit")?.restoredFrom).toBeUndefined(); + expect(relations.get("edit")?.isRestoreSource).toBe(false); + }); +}); diff --git a/src/utils/helpers/formatTime.ts b/src/utils/helpers/formatTime.ts index 424a2372..6abf739b 100644 --- a/src/utils/helpers/formatTime.ts +++ b/src/utils/helpers/formatTime.ts @@ -2,6 +2,16 @@ import moment from "moment"; export const DEFAULT_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss"; +export const formatRelativeTime = (timestamp: string): string => { + const parsed = moment(timestamp); + + if (!parsed.isValid()) { + return timestamp; + } + + return parsed.fromNow(); +}; + export default (timestamp: string, format?: string) => { const result = moment(timestamp).format(format || DEFAULT_TIME_FORMAT); diff --git a/src/utils/helpers/versionDiff.ts b/src/utils/helpers/versionDiff.ts new file mode 100644 index 00000000..6c0da97f --- /dev/null +++ b/src/utils/helpers/versionDiff.ts @@ -0,0 +1,463 @@ +export type DiffLineType = "equal" | "add" | "remove"; + +export interface DiffLine { + type: DiffLineType; + text: string; + oldNumber: number | null; + newNumber: number | null; +} + +export interface DiffHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffLine[]; +} + +export type FileChangeKind = "added" | "removed" | "modified" | "unchanged"; + +export interface NamedCode { + name: string; + code?: string | null; +} + +export interface FileChange { + name: string; + kind: FileChangeKind; + previousCode: string; + currentCode: string; + hunks: DiffHunk[]; + added: number; + removed: number; +} + +export interface SchemaDiff { + isInitial: boolean; + files: FileChange[]; + changedFiles: FileChange[]; + addedLines: number; + removedLines: number; +} + +export const shortChecksum = (checksum?: string | null, length = 8): string => { + if (!checksum) { + return ""; + } + + if (checksum.length <= length) { + return checksum; + } + + return checksum.slice(0, length); +}; + +export interface VersionChecksumRef { + id: string; + checksum: string; + created_at: string; +} + +export interface RestoreRelation { + restoredFrom?: VersionChecksumRef; + isRestoreSource: boolean; +} + +export const restoreRelations = ( + versions: VersionChecksumRef[] +): Map => { + const unique = new Map(); + + versions.forEach((version) => { + if (version?.id && version.checksum && version.created_at) { + unique.set(version.id, version); + } + }); + + const byChecksum = new Map(); + + unique.forEach((version) => { + const group = byChecksum.get(version.checksum) ?? []; + group.push(version); + byChecksum.set(version.checksum, group); + }); + + const relations = new Map(); + + unique.forEach((version) => { + relations.set(version.id, { isRestoreSource: false }); + }); + + byChecksum.forEach((group) => { + const sorted = [...group].sort( + (left, right) => + new Date(left.created_at).getTime() - + new Date(right.created_at).getTime() + ); + + if (sorted.length < 2) { + return; + } + + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index]; + const previous = sorted[index - 1]; + relations.set(current.id, { + restoredFrom: previous, + isRestoreSource: index < sorted.length - 1, + }); + const previousRelation = relations.get(previous.id) ?? { + isRestoreSource: false, + }; + relations.set(previous.id, { + ...previousRelation, + isRestoreSource: true, + }); + } + }); + + return relations; +}; + +const DEFAULT_MAX_LCS_CELLS = 400000; +const DEFAULT_CONTEXT = 3; + +export const splitLines = (value: string): string[] => { + if (!value) { + return []; + } + + return value.replace(/\r\n/g, "\n").split("\n"); +}; + +const normalizeNewlines = (value: string): string => + value.replace(/\r\n/g, "\n"); + +const countLineChanges = ( + lines: DiffLine[] +): { added: number; removed: number } => { + let added = 0; + let removed = 0; + + for (const line of lines) { + if (line.type === "add") { + added += 1; + } else if (line.type === "remove") { + removed += 1; + } + } + + return { added, removed }; +}; + +const withLineNumbers = ( + ops: Array<{ type: DiffLineType; text: string }> +): DiffLine[] => { + let oldNumber = 0; + let newNumber = 0; + + return ops.map((op) => { + if (op.type === "add") { + newNumber += 1; + return { + type: op.type, + text: op.text, + oldNumber: null, + newNumber, + }; + } + + if (op.type === "remove") { + oldNumber += 1; + return { + type: op.type, + text: op.text, + oldNumber, + newNumber: null, + }; + } + + oldNumber += 1; + newNumber += 1; + return { + type: op.type, + text: op.text, + oldNumber, + newNumber, + }; + }); +}; + +const diffMiddle = ( + previous: string[], + current: string[], + maxLcsCells: number +): Array<{ type: DiffLineType; text: string }> => { + const n = previous.length; + const m = current.length; + + if (!n && !m) { + return []; + } + + if (!n) { + return current.map((text) => ({ type: "add" as const, text })); + } + + if (!m) { + return previous.map((text) => ({ type: "remove" as const, text })); + } + + if (n * m > maxLcsCells) { + return [ + ...previous.map((text) => ({ type: "remove" as const, text })), + ...current.map((text) => ({ type: "add" as const, text })), + ]; + } + + const dp: Int16Array[] = new Array(n + 1); + for (let i = 0; i <= n; i += 1) { + dp[i] = new Int16Array(m + 1); + } + + for (let i = 1; i <= n; i += 1) { + const prevLine = previous[i - 1]; + const row = dp[i]; + const prevRow = dp[i - 1]; + + for (let j = 1; j <= m; j += 1) { + if (prevLine === current[j - 1]) { + row[j] = prevRow[j - 1] + 1; + } else { + row[j] = prevRow[j] >= row[j - 1] ? prevRow[j] : row[j - 1]; + } + } + } + + const ops: Array<{ type: DiffLineType; text: string }> = []; + let i = n; + let j = m; + + while (i > 0 && j > 0) { + if (previous[i - 1] === current[j - 1]) { + ops.push({ type: "equal", text: previous[i - 1] }); + i -= 1; + j -= 1; + } else if (dp[i][j - 1] >= dp[i - 1][j]) { + ops.push({ type: "add", text: current[j - 1] }); + j -= 1; + } else { + ops.push({ type: "remove", text: previous[i - 1] }); + i -= 1; + } + } + + while (i > 0) { + ops.push({ type: "remove", text: previous[i - 1] }); + i -= 1; + } + + while (j > 0) { + ops.push({ type: "add", text: current[j - 1] }); + j -= 1; + } + + ops.reverse(); + return ops; +}; + +export const diffLines = ( + previous: string, + current: string, + maxLcsCells = DEFAULT_MAX_LCS_CELLS +): DiffLine[] => { + const previousLines = splitLines(previous); + const currentLines = splitLines(current); + + if (previousLines.length === currentLines.length) { + let identical = true; + for (let i = 0; i < previousLines.length; i += 1) { + if (previousLines[i] !== currentLines[i]) { + identical = false; + break; + } + } + if (identical) { + return withLineNumbers( + previousLines.map((text) => ({ type: "equal" as const, text })) + ); + } + } + + let start = 0; + while ( + start < previousLines.length && + start < currentLines.length && + previousLines[start] === currentLines[start] + ) { + start += 1; + } + + let previousEnd = previousLines.length; + let currentEnd = currentLines.length; + while ( + previousEnd > start && + currentEnd > start && + previousLines[previousEnd - 1] === currentLines[currentEnd - 1] + ) { + previousEnd -= 1; + currentEnd -= 1; + } + + const prefix = previousLines + .slice(0, start) + .map((text) => ({ type: "equal" as const, text })); + const middle = diffMiddle( + previousLines.slice(start, previousEnd), + currentLines.slice(start, currentEnd), + maxLcsCells + ); + const suffix = previousLines + .slice(previousEnd) + .map((text) => ({ type: "equal" as const, text })); + + return withLineNumbers([...prefix, ...middle, ...suffix]); +}; + +export const toHunks = ( + lines: DiffLine[], + context = DEFAULT_CONTEXT +): DiffHunk[] => { + if (!lines.length) { + return []; + } + + const include = lines.map((line) => line.type !== "equal"); + if (!include.some(Boolean)) { + return []; + } + + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].type === "equal") { + continue; + } + + const from = Math.max(0, i - context); + const to = Math.min(lines.length - 1, i + context); + for (let j = from; j <= to; j += 1) { + include[j] = true; + } + } + + const hunks: DiffHunk[] = []; + let index = 0; + + while (index < lines.length) { + if (!include[index]) { + index += 1; + continue; + } + + const start = index; + while (index < lines.length && include[index]) { + index += 1; + } + + const chunk = lines.slice(start, index); + const oldLines = chunk.filter((line) => line.type !== "add"); + const newLines = chunk.filter((line) => line.type !== "remove"); + const firstOld = oldLines.find((line) => line.oldNumber !== null); + const firstNew = newLines.find((line) => line.newNumber !== null); + + hunks.push({ + oldStart: firstOld?.oldNumber ?? 0, + oldCount: oldLines.length, + newStart: firstNew?.newNumber ?? 0, + newCount: newLines.length, + lines: chunk, + }); + } + + return hunks; +}; + +const makeFileChange = ( + name: string, + kind: FileChangeKind, + previousCode: string, + currentCode: string +): FileChange => { + if (kind === "unchanged") { + return { + name, + kind, + previousCode, + currentCode, + hunks: [], + added: 0, + removed: 0, + }; + } + + const lines = diffLines(previousCode, currentCode); + const { added, removed } = countLineChanges(lines); + + return { + name, + kind, + previousCode, + currentCode, + hunks: toHunks(lines), + added, + removed, + }; +}; + +export const diffSchemaVersions = ( + currentFiles: NamedCode[] = [], + previousFiles?: NamedCode[] | null +): SchemaDiff => { + const isInitial = previousFiles == null; + const previousMap = new Map( + (previousFiles ?? []).map((file) => [file.name, file.code ?? ""]) + ); + const currentMap = new Map( + currentFiles.map((file) => [file.name, file.code ?? ""]) + ); + const names = [ + ...new Set([...previousMap.keys(), ...currentMap.keys()]), + ].sort(); + const files: FileChange[] = names.map((name) => { + const previousCode = previousMap.get(name); + const currentCode = currentMap.get(name); + + if (previousCode === undefined) { + return makeFileChange(name, "added", "", currentCode ?? ""); + } + + if (currentCode === undefined) { + return makeFileChange(name, "removed", previousCode, ""); + } + + if (normalizeNewlines(previousCode) === normalizeNewlines(currentCode)) { + return makeFileChange(name, "unchanged", previousCode, currentCode); + } + + return makeFileChange(name, "modified", previousCode, currentCode); + }); + + const changedFiles = files.filter((file) => file.kind !== "unchanged"); + const addedLines = changedFiles.reduce((sum, file) => sum + file.added, 0); + const removedLines = changedFiles.reduce( + (sum, file) => sum + file.removed, + 0 + ); + + return { + isInitial, + files, + changedFiles, + addedLines, + removedLines, + }; +};