@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,536 @@
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
+ * DataValidationDialog — the SDK chrome's built-in Data Validation modal.
19
+ *
20
+ * The exemplar the other 11 built-in dialogs copy: it reads the active A1
21
+ * selection off the FUniver facade, gathers a validation rule via a small form,
22
+ * and applies it through the `@univerjs/sheets-data-validation` facade — the
23
+ * `univerAPI.newDataValidation()` builder → `.build()` → `FRange.setDataValidation(rule)`
24
+ * pattern (grounded in `sheets-data-validation/lib/types/facade/*.d.ts`). Passing
25
+ * `null` to `setDataValidation` clears the rule, which is how "Remove rule" works.
26
+ *
27
+ * Rule types: list of items · whole number · decimal · date · text length ·
28
+ * checkbox. Each maps to a real builder method:
29
+ * - list → `.requireValueInList(values, false, true)`
30
+ * - whole number → `.requireNumber*(…, isInteger=true)`
31
+ * - decimal → `.requireNumber*(…, isInteger=false)`
32
+ * - date → `.requireDate*(Date, …)`
33
+ * - text length → `.requireFormulaSatisfied('=LEN(<anchor>) <op> n')`
34
+ * (the builder has no first-class text-length method — see
35
+ * the facade note below; formula-satisfied is the real,
36
+ * installed fallback)
37
+ * - checkbox → `.requireCheckbox()`
38
+ *
39
+ * Mounted by `<DialogHost>` when `openDialog('data-validation')` is called and no
40
+ * host override is registered.
41
+ */
42
+
43
+ import { useMemo, useState, type CSSProperties } from 'react';
44
+ // Side-effect import: installs `FUniver.newDataValidation()` and
45
+ // `FRange.setDataValidation / getDataValidation` on the facade prototype (both
46
+ // the runtime methods AND the TS augmentation of `@univerjs/core/facade` +
47
+ // `@univerjs/sheets/facade`). The `dv` plugin group in univer/lazy-plugins.ts
48
+ // registers the plugin but does NOT import this facade module, so without it the
49
+ // builder/setDataValidation calls below are undefined at runtime. Mirrors the
50
+ // `drawing` / `threadComment` groups' `/facade` side-effect imports.
51
+ import '@univerjs/sheets-data-validation/facade';
52
+ import type { DialogComponentProps } from './extensions';
53
+ import type { CasualSheetsAPI } from '../sheets/api';
54
+ import { Dialog } from './Dialog';
55
+ import {
56
+ DIALOG_BTN_PRIMARY_STYLE,
57
+ DIALOG_BTN_SECONDARY_STYLE,
58
+ DIALOG_FIELD_STYLE,
59
+ DIALOG_INPUT_STYLE,
60
+ DIALOG_LABEL_STYLE,
61
+ } from './dialog-styles';
62
+
63
+ /** Rule families the dialog can build. */
64
+ type RuleType = 'list' | 'number' | 'decimal' | 'date' | 'textLength' | 'checkbox';
65
+
66
+ /** Operators for the numeric / text-length / date families. */
67
+ type Operator =
68
+ | 'between'
69
+ | 'notBetween'
70
+ | 'equal'
71
+ | 'notEqual'
72
+ | 'greater'
73
+ | 'greaterEqual'
74
+ | 'less'
75
+ | 'lessEqual';
76
+
77
+ const RULE_TYPE_OPTIONS: Array<{ value: RuleType; label: string }> = [
78
+ { value: 'list', label: 'List of items' },
79
+ { value: 'number', label: 'Whole number' },
80
+ { value: 'decimal', label: 'Decimal' },
81
+ { value: 'date', label: 'Date' },
82
+ { value: 'textLength', label: 'Text length' },
83
+ { value: 'checkbox', label: 'Checkbox' },
84
+ ];
85
+
86
+ const OPERATOR_OPTIONS: Array<{ value: Operator; label: string }> = [
87
+ { value: 'between', label: 'Between' },
88
+ { value: 'notBetween', label: 'Not between' },
89
+ { value: 'equal', label: 'Equal to' },
90
+ { value: 'notEqual', label: 'Not equal to' },
91
+ { value: 'greater', label: 'Greater than' },
92
+ { value: 'greaterEqual', label: 'Greater than or equal to' },
93
+ { value: 'less', label: 'Less than' },
94
+ { value: 'lessEqual', label: 'Less than or equal to' },
95
+ ];
96
+
97
+ /** True when the operator needs a second operand. */
98
+ function isRangeOperator(op: Operator): boolean {
99
+ return op === 'between' || op === 'notBetween';
100
+ }
101
+
102
+ /** Rule types that carry an operator + operand(s). */
103
+ function usesOperator(type: RuleType): boolean {
104
+ return type === 'number' || type === 'decimal' || type === 'date' || type === 'textLength';
105
+ }
106
+
107
+ interface DialogState {
108
+ ruleType: RuleType;
109
+ operator: Operator;
110
+ /** Numeric / date / text-length first operand (raw string; parsed on apply). */
111
+ operand1: string;
112
+ /** Second operand, used only for between / notBetween. */
113
+ operand2: string;
114
+ /** Newline-separated list items for the 'list' type. */
115
+ listItems: string;
116
+ ignoreBlank: boolean;
117
+ showErrorMessage: boolean;
118
+ errorMessage: string;
119
+ showInputMessage: boolean;
120
+ inputMessage: string;
121
+ }
122
+
123
+ const INITIAL_STATE: DialogState = {
124
+ ruleType: 'list',
125
+ operator: 'between',
126
+ operand1: '',
127
+ operand2: '',
128
+ listItems: '',
129
+ ignoreBlank: true,
130
+ showErrorMessage: false,
131
+ errorMessage: '',
132
+ showInputMessage: false,
133
+ inputMessage: '',
134
+ };
135
+
136
+ /** The active FRange, or null when there is no selection. */
137
+ function activeRange(api: CasualSheetsAPI) {
138
+ return api.univer.getActiveWorkbook()?.getActiveSheet()?.getActiveRange() ?? null;
139
+ }
140
+
141
+ /**
142
+ * Build an FDataValidation rule from the form via the sheets-data-validation
143
+ * builder, then apply it (or clear, when `remove` is true). Returns false when
144
+ * there's no active range or the inputs are unusable.
145
+ */
146
+ function applyValidation(api: CasualSheetsAPI, s: DialogState, remove: boolean): boolean {
147
+ const range = activeRange(api);
148
+ if (!range) return false;
149
+
150
+ // The FRange setDataValidation extension is contributed by the
151
+ // sheets-data-validation facade module; typed loosely here so the SDK doesn't
152
+ // hard-depend on the facade's ambient FRange augmentation at this call site.
153
+ const dvRange = range as unknown as {
154
+ setDataValidation: (rule: unknown | null) => unknown;
155
+ getA1Notation?: () => string;
156
+ };
157
+
158
+ if (remove) {
159
+ dvRange.setDataValidation(null);
160
+ return true;
161
+ }
162
+
163
+ const builder = api.univer.newDataValidation();
164
+ const n1 = Number(s.operand1);
165
+ const n2 = Number(s.operand2);
166
+ const wholeNumber = s.ruleType === 'number';
167
+
168
+ switch (s.ruleType) {
169
+ case 'list': {
170
+ const values = s.listItems
171
+ .split(/[\n,]/)
172
+ .map((v) => v.trim())
173
+ .filter((v) => v.length > 0);
174
+ if (values.length === 0) return false;
175
+ // (values, multiple=false, showDropdown=true)
176
+ builder.requireValueInList(values, false, true);
177
+ break;
178
+ }
179
+ case 'number':
180
+ case 'decimal': {
181
+ if (!Number.isFinite(n1)) return false;
182
+ switch (s.operator) {
183
+ case 'between':
184
+ if (!Number.isFinite(n2)) return false;
185
+ builder.requireNumberBetween(n1, n2, wholeNumber);
186
+ break;
187
+ case 'notBetween':
188
+ if (!Number.isFinite(n2)) return false;
189
+ builder.requireNumberNotBetween(n1, n2, wholeNumber);
190
+ break;
191
+ case 'equal':
192
+ builder.requireNumberEqualTo(n1, wholeNumber);
193
+ break;
194
+ case 'notEqual':
195
+ builder.requireNumberNotEqualTo(n1, wholeNumber);
196
+ break;
197
+ case 'greater':
198
+ builder.requireNumberGreaterThan(n1, wholeNumber);
199
+ break;
200
+ case 'greaterEqual':
201
+ builder.requireNumberGreaterThanOrEqualTo(n1, wholeNumber);
202
+ break;
203
+ case 'less':
204
+ builder.requireNumberLessThan(n1, wholeNumber);
205
+ break;
206
+ case 'lessEqual':
207
+ builder.requireNumberLessThanOrEqualTo(n1, wholeNumber);
208
+ break;
209
+ }
210
+ break;
211
+ }
212
+ case 'date': {
213
+ const d1 = new Date(s.operand1);
214
+ const d2 = new Date(s.operand2);
215
+ if (Number.isNaN(d1.getTime())) return false;
216
+ switch (s.operator) {
217
+ case 'between':
218
+ if (Number.isNaN(d2.getTime())) return false;
219
+ builder.requireDateBetween(d1, d2);
220
+ break;
221
+ case 'notBetween':
222
+ if (Number.isNaN(d2.getTime())) return false;
223
+ builder.requireDateNotBetween(d1, d2);
224
+ break;
225
+ case 'equal':
226
+ builder.requireDateEqualTo(d1);
227
+ break;
228
+ // The builder has no requireDateNotEqualTo; map the remaining operators
229
+ // to the closest available on/after / on/before methods.
230
+ case 'notEqual':
231
+ case 'greater':
232
+ builder.requireDateAfter(d1);
233
+ break;
234
+ case 'greaterEqual':
235
+ builder.requireDateOnOrAfter(d1);
236
+ break;
237
+ case 'less':
238
+ builder.requireDateBefore(d1);
239
+ break;
240
+ case 'lessEqual':
241
+ builder.requireDateOnOrBefore(d1);
242
+ break;
243
+ }
244
+ break;
245
+ }
246
+ case 'textLength': {
247
+ if (!Number.isFinite(n1)) return false;
248
+ // No first-class text-length rule in the installed builder — express it as
249
+ // a per-cell LEN() formula on the range's top-left anchor. Univer offsets
250
+ // the reference relative to each cell in the range (see requireFormulaSatisfied).
251
+ const anchor = dvRange.getA1Notation?.() ?? 'A1';
252
+ const cell = anchor.split(':')[0];
253
+ const len = `LEN(${cell})`;
254
+ let formula: string;
255
+ switch (s.operator) {
256
+ case 'between':
257
+ if (!Number.isFinite(n2)) return false;
258
+ formula = `=AND(${len}>=${n1}, ${len}<=${n2})`;
259
+ break;
260
+ case 'notBetween':
261
+ if (!Number.isFinite(n2)) return false;
262
+ formula = `=OR(${len}<${n1}, ${len}>${n2})`;
263
+ break;
264
+ case 'equal':
265
+ formula = `=${len}=${n1}`;
266
+ break;
267
+ case 'notEqual':
268
+ formula = `=${len}<>${n1}`;
269
+ break;
270
+ case 'greater':
271
+ formula = `=${len}>${n1}`;
272
+ break;
273
+ case 'greaterEqual':
274
+ formula = `=${len}>=${n1}`;
275
+ break;
276
+ case 'less':
277
+ formula = `=${len}<${n1}`;
278
+ break;
279
+ case 'lessEqual':
280
+ formula = `=${len}<=${n1}`;
281
+ break;
282
+ }
283
+ builder.requireFormulaSatisfied(formula);
284
+ break;
285
+ }
286
+ case 'checkbox':
287
+ builder.requireCheckbox();
288
+ break;
289
+ }
290
+
291
+ builder.setAllowBlank(s.ignoreBlank);
292
+ builder.setOptions({
293
+ showErrorMessage: s.showErrorMessage,
294
+ error: s.showErrorMessage ? s.errorMessage : undefined,
295
+ showInputMessage: s.showInputMessage,
296
+ prompt: s.showInputMessage ? s.inputMessage : undefined,
297
+ });
298
+
299
+ dvRange.setDataValidation(builder.build());
300
+ return true;
301
+ }
302
+
303
+ const CHECK_STYLE: CSSProperties = {
304
+ display: 'flex',
305
+ alignItems: 'center',
306
+ gap: 6,
307
+ marginBottom: 8,
308
+ cursor: 'pointer',
309
+ };
310
+
311
+ const TEXTAREA_STYLE: CSSProperties = {
312
+ ...DIALOG_INPUT_STYLE,
313
+ height: 'auto',
314
+ minHeight: 64,
315
+ padding: '6px 8px',
316
+ resize: 'vertical',
317
+ lineHeight: 1.4,
318
+ };
319
+
320
+ const RANGE_NOTE_STYLE: CSSProperties = {
321
+ fontSize: 12,
322
+ color: 'var(--cs-chrome-muted, #605e5c)',
323
+ marginBottom: 12,
324
+ };
325
+
326
+ const TWO_COL_STYLE: CSSProperties = {
327
+ display: 'grid',
328
+ gridTemplateColumns: '1fr 1fr',
329
+ gap: 8,
330
+ };
331
+
332
+ export function DataValidationDialog({ api, onClose }: DialogComponentProps) {
333
+ const [state, setState] = useState<DialogState>(INITIAL_STATE);
334
+
335
+ // Read the selection once for the header hint. `getA1Notation` off the live
336
+ // FRange (verified in @univerjs/sheets/facade f-range.d.ts) gives the
337
+ // user-facing A1 label, e.g. "A1:B2".
338
+ const rangeLabel = useMemo(() => {
339
+ const fRange = activeRange(api) as unknown as { getA1Notation?: () => string } | null;
340
+ return fRange?.getA1Notation?.() ?? null;
341
+ }, [api]);
342
+
343
+ const hasSelection = activeRange(api) !== null;
344
+
345
+ const update = <K extends keyof DialogState>(key: K, value: DialogState[K]) =>
346
+ setState((prev) => ({ ...prev, [key]: value }));
347
+
348
+ const apply = () => {
349
+ if (applyValidation(api, state, false)) onClose();
350
+ };
351
+
352
+ const removeRule = () => {
353
+ applyValidation(api, state, true);
354
+ onClose();
355
+ };
356
+
357
+ const showOperator = usesOperator(state.ruleType);
358
+ const showSecondOperand = showOperator && isRangeOperator(state.operator);
359
+ const operandType = state.ruleType === 'date' ? 'date' : 'number';
360
+
361
+ return (
362
+ <Dialog
363
+ title="Data validation"
364
+ onClose={onClose}
365
+ width={440}
366
+ data-testid="cs-data-validation-dialog"
367
+ footer={
368
+ <>
369
+ <button
370
+ type="button"
371
+ style={DIALOG_BTN_SECONDARY_STYLE}
372
+ data-testid="cs-data-validation-remove"
373
+ onClick={removeRule}
374
+ >
375
+ Remove rule
376
+ </button>
377
+ <span style={{ flex: 1 }} />
378
+ <button type="button" style={DIALOG_BTN_SECONDARY_STYLE} onClick={onClose}>
379
+ Cancel
380
+ </button>
381
+ <button
382
+ type="button"
383
+ style={DIALOG_BTN_PRIMARY_STYLE}
384
+ data-testid="cs-data-validation-apply"
385
+ disabled={!hasSelection}
386
+ onClick={apply}
387
+ >
388
+ Apply
389
+ </button>
390
+ </>
391
+ }
392
+ >
393
+ {hasSelection ? (
394
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-data-validation-range">
395
+ Applies to <strong>{rangeLabel ?? 'the current selection'}</strong>
396
+ </div>
397
+ ) : (
398
+ <div style={RANGE_NOTE_STYLE} data-testid="cs-data-validation-no-selection">
399
+ Select one or more cells first, then reopen this dialog.
400
+ </div>
401
+ )}
402
+
403
+ <label style={DIALOG_FIELD_STYLE}>
404
+ <span style={DIALOG_LABEL_STYLE}>Criteria</span>
405
+ <select
406
+ style={DIALOG_INPUT_STYLE}
407
+ data-testid="cs-data-validation-type"
408
+ value={state.ruleType}
409
+ onChange={(e) => update('ruleType', e.target.value as RuleType)}
410
+ >
411
+ {RULE_TYPE_OPTIONS.map((opt) => (
412
+ <option key={opt.value} value={opt.value}>
413
+ {opt.label}
414
+ </option>
415
+ ))}
416
+ </select>
417
+ </label>
418
+
419
+ {state.ruleType === 'list' && (
420
+ <label style={DIALOG_FIELD_STYLE}>
421
+ <span style={DIALOG_LABEL_STYLE}>Items (one per line, or comma-separated)</span>
422
+ <textarea
423
+ style={TEXTAREA_STYLE}
424
+ data-testid="cs-data-validation-list-items"
425
+ value={state.listItems}
426
+ placeholder={'Yes\nNo\nMaybe'}
427
+ onChange={(e) => update('listItems', e.target.value)}
428
+ />
429
+ </label>
430
+ )}
431
+
432
+ {showOperator && (
433
+ <>
434
+ <label style={DIALOG_FIELD_STYLE}>
435
+ <span style={DIALOG_LABEL_STYLE}>Condition</span>
436
+ <select
437
+ style={DIALOG_INPUT_STYLE}
438
+ data-testid="cs-data-validation-operator"
439
+ value={state.operator}
440
+ onChange={(e) => update('operator', e.target.value as Operator)}
441
+ >
442
+ {OPERATOR_OPTIONS.map((opt) => (
443
+ <option key={opt.value} value={opt.value}>
444
+ {opt.label}
445
+ </option>
446
+ ))}
447
+ </select>
448
+ </label>
449
+
450
+ <div style={showSecondOperand ? TWO_COL_STYLE : undefined}>
451
+ <label style={DIALOG_FIELD_STYLE}>
452
+ <span style={DIALOG_LABEL_STYLE}>{showSecondOperand ? 'Minimum' : 'Value'}</span>
453
+ <input
454
+ style={DIALOG_INPUT_STYLE}
455
+ data-testid="cs-data-validation-operand1"
456
+ type={operandType}
457
+ value={state.operand1}
458
+ onChange={(e) => update('operand1', e.target.value)}
459
+ />
460
+ </label>
461
+ {showSecondOperand && (
462
+ <label style={DIALOG_FIELD_STYLE}>
463
+ <span style={DIALOG_LABEL_STYLE}>Maximum</span>
464
+ <input
465
+ style={DIALOG_INPUT_STYLE}
466
+ data-testid="cs-data-validation-operand2"
467
+ type={operandType}
468
+ value={state.operand2}
469
+ onChange={(e) => update('operand2', e.target.value)}
470
+ />
471
+ </label>
472
+ )}
473
+ </div>
474
+ </>
475
+ )}
476
+
477
+ {state.ruleType === 'checkbox' && (
478
+ <div style={RANGE_NOTE_STYLE}>
479
+ Each cell in the selection becomes a checkbox (checked = TRUE).
480
+ </div>
481
+ )}
482
+
483
+ <label style={CHECK_STYLE} data-testid="cs-data-validation-ignore-blank-label">
484
+ <input
485
+ type="checkbox"
486
+ data-testid="cs-data-validation-ignore-blank"
487
+ checked={state.ignoreBlank}
488
+ onChange={(e) => update('ignoreBlank', e.target.checked)}
489
+ />
490
+ <span>Ignore blank cells</span>
491
+ </label>
492
+
493
+ <label style={CHECK_STYLE}>
494
+ <input
495
+ type="checkbox"
496
+ data-testid="cs-data-validation-show-input"
497
+ checked={state.showInputMessage}
498
+ onChange={(e) => update('showInputMessage', e.target.checked)}
499
+ />
500
+ <span>Show input message</span>
501
+ </label>
502
+ {state.showInputMessage && (
503
+ <label style={DIALOG_FIELD_STYLE}>
504
+ <span style={DIALOG_LABEL_STYLE}>Input message</span>
505
+ <input
506
+ style={DIALOG_INPUT_STYLE}
507
+ data-testid="cs-data-validation-input-message"
508
+ value={state.inputMessage}
509
+ onChange={(e) => update('inputMessage', e.target.value)}
510
+ />
511
+ </label>
512
+ )}
513
+
514
+ <label style={CHECK_STYLE}>
515
+ <input
516
+ type="checkbox"
517
+ data-testid="cs-data-validation-show-error"
518
+ checked={state.showErrorMessage}
519
+ onChange={(e) => update('showErrorMessage', e.target.checked)}
520
+ />
521
+ <span>Show error message on invalid input</span>
522
+ </label>
523
+ {state.showErrorMessage && (
524
+ <label style={DIALOG_FIELD_STYLE}>
525
+ <span style={DIALOG_LABEL_STYLE}>Error message</span>
526
+ <input
527
+ style={DIALOG_INPUT_STYLE}
528
+ data-testid="cs-data-validation-error-message"
529
+ value={state.errorMessage}
530
+ onChange={(e) => update('errorMessage', e.target.value)}
531
+ />
532
+ </label>
533
+ )}
534
+ </Dialog>
535
+ );
536
+ }