mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add snippet metadata panel, duplicate, and immediate-load preview (M4.5)
This commit is contained in:
@@ -254,6 +254,103 @@ describe('publish — datasetRefs recomputation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameSnippet (spec §02 metadata panel)', () => {
|
||||
test('renames and advances modified, without touching the editor buffer', () => {
|
||||
const a = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
const epochBefore = store().bufferEpoch;
|
||||
|
||||
store().renameSnippet('a', 'New', new Date('2026-05-01T00:00:00Z'));
|
||||
const saved = store().snippets.find((s) => s.id === 'a')!;
|
||||
expect(saved.name).toBe('New');
|
||||
expect(saved.modified).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(store().bufferEpoch).toBe(epochBefore); // name is not in the spec buffer
|
||||
});
|
||||
|
||||
test('is a no-op for an unchanged name (modified does not advance)', () => {
|
||||
const a = createSnippet({ id: 'a', name: 'Same', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().renameSnippet('a', 'Same', new Date('2026-05-01T00:00:00Z'));
|
||||
expect(store().snippets.find((s) => s.id === 'a')!.modified).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('ignores an unknown id', () => {
|
||||
const a = createSnippet({ id: 'a' });
|
||||
store().hydrate([a], 'a');
|
||||
expect(() => store().renameSnippet('missing', 'X')).not.toThrow();
|
||||
expect(store().snippets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setComment (spec §02 metadata panel)', () => {
|
||||
test('sets the comment and advances modified', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().setComment('a', 'a useful note', new Date('2026-05-02T00:00:00Z'));
|
||||
const saved = store().snippets.find((s) => s.id === 'a')!;
|
||||
expect(saved.comment).toBe('a useful note');
|
||||
expect(saved.modified).toBe('2026-05-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('is a no-op for an unchanged comment', () => {
|
||||
const a = {
|
||||
...createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }),
|
||||
comment: 'x',
|
||||
};
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().setComment('a', 'x', new Date('2026-05-02T00:00:00Z'));
|
||||
expect(store().snippets.find((s) => s.id === 'a')!.modified).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicateActiveSnippet (spec §02 → Duplicate)', () => {
|
||||
test('prepends an independent copy and makes it active', () => {
|
||||
const a = {
|
||||
...createSnippet({
|
||||
id: 'a',
|
||||
name: 'Chart',
|
||||
spec: '{"a":1}',
|
||||
now: new Date('2026-01-01T00:00:00Z'),
|
||||
}),
|
||||
comment: 'note',
|
||||
tags: ['imported'],
|
||||
datasetRefs: ['Sales'],
|
||||
};
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
const id = store().duplicateActiveSnippet(new Date('2026-04-01T00:00:00Z'), 'copy');
|
||||
expect(id).toBe('copy');
|
||||
expect(store().activeSnippetId).toBe('copy');
|
||||
expect(store().snippets[0].id).toBe('copy'); // prepended
|
||||
|
||||
const copy = store().snippets[0];
|
||||
expect(copy.name).toBe('Chart (copy)');
|
||||
expect(copy.spec).toBe('{"a":1}');
|
||||
expect(copy.comment).toBe('note');
|
||||
expect(copy.tags).toEqual(['imported']);
|
||||
expect(copy.datasetRefs).toEqual(['Sales']);
|
||||
expect(copy.created).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(store().draftText).toBe(copy.draftSpec);
|
||||
});
|
||||
|
||||
test('captures in-progress buffer edits before copying', () => {
|
||||
const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft('{"a":99}'); // edited, not yet auto-saved
|
||||
store().duplicateActiveSnippet(new Date('2026-04-01T00:00:00Z'), 'copy');
|
||||
expect(store().snippets[0].draftSpec).toBe('{"a":99}'); // copy reflects the live edit
|
||||
});
|
||||
|
||||
test('returns null when no snippet is active', () => {
|
||||
store().hydrate([], null);
|
||||
expect(store().duplicateActiveSnippet()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameDatasetRefs', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
|
||||
import {
|
||||
createSnippet,
|
||||
duplicateSnippet as duplicateSnippetRecord,
|
||||
type CreateSnippetOptions,
|
||||
type Snippet,
|
||||
} from '@core/snippet';
|
||||
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
|
||||
|
||||
/** Which version of the active snippet the editor is showing (spec §03D). */
|
||||
@@ -47,6 +52,27 @@ export interface SnippetState {
|
||||
selectSnippet: (id: string) => void;
|
||||
/** Remove a snippet; if it was active, fall back to the newest remaining one. */
|
||||
removeSnippet: (id: string) => void;
|
||||
/**
|
||||
* Rename a snippet (spec §02 metadata panel, inline name edit). Advances
|
||||
* `modified` (a name edit is a save, §02 → Sort), but never touches the editor
|
||||
* buffer — the name isn't part of the spec text. No-op for an unknown id or an
|
||||
* unchanged name. `now` injectable.
|
||||
*/
|
||||
renameSnippet: (id: string, name: string, now?: Date) => void;
|
||||
/**
|
||||
* Set a snippet's free-form comment (spec §02 metadata panel). Advances
|
||||
* `modified` like a rename; no editor-buffer effect. No-op for an unknown id or
|
||||
* an unchanged comment. `now` injectable.
|
||||
*/
|
||||
setComment: (id: string, comment: string, now?: Date) => void;
|
||||
/**
|
||||
* Duplicate the active snippet (spec §02 → Duplicate): flushes the live buffer
|
||||
* into the source draft first so the copy reflects in-progress edits, then
|
||||
* prepends an independent copy ("(copy)" name, fresh identity/timestamps) and
|
||||
* makes it active. Returns the new id, or null if no snippet is active. `now`
|
||||
* injectable; `id` injectable for deterministic tests.
|
||||
*/
|
||||
duplicateActiveSnippet: (now?: Date, id?: string) => string | null;
|
||||
/** Update the draft buffer only (no persistence; debounced commit follows). */
|
||||
updateDraft: (text: string) => void;
|
||||
/**
|
||||
@@ -158,6 +184,9 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
set((s) => {
|
||||
const snippets = s.snippets.filter((x) => x.id !== id);
|
||||
if (s.activeSnippetId !== id) return { snippets };
|
||||
// Deleting the active snippet falls back to the newest remaining one, so the
|
||||
// editor and detail panel stay populated (spec §02 → Delete); null only when
|
||||
// none remain.
|
||||
const activeSnippetId = newestId(snippets);
|
||||
return {
|
||||
snippets,
|
||||
@@ -169,6 +198,48 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
renameSnippet: (id, name, now) => {
|
||||
set((s) => {
|
||||
const target = s.snippets.find((x) => x.id === id);
|
||||
if (!target || target.name === name) return s; // unknown id or no change
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
return {
|
||||
snippets: s.snippets.map((x) => (x.id === id ? { ...x, name, modified } : x)),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setComment: (id, comment, now) => {
|
||||
set((s) => {
|
||||
const target = s.snippets.find((x) => x.id === id);
|
||||
if (!target || target.comment === comment) return s; // unknown id or no change
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
return {
|
||||
snippets: s.snippets.map((x) => (x.id === id ? { ...x, comment, modified } : x)),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
duplicateActiveSnippet: (now, id) => {
|
||||
// Flush the live buffer into the source draft first, so the copy faithfully
|
||||
// mirrors what the user currently sees, not the last auto-saved draft.
|
||||
get().commitDraft(now);
|
||||
const { activeSnippetId, snippets } = get();
|
||||
if (!activeSnippetId) return null;
|
||||
const source = snippets.find((s) => s.id === activeSnippetId);
|
||||
if (!source) return null;
|
||||
|
||||
const copy = duplicateSnippetRecord(source, { now, id });
|
||||
set((s) => ({
|
||||
snippets: [copy, ...s.snippets],
|
||||
activeSnippetId: copy.id,
|
||||
draftText: copy.draftSpec,
|
||||
editorView: 'draft', // a fresh copy opens on its editable draft
|
||||
bufferEpoch: s.bufferEpoch + 1,
|
||||
}));
|
||||
return copy.id;
|
||||
},
|
||||
|
||||
updateDraft: (draftText) => set({ draftText }),
|
||||
|
||||
replaceActiveDraft: (text, now) => {
|
||||
|
||||
Reference in New Issue
Block a user