Add snippet metadata panel, duplicate, and immediate-load preview (M4.5)

This commit is contained in:
2026-06-06 23:54:59 +03:00
parent 3e89d9a531
commit 80bedd2a8d
13 changed files with 785 additions and 51 deletions
+50
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
import {
CURRENT_SNIPPET_VERSION,
createSnippet,
duplicateSnippet,
formatSnippetSize,
generateSnippetName,
hasUnpublishedChanges,
@@ -67,6 +68,55 @@ describe('generateSnippetName', () => {
});
});
describe('duplicateSnippet', () => {
const source = {
...createSnippet({
id: 'src',
name: 'My Chart',
spec: '{"published":1}',
now: new Date('2026-01-01T00:00:00Z'),
}),
draftSpec: '{"draft":1}',
comment: 'a note',
tags: ['imported'],
datasetRefs: ['Sales'],
meta: { createdWith: 'chart-builder' },
};
test('suffixes the name with "(copy)" and takes a new identity + timestamps', () => {
const now = new Date('2026-03-04T05:06:07Z');
const copy = duplicateSnippet(source, { id: 'copy', now });
expect(copy.id).toBe('copy');
expect(copy.id).not.toBe(source.id);
expect(copy.name).toBe('My Chart (copy)');
expect(copy.version).toBe(CURRENT_SNIPPET_VERSION);
expect(copy.created).toBe(now.toISOString());
expect(copy.modified).toBe(now.toISOString());
});
test('carries over both spec versions, comment, tags, refs, and meta', () => {
const copy = duplicateSnippet(source);
expect(copy.spec).toBe('{"published":1}');
expect(copy.draftSpec).toBe('{"draft":1}');
expect(copy.comment).toBe('a note');
expect(copy.tags).toEqual(['imported']);
expect(copy.datasetRefs).toEqual(['Sales']);
expect(copy.meta).toEqual({ createdWith: 'chart-builder' });
});
test('clones mutable members so the copy shares no references with its source', () => {
const copy = duplicateSnippet(source);
expect(copy.tags).not.toBe(source.tags);
expect(copy.datasetRefs).not.toBe(source.datasetRefs);
expect(copy.meta).not.toBe(source.meta);
});
test('generates a unique id by default', () => {
expect(duplicateSnippet(source).id).not.toBe(duplicateSnippet(source).id);
});
});
describe('hasUnpublishedChanges', () => {
test('false when draft matches published, true once draft diverges', () => {
const s = createSnippet({ now: new Date('2026-06-04T00:00:00Z') });
+29
View File
@@ -126,6 +126,35 @@ export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
};
}
export interface DuplicateSnippetOptions {
/** Clock injection for deterministic tests; defaults to the current time. */
now?: Date;
/** Id injection for deterministic tests; defaults to a random UUID. */
id?: string;
}
/**
* Create an independent copy of a snippet (spec §02 → Duplicate). The copy carries
* over the specification (both published and draft), comment, tags, and dataset
* references, gets a new identity and fresh created/modified timestamps, and a name
* suffixed "(copy)". Mutable members are cloned so the copy shares no references
* with its source.
*/
export function duplicateSnippet(source: Snippet, options: DuplicateSnippetOptions = {}): Snippet {
const iso = (options.now ?? new Date()).toISOString();
return {
...source,
id: options.id ?? crypto.randomUUID(),
version: CURRENT_SNIPPET_VERSION,
name: `${source.name} (copy)`,
created: iso,
modified: iso,
tags: [...source.tags],
datasetRefs: [...source.datasetRefs],
meta: { ...source.meta },
};
}
/** True when the snippet's draft differs from its published spec (§03D). */
export function hasUnpublishedChanges(snippet: Snippet): boolean {
return snippet.draftSpec !== snippet.spec;