@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.
- package/dist/chrome.cjs +3421 -307
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +33 -0
- package/dist/chrome.d.ts +33 -0
- package/dist/chrome.js +3380 -266
- package/dist/chrome.js.map +1 -1
- package/dist/embed/embed-runtime.js +152 -150
- package/package.json +1 -1
- package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
- package/src/chrome/CustomSortDialog.tsx +357 -0
- package/src/chrome/DataValidationDialog.tsx +536 -0
- package/src/chrome/DeleteCellsDialog.tsx +183 -0
- package/src/chrome/GoalSeekDialog.tsx +370 -0
- package/src/chrome/InsertCellsDialog.tsx +185 -0
- package/src/chrome/InsertChartDialog.tsx +490 -0
- package/src/chrome/InsertFunctionDialog.tsx +493 -0
- package/src/chrome/InsertPivotDialog.tsx +488 -0
- package/src/chrome/InsertSparklineDialog.tsx +344 -0
- package/src/chrome/MenuBar.feature-gate.unit.test.ts +152 -0
- package/src/chrome/MenuBar.tsx +36 -141
- package/src/chrome/NameManagerDialog.tsx +378 -0
- package/src/chrome/PasteSpecialDialog.tsx +286 -0
- package/src/chrome/dialog-context.tsx +24 -0
- package/src/chrome/menu-model.ts +191 -0
|
@@ -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
|
+
});
|