@casualoffice/sheets 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * InsertSparklineDialog — the SDK chrome's built-in "Insert sparkline" modal.
19
+ *
20
+ * Excel/Sheets-style in-cell sparklines: pick a type (line / column / win-loss),
21
+ * a data range (the values to plot), and a location cell (where the mini-chart
22
+ * renders). Follows the FormatCells/DataValidation exemplars: reads the active
23
+ * A1 selection off the FUniver facade to seed the fields, gathers the form, and
24
+ * applies through the SDK API.
25
+ *
26
+ * FACADE NOTE — no first-class Univer sparkline facade is installed. Unlike
27
+ * data-validation (`@univerjs/sheets-data-validation/facade`), there is no
28
+ * `@univerjs/sheets-sparkline` package in `node_modules/@univerjs/*`, so there is
29
+ * no `univerAPI.newSparkline()` builder or `FRange.setSparkline(...)` to call.
30
+ * Sparklines in this workbook are a Casual-Sheets feature persisted on
31
+ * `IWorkbookData.resources['__casual_sheets_sparklines__']` — the resource the
32
+ * app's `SparklineLayer` renders from and that round-trips through xlsx/collab
33
+ * (see apps/web/src/sparklines/{types,resources}.ts). The dialog therefore wires
34
+ * the REAL op the feature owns: it merges a new `SparklineModel` into that
35
+ * resource on the live snapshot via `api.getContent()` and re-mounts it via
36
+ * `api.setContent()` (the canonical cross-editor content accessors on
37
+ * `CasualSheetsAPI`). The resource name + model shape are duplicated locally so
38
+ * the SDK stays decoupled from the app package.
39
+ *
40
+ * Mounted by `<DialogHost>` when `openDialog('insert-sparkline')` is called and
41
+ * no host override is registered.
42
+ */
43
+
44
+ import { useMemo, useState, type CSSProperties } from 'react';
45
+ import type { IWorkbookData } from '@univerjs/core';
46
+ import type { DialogComponentProps } from './extensions';
47
+ import type { CasualSheetsAPI } from '../sheets/api';
48
+ import { Dialog } from './Dialog';
49
+ import {
50
+ DIALOG_BTN_PRIMARY_STYLE,
51
+ DIALOG_BTN_SECONDARY_STYLE,
52
+ DIALOG_FIELD_STYLE,
53
+ DIALOG_INPUT_STYLE,
54
+ DIALOG_LABEL_STYLE,
55
+ } from './dialog-styles';
56
+
57
+ /** Excel-canonical sparkline families. Mirrors apps/web sparklines `SparklineType`. */
58
+ type SparklineType = 'line' | 'column' | 'win-loss';
59
+
60
+ /** A single source rectangle in row/column indices. */
61
+ interface SourceRange {
62
+ startRow: number;
63
+ endRow: number;
64
+ startColumn: number;
65
+ endColumn: number;
66
+ }
67
+
68
+ /**
69
+ * Sparkline model persisted on the workbook resource. Structurally identical to
70
+ * the app's `SparklineModel` (apps/web/src/sparklines/types.ts) — duplicated here
71
+ * so the SDK doesn't depend on the app package. `SparklineLayer` reads this shape.
72
+ */
73
+ interface SparklineModel {
74
+ id: string;
75
+ type: SparklineType;
76
+ unitId: string;
77
+ sheetId: string;
78
+ source: SourceRange;
79
+ anchor: { row: number; col: number };
80
+ }
81
+
82
+ /** Resource key + payload envelope — mirrors `SPARKLINES_RESOURCE_NAME` /
83
+ * `SparklinesResourceV1` in apps/web/src/sparklines/types.ts. */
84
+ const SPARKLINES_RESOURCE_NAME = '__casual_sheets_sparklines__';
85
+ interface SparklinesResourceV1 {
86
+ v: 1;
87
+ sparklines: SparklineModel[];
88
+ }
89
+
90
+ const TYPE_OPTIONS: Array<{ value: SparklineType; label: string }> = [
91
+ { value: 'line', label: 'Line' },
92
+ { value: 'column', label: 'Column' },
93
+ { value: 'win-loss', label: 'Win / Loss' },
94
+ ];
95
+
96
+ interface DialogState {
97
+ type: SparklineType;
98
+ /** Data range in A1, e.g. "A1:F1". */
99
+ sourceA1: string;
100
+ /** Location cell in A1, e.g. "G1". */
101
+ anchorA1: string;
102
+ }
103
+
104
+ /** The active FRange, or null when there is no selection. */
105
+ function activeRange(api: CasualSheetsAPI) {
106
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
107
+ }
108
+
109
+ /** Convert column letters (A, B, …, AA) to a 0-based index. */
110
+ function colLettersToIndex(letters: string): number {
111
+ let n = 0;
112
+ for (let i = 0; i < letters.length; i += 1) {
113
+ n = n * 26 + (letters.charCodeAt(i) - 64);
114
+ }
115
+ return n - 1;
116
+ }
117
+
118
+ /** Parse an A1 range ("A1:F1" or a single "A1") into row/column indices. */
119
+ function parseRange(s: string): SourceRange | null {
120
+ const m = /^\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?(\d+))?$/.exec(s.trim().toUpperCase());
121
+ if (!m) return null;
122
+ const c1 = colLettersToIndex(m[1]);
123
+ const r1 = parseInt(m[2], 10) - 1;
124
+ const c2 = m[3] ? colLettersToIndex(m[3]) : c1;
125
+ const r2 = m[4] ? parseInt(m[4], 10) - 1 : r1;
126
+ return {
127
+ startRow: Math.min(r1, r2),
128
+ endRow: Math.max(r1, r2),
129
+ startColumn: Math.min(c1, c2),
130
+ endColumn: Math.max(c1, c2),
131
+ };
132
+ }
133
+
134
+ /** Parse a single A1 cell ("G1") into row/col indices. */
135
+ function parseSingleCell(s: string): { row: number; col: number } | null {
136
+ const m = /^\$?([A-Z]+)\$?(\d+)$/.exec(s.trim().toUpperCase());
137
+ if (!m) return null;
138
+ return { col: colLettersToIndex(m[1]), row: parseInt(m[2], 10) - 1 };
139
+ }
140
+
141
+ /**
142
+ * Merge a new sparkline into the workbook's sparklines resource and re-mount the
143
+ * snapshot. Returns false when there is no active workbook.
144
+ *
145
+ * This is the REAL persistence path for sparklines in this workbook (there is no
146
+ * Univer sparkline facade — see the file header). `getContent()` yields the live
147
+ * `IWorkbookData` including `resources`; we splice our model into the
148
+ * `__casual_sheets_sparklines__` entry and hand it back through `setContent()`,
149
+ * exactly the shape `SparklineLayer` renders and xlsx/collab round-trip.
150
+ */
151
+ function insertSparkline(api: CasualSheetsAPI, model: SparklineModel): boolean {
152
+ const data = api.getContent();
153
+ if (!data) return false;
154
+
155
+ const resources = data.resources ? [...data.resources] : [];
156
+ const idx = resources.findIndex((r) => r.name === SPARKLINES_RESOURCE_NAME);
157
+
158
+ let existing: SparklineModel[] = [];
159
+ if (idx >= 0 && resources[idx]?.data) {
160
+ try {
161
+ const parsed = JSON.parse(resources[idx].data) as Partial<SparklinesResourceV1>;
162
+ if (parsed?.v === 1 && Array.isArray(parsed.sparklines)) existing = parsed.sparklines;
163
+ } catch {
164
+ existing = [];
165
+ }
166
+ }
167
+
168
+ const payload: SparklinesResourceV1 = { v: 1, sparklines: [...existing, model] };
169
+ const entry = { name: SPARKLINES_RESOURCE_NAME, data: JSON.stringify(payload) };
170
+ if (idx >= 0) resources[idx] = entry;
171
+ else resources.push(entry);
172
+
173
+ const next: IWorkbookData = { ...data, resources };
174
+ api.setContent(next);
175
+ return true;
176
+ }
177
+
178
+ /** Read the active workbook's unitId + active sheetId for the model. */
179
+ function activeIds(api: CasualSheetsAPI): { unitId: string; sheetId: string } | null {
180
+ const wb = api.univer.getActiveWorkbook();
181
+ const sheet = wb?.getActiveSheet();
182
+ if (!wb || !sheet) return null;
183
+ return { unitId: wb.getId(), sheetId: sheet.getSheetId() };
184
+ }
185
+
186
+ const RANGE_NOTE_STYLE: CSSProperties = {
187
+ fontSize: 12,
188
+ color: 'var(--cs-chrome-muted, #605e5c)',
189
+ marginBottom: 12,
190
+ };
191
+
192
+ const RADIO_ROW_STYLE: CSSProperties = {
193
+ display: 'flex',
194
+ gap: 16,
195
+ marginTop: 2,
196
+ };
197
+
198
+ const RADIO_OPT_STYLE: CSSProperties = {
199
+ display: 'flex',
200
+ alignItems: 'center',
201
+ gap: 6,
202
+ cursor: 'pointer',
203
+ };
204
+
205
+ const ERROR_STYLE: CSSProperties = {
206
+ fontSize: 12,
207
+ color: 'var(--cs-chrome-danger-fg, #b91c1c)',
208
+ marginTop: 4,
209
+ };
210
+
211
+ export function InsertSparklineDialog({ api, onClose }: DialogComponentProps) {
212
+ // Seed the data range from the current selection (its A1 label), leaving the
213
+ // location cell for the user to pick — mirrors Excel's Insert Sparkline flow.
214
+ const selectedA1 = useMemo(() => {
215
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
216
+ return fRange?.getA1Notation?.() ?? '';
217
+ }, [api]);
218
+
219
+ const [state, setState] = useState<DialogState>({
220
+ type: 'line',
221
+ sourceA1: selectedA1,
222
+ anchorA1: '',
223
+ });
224
+ const [error, setError] = useState<string | null>(null);
225
+
226
+ const hasWorkbook = api.univer.getActiveWorkbook() != null;
227
+
228
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) => {
229
+ setState((prev) => ({ ...prev, [key]: value }));
230
+ setError(null);
231
+ };
232
+
233
+ const apply = () => {
234
+ const source = parseRange(state.sourceA1);
235
+ if (!source) {
236
+ setError('Data range must be a range like A1:F1.');
237
+ return;
238
+ }
239
+ const anchor = parseSingleCell(state.anchorA1);
240
+ if (!anchor) {
241
+ setError('Location must be a single cell like G1.');
242
+ return;
243
+ }
244
+ const ids = activeIds(api);
245
+ if (!ids) {
246
+ setError('No active sheet.');
247
+ return;
248
+ }
249
+
250
+ const model: SparklineModel = {
251
+ id: `spark-${Math.random().toString(36).slice(2, 10)}`,
252
+ type: state.type,
253
+ unitId: ids.unitId,
254
+ sheetId: ids.sheetId,
255
+ source,
256
+ anchor,
257
+ };
258
+ if (insertSparkline(api, model)) onClose();
259
+ else setError('Could not read the active workbook.');
260
+ };
261
+
262
+ return (
263
+ <Dialog
264
+ title="Insert sparkline"
265
+ onClose={onClose}
266
+ width={420}
267
+ data-testid="cs-insert-sparkline-dialog"
268
+ footer={
269
+ <>
270
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
271
+ Cancel
272
+ </button>
273
+ <button
274
+ type="button"
275
+ style={DIALOG_BTN_PRIMARY_STYLE}
276
+ data-testid="cs-insert-sparkline-apply"
277
+ disabled={!hasWorkbook}
278
+ onClick={apply}
279
+ >
280
+ Insert
281
+ </button>
282
+ </>
283
+ }
284
+ >
285
+ <div style={RANGE_NOTE_STYLE}>
286
+ Draws an in-cell mini-chart in the location cell from the values in the data range.
287
+ </div>
288
+
289
+ <label style={DIALOG_FIELD_STYLE}>
290
+ <span style={DIALOG_LABEL_STYLE}>Type</span>
291
+ <div
292
+ style={RADIO_ROW_STYLE}
293
+ role="radiogroup"
294
+ aria-label="Sparkline type"
295
+ data-testid="cs-insert-sparkline-type"
296
+ >
297
+ {TYPE_OPTIONS.map((opt) => (
298
+ <label key={opt.value} style={RADIO_OPT_STYLE}>
299
+ <input
300
+ type="radio"
301
+ name="cs-sparkline-type"
302
+ value={opt.value}
303
+ data-testid={`cs-insert-sparkline-type-${opt.value}`}
304
+ checked={state.type === opt.value}
305
+ onChange={() => update('type', opt.value)}
306
+ />
307
+ <span>{opt.label}</span>
308
+ </label>
309
+ ))}
310
+ </div>
311
+ </label>
312
+
313
+ <label style={DIALOG_FIELD_STYLE}>
314
+ <span style={DIALOG_LABEL_STYLE}>Data range</span>
315
+ <input
316
+ style={DIALOG_INPUT_STYLE}
317
+ data-testid="cs-insert-sparkline-source"
318
+ value={state.sourceA1}
319
+ placeholder="A1:F1"
320
+ spellCheck={false}
321
+ onChange={(e) => update('sourceA1', e.target.value.toUpperCase())}
322
+ />
323
+ </label>
324
+
325
+ <label style={DIALOG_FIELD_STYLE}>
326
+ <span style={DIALOG_LABEL_STYLE}>Location</span>
327
+ <input
328
+ style={DIALOG_INPUT_STYLE}
329
+ data-testid="cs-insert-sparkline-anchor"
330
+ value={state.anchorA1}
331
+ placeholder="G1"
332
+ spellCheck={false}
333
+ onChange={(e) => update('anchorA1', e.target.value.toUpperCase())}
334
+ />
335
+ </label>
336
+
337
+ {error && (
338
+ <div style={ERROR_STYLE} data-testid="cs-insert-sparkline-error">
339
+ {error}
340
+ </div>
341
+ )}
342
+ </Dialog>
343
+ );
344
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Unit tests for the MenuBar feature-gating contract — specifically the
19
+ * embed-mode gates that let a host hide the editor's branding surfaces:
20
+ *
21
+ * features={{ help: false }} → the whole Help menu is dropped.
22
+ * features={{ branding: false }} → the "View on GitHub" + "About" links are
23
+ * dropped (from both Help and File), so an
24
+ * embedded sheet shows no editor branding,
25
+ * while Keyboard shortcuts stays in Help.
26
+ *
27
+ * Exercises the pure `computeVisibleMenus` resolver (no DOM needed). The
28
+ * `MENUS` array itself lives in `MenuBar.tsx`, which statically imports
29
+ * `@univerjs/core` values the vendored ESM can't expose as named exports to
30
+ * node's loader — so it can't be imported here. Instead this pins the resolver
31
+ * against a fixture that mirrors the real Help/File branding subtree (kept in
32
+ * sync with MenuBar.tsx: Help menu `feature: 'help'`; About + View-on-GitHub +
33
+ * their separator `feature: 'branding'`; File > About `feature: 'branding'`).
34
+ *
35
+ * Run with: `pnpm test:unit`
36
+ */
37
+ import { strict as assert } from 'node:assert';
38
+ import { test } from 'node:test';
39
+
40
+ import { computeVisibleMenus, type MenuDef } from './menu-model';
41
+
42
+ // A faithful copy of the branding-relevant subtree of `MENUS` in MenuBar.tsx.
43
+ const MENUS_FIXTURE: MenuDef[] = [
44
+ {
45
+ id: 'file',
46
+ label: 'File',
47
+ feature: 'file',
48
+ items: [
49
+ { kind: 'item', id: 'properties', label: 'Properties…', dialog: 'properties' },
50
+ {
51
+ kind: 'item',
52
+ id: 'about',
53
+ label: 'About casual sheets',
54
+ dialog: 'about',
55
+ feature: 'branding',
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ id: 'help',
61
+ label: 'Help',
62
+ feature: 'help',
63
+ items: [
64
+ {
65
+ kind: 'item',
66
+ id: 'keyboard-shortcuts',
67
+ label: 'Keyboard shortcuts',
68
+ dialog: 'keyboard-shortcuts',
69
+ },
70
+ { kind: 'separator', id: 'sep-help', feature: 'branding' },
71
+ {
72
+ kind: 'item',
73
+ id: 'about',
74
+ label: 'About casual sheets',
75
+ dialog: 'about',
76
+ feature: 'branding',
77
+ },
78
+ {
79
+ kind: 'item',
80
+ id: 'github',
81
+ label: 'View on GitHub',
82
+ run: () => {},
83
+ feature: 'branding',
84
+ },
85
+ ],
86
+ },
87
+ ];
88
+
89
+ // All SDK-built-in dialogs openable — matches standalone chrome (the SDK ships
90
+ // the About / Keyboard-shortcuts modals), so branding items are not
91
+ // latent-dropped and the feature gate is the only thing that can hide them.
92
+ const canOpenAll = () => true;
93
+
94
+ function findMenu(menus: MenuDef[], id: string) {
95
+ return menus.find((m) => m.id === id);
96
+ }
97
+
98
+ function itemIds(items: { id: string }[]): string[] {
99
+ return items.map((i) => i.id);
100
+ }
101
+
102
+ test('default (features unset): Help menu, About and View-on-GitHub all render', () => {
103
+ const menus = computeVisibleMenus(MENUS_FIXTURE, {}, canOpenAll);
104
+ const help = findMenu(menus, 'help');
105
+ assert.ok(help, 'Help menu should render by default');
106
+ const ids = itemIds(help.items);
107
+ assert.ok(ids.includes('github'), 'View on GitHub should render by default');
108
+ assert.ok(ids.includes('about'), 'Help > About should render by default');
109
+ assert.ok(ids.includes('keyboard-shortcuts'), 'Keyboard shortcuts should render');
110
+
111
+ const file = findMenu(menus, 'file');
112
+ assert.ok(file, 'File menu should render by default');
113
+ assert.ok(itemIds(file.items).includes('about'), 'File > About should render by default');
114
+ });
115
+
116
+ test('features={{ help: false }}: the whole Help menu is dropped', () => {
117
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false }, canOpenAll);
118
+ assert.equal(findMenu(menus, 'help'), undefined, 'Help menu must not render when help:false');
119
+ // File menu is unaffected by the help gate.
120
+ assert.ok(findMenu(menus, 'file'), 'File menu still renders when only help is disabled');
121
+ });
122
+
123
+ test('features={{ branding: false }}: GitHub + About vanish, Help stays for shortcuts', () => {
124
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { branding: false }, canOpenAll);
125
+
126
+ const help = findMenu(menus, 'help');
127
+ assert.ok(help, 'Help menu still renders (keyboard shortcuts is not branding)');
128
+ const helpIds = itemIds(help.items);
129
+ assert.ok(!helpIds.includes('github'), 'View on GitHub must be hidden when branding:false');
130
+ assert.ok(!helpIds.includes('about'), 'Help > About must be hidden when branding:false');
131
+ assert.ok(helpIds.includes('keyboard-shortcuts'), 'Keyboard shortcuts stays');
132
+ // Trailing separator before the (now removed) branding items must be collapsed.
133
+ assert.ok(!helpIds.includes('sep-help'), 'dangling separator collapses once branding items go');
134
+
135
+ const file = findMenu(menus, 'file');
136
+ assert.ok(file, 'File menu still renders');
137
+ assert.ok(
138
+ !itemIds(file.items).includes('about'),
139
+ 'File > About must be hidden when branding:false',
140
+ );
141
+ });
142
+
143
+ test('features={{ help: false, branding: false }}: no editor-branded surface at all', () => {
144
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false, branding: false }, canOpenAll);
145
+ assert.equal(findMenu(menus, 'help'), undefined, 'Help menu gone');
146
+ const file = findMenu(menus, 'file');
147
+ assert.ok(file && !itemIds(file.items).includes('about'), 'File > About gone');
148
+ // No visible menu anywhere links to GitHub.
149
+ for (const m of menus) {
150
+ assert.ok(!itemIds(m.items).includes('github'), `menu ${m.id} has no GitHub link`);
151
+ }
152
+ });