@globalbrain/sefirot 4.43.5 → 4.45.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.
Files changed (41) hide show
  1. package/README.md +5 -5
  2. package/config/vite.js +1 -24
  3. package/lib/blocks/lens/FieldData.ts +46 -0
  4. package/lib/blocks/lens/Rule.ts +6 -0
  5. package/lib/blocks/lens/components/LensCatalog.vue +57 -2
  6. package/lib/blocks/lens/components/LensCatalogControl.vue +9 -0
  7. package/lib/blocks/lens/components/LensCatalogStateFilterCondition.vue +13 -7
  8. package/lib/blocks/lens/components/LensFormFilter.vue +22 -3
  9. package/lib/blocks/lens/components/LensFormFilterCondition.vue +6 -0
  10. package/lib/blocks/lens/components/LensFormOverrideBase.vue +6 -0
  11. package/lib/blocks/lens/components/LensFormOverrideNumber.vue +166 -0
  12. package/lib/blocks/lens/components/LensFormView.vue +31 -4
  13. package/lib/blocks/lens/components/LensTable.vue +18 -5
  14. package/lib/blocks/lens/composables/SetupLens.ts +6 -0
  15. package/lib/blocks/lens/fields/BooleanField.ts +76 -0
  16. package/lib/blocks/lens/fields/DateField.ts +9 -1
  17. package/lib/blocks/lens/fields/DatetimeField.ts +8 -1
  18. package/lib/blocks/lens/fields/DecimalField.ts +60 -0
  19. package/lib/blocks/lens/fields/Field.ts +24 -1
  20. package/lib/blocks/lens/fields/NumberField.ts +25 -4
  21. package/lib/blocks/lens/fields/RelatedManyField.ts +29 -5
  22. package/lib/blocks/lens/fields/RelatedOneField.ts +119 -0
  23. package/lib/blocks/lens/fields/TextField.ts +2 -0
  24. package/lib/blocks/lens/fields/support/Renderers.ts +78 -0
  25. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +5 -1
  26. package/lib/blocks/lens/validation/RuleMapper.ts +3 -1
  27. package/lib/components/SInputFileUpload.vue +17 -2
  28. package/lib/components/SInputFileUploadItem.vue +57 -16
  29. package/lib/components/STableCell.vue +1 -0
  30. package/lib/components/STableCellNumber.vue +25 -2
  31. package/lib/composables/Table.ts +6 -0
  32. package/lib/http/Http.ts +8 -13
  33. package/lib/support/Chart.ts +7 -3
  34. package/lib/support/Day.ts +5 -4
  35. package/lib/support/File.ts +25 -0
  36. package/lib/support/Num.ts +3 -2
  37. package/lib/support/Utils.ts +13 -0
  38. package/lib/validation/validators/maxLength.ts +3 -1
  39. package/lib/validation/validators/maxTotalFileSize.ts +10 -2
  40. package/lib/validation/validators/minLength.ts +3 -1
  41. package/package.json +34 -35
package/README.md CHANGED
@@ -27,31 +27,31 @@ Sefirot follows the official [Vue Style Guide](https://v3.vuejs.org/style-guide/
27
27
  ### Development
28
28
 
29
29
  ```bash
30
- $ pnpm run story
30
+ $ pnpm story
31
31
  ```
32
32
 
33
33
  Serve Histoire at http://localhost:4010.
34
34
 
35
35
  ```bash
36
- $ pnpm run docs
36
+ $ pnpm docs
37
37
  ```
38
38
 
39
39
  Serve documentation website at http://localhost:4011.
40
40
 
41
41
  ```bash
42
- $ pnpm run lint
42
+ $ pnpm lint
43
43
  ```
44
44
 
45
45
  Lint files using a rule of Standard JS.
46
46
 
47
47
  ```bash
48
- $ pnpm run test
48
+ $ pnpm test
49
49
  ```
50
50
 
51
51
  Run the tests.
52
52
 
53
53
  ```bash
54
- $ pnpm run coverage
54
+ $ pnpm coverage
55
55
  ```
56
56
 
57
57
  Output test coverage in `coverage` directory.
package/config/vite.js CHANGED
@@ -13,13 +13,6 @@ const files = (await Array.fromAsync(glob(`**/*.ts`, { cwd: lib })))
13
13
  .filter((file) => !file.endsWith('.d.ts'))
14
14
  .map((file) => `sefirot/${file}`.replace(/(?:\/index)?\.ts$/, ''))
15
15
 
16
- // @ts-ignore
17
- const isRolldown = !!vite.rolldownVersion
18
-
19
- const define = {
20
- 'navigator.userAgent': '""'
21
- }
22
-
23
16
  /** @type {import('vite').UserConfig} */
24
17
  export const baseConfig = {
25
18
  plugins: [
@@ -52,18 +45,7 @@ export const baseConfig = {
52
45
  },
53
46
 
54
47
  ssr: {
55
- noExternal: [
56
- /sentry/
57
- ],
58
- optimizeDeps: {
59
- include: [
60
- 'file-saver'
61
- ],
62
- // @ts-ignore
63
- esbuildOptions: isRolldown ? undefined : { define },
64
- // @ts-ignore
65
- rolldownOptions: isRolldown ? { transform: { define } } : undefined
66
- }
48
+ noExternal: [/(?:^|\/)dayjs/]
67
49
  },
68
50
 
69
51
  optimizeDeps: {
@@ -71,12 +53,7 @@ export const baseConfig = {
71
53
  include: [
72
54
  ...files,
73
55
  '@globalbrain/sefirot/dompurify',
74
- 'dayjs',
75
- 'dayjs/plugin/relativeTime',
76
- 'dayjs/plugin/timezone',
77
- 'dayjs/plugin/utc',
78
56
  'dompurify',
79
- 'file-saver',
80
57
  'markdown-it > argparse',
81
58
  'markdown-it > entities',
82
59
  'pinia',
@@ -17,14 +17,17 @@ import { type Rule } from './Rule'
17
17
  * }
18
18
  */
19
19
  export interface FieldDataRegistry {
20
+ boolean: BooleanFieldData
20
21
  content: ContentFieldData
21
22
  date: DateFieldData
22
23
  datetime: DatetimeFieldData
24
+ decimal: DecimalFieldData
23
25
  file_upload: FileUploadFieldData
24
26
  id: IdFieldData
25
27
  link: LinkFieldData
26
28
  number: NumberFieldData
27
29
  related_many: RelatedManyFieldData
30
+ related_one: RelatedOneFieldData
28
31
  select: SelectFieldData
29
32
  slack_message: SlackMessageFieldData
30
33
  text: TextFieldData
@@ -47,6 +50,14 @@ export interface FieldDataBase {
47
50
  rules: Rule[]
48
51
  }
49
52
 
53
+ export interface BooleanFieldData extends FieldDataBase {
54
+ type: 'boolean'
55
+ labelTrueEn: string | null
56
+ labelTrueJa: string | null
57
+ labelFalseEn: string | null
58
+ labelFalseJa: string | null
59
+ }
60
+
50
61
  export interface ContentFieldData extends FieldDataBase {
51
62
  type: 'content'
52
63
  bodyEn: string
@@ -88,6 +99,24 @@ export interface LinkFieldData extends FieldDataBase {
88
99
 
89
100
  export interface NumberFieldData extends FieldDataBase {
90
101
  type: 'number'
102
+ align: 'left' | 'center' | 'right' | null
103
+ separator: boolean | null
104
+ abbr: 'en' | 'ja' | null
105
+ fractionDigits: number | null
106
+ }
107
+
108
+ /**
109
+ * A `decimal` field is rendered identically to a `number` field on the
110
+ * client. The distinction exists so backends can preserve arbitrary
111
+ * precision (e.g. sending the value as a string instead of a JS number)
112
+ * without losing the type-level signal at the spec layer.
113
+ */
114
+ export interface DecimalFieldData extends FieldDataBase {
115
+ type: 'decimal'
116
+ align: 'left' | 'center' | 'right' | null
117
+ separator: boolean | null
118
+ abbr: 'en' | 'ja' | null
119
+ fractionDigits: number | null
91
120
  }
92
121
 
93
122
  export interface SelectFieldData extends FieldDataBase {
@@ -112,10 +141,25 @@ export interface SelectFieldDataOption {
112
141
  export interface RelatedManyFieldData extends FieldDataBase {
113
142
  type: 'related_many'
114
143
  title: string
144
+ image?: string | null
145
+ resourceEndpointMethod: 'get' | 'post'
146
+ resourceEndpointPath: string
147
+ resourceEndpointDataKey: string | null
148
+ resourceTitle: string
149
+ resourceImage?: string | null
150
+ displayAs?: 'pills' | 'avatars' | null
151
+ }
152
+
153
+ export interface RelatedOneFieldData extends FieldDataBase {
154
+ type: 'related_one'
155
+ title: string
156
+ image?: string | null
115
157
  resourceEndpointMethod: 'get' | 'post'
116
158
  resourceEndpointPath: string
117
159
  resourceEndpointDataKey: string | null
118
160
  resourceTitle: string
161
+ resourceImage?: string | null
162
+ displayAs?: 'text' | 'avatar' | null
119
163
  }
120
164
 
121
165
  export interface SlackMessageFieldData extends FieldDataBase {
@@ -128,6 +172,8 @@ export interface TextFieldData extends FieldDataBase {
128
172
  placeholderJa: string | null
129
173
  helpEn: string | null
130
174
  helpJa: string | null
175
+ unitBefore: string | null
176
+ unitAfter: string | null
131
177
  }
132
178
 
133
179
  export interface TextareaFieldData extends FieldDataBase {
@@ -1,6 +1,7 @@
1
1
  export type Rule =
2
2
  | MaxLengthRule
3
3
  | RequiredRule
4
+ | SlackChannelNameRule
4
5
  | BeforeRule
5
6
  | BeforeOrEqualRule
6
7
  | AfterRule
@@ -15,6 +16,11 @@ export interface RequiredRule {
15
16
  type: 'required'
16
17
  }
17
18
 
19
+ export interface SlackChannelNameRule {
20
+ type: 'slack_channel_name'
21
+ offset: number
22
+ }
23
+
18
24
  export interface BeforeRule {
19
25
  type: 'before'
20
26
  date: string
@@ -102,6 +102,13 @@ export interface Props {
102
102
  // the catalog will hide entire catalog component and renders the given
103
103
  // `#empty-state` slot instead.
104
104
  showEmptyState?: boolean
105
+
106
+ // Whether to populate the selectable column list from every field the
107
+ // backend exposes for the entity, rather than only the columns passed
108
+ // in `selectable` / `select`. Use this when the catalog should let the
109
+ // user add any available column to the view (e.g. a saved-view editor),
110
+ // not just toggle the ones already selected.
111
+ loadSelectable?: boolean
105
112
  }
106
113
 
107
114
  const props = withDefaults(defineProps<Props>(), {
@@ -116,6 +123,7 @@ const emit = defineEmits<{
116
123
  'select-updated': [select: string[]]
117
124
  'selectable-updated': [selectable: string[]]
118
125
  'filters-updated': [filters: any[]]
126
+ 'query-filter-updated': [filter: any[]]
119
127
  'sort-updated': [sort: LensQuerySort[]]
120
128
  'overrides-updated': [overrides: Record<string, Partial<FieldData>>]
121
129
  'cell-clicked': [value: any, record: any]
@@ -155,6 +163,13 @@ const queryFilter = computed(() => {
155
163
  })]
156
164
  })
157
165
 
166
+ // Surface the free-text query filter so callers can fold it into their
167
+ // own export / side-channel requests (the search box lives inside the
168
+ // catalog, so the parent has no other way to observe it).
169
+ watch(queryFilter, (filter) => {
170
+ emit('query-filter-updated', filter)
171
+ })
172
+
158
173
  const _filters = ref(props.filters ?? [])
159
174
 
160
175
  const _sort = ref<LensQuerySort[]>([])
@@ -262,6 +277,20 @@ watch(result, (res) => {
262
277
  if (_selectable.value.length === 0) {
263
278
  _selectable.value = withoutIndexField(res!.query.select)
264
279
  }
280
+ // When `loadSelectable` is set, widen the selectable column list to
281
+ // every field the backend exposes for the entity (minus the index
282
+ // field), so the column picker can offer columns that aren't part of
283
+ // the current selection. Existing entries are preserved and order is
284
+ // kept stable by appending only the not-yet-present keys.
285
+ if (props.loadSelectable && res!.fields) {
286
+ const present = new Set(_selectable.value)
287
+ for (const key of withoutIndexField(Object.keys(res!.fields))) {
288
+ if (!present.has(key)) {
289
+ _selectable.value.push(key)
290
+ present.add(key)
291
+ }
292
+ }
293
+ }
265
294
  }, { once: true })
266
295
 
267
296
  // Columns to render in the table. We always defer to `_select` (caller
@@ -328,14 +357,21 @@ function onInlineFilterUpdated(filter: any[]) {
328
357
  emit('filters-updated', _filters.value)
329
358
  }
330
359
 
360
+ // A filter value "clears" the filter when it's nullish or an empty
361
+ // array. Scalar operators (e.g. a boolean `=`) carry a single value, so
362
+ // `false` is a real value to keep — only `null` clears.
363
+ function isEmptyFilterValue(value: any): boolean {
364
+ return value == null || (Array.isArray(value) && value.length === 0)
365
+ }
366
+
331
367
  function applyNewFilter(filter: any[]) {
332
- if (filter[2].length > 0) {
368
+ if (!isEmptyFilterValue(filter[2])) {
333
369
  _filters.value.push(filter)
334
370
  }
335
371
  }
336
372
 
337
373
  function replaceFilter(index: number, filter: any[]) {
338
- if (filter[2].length === 0) {
374
+ if (isEmptyFilterValue(filter[2])) {
339
375
  _filters.value.splice(index, 1)
340
376
  return
341
377
  }
@@ -395,7 +431,23 @@ function onNext() {
395
431
  doRefresh()
396
432
  }
397
433
 
434
+ // Re-runs the current query against the endpoint, preserving the
435
+ // catalog's in-memory state (select / filters / sort / page). Exposed
436
+ // so callers can reflect server-side changes — e.g. after a bulk action
437
+ // mutates rows — without remounting the component.
438
+ async function refreshCatalog(): Promise<void> {
439
+ // A refresh is requested precisely when server-side data may have
440
+ // changed while the query input did not (e.g. after a bulk action).
441
+ // Clear the memoized input so the equality shortcut in the fetcher
442
+ // misses and a real request is issued instead of returning the stale
443
+ // cached result.
444
+ prevFetchInput = null
445
+ await doRefresh()
446
+ }
447
+
398
448
  defineExpose({
449
+ refresh: refreshCatalog,
450
+
399
451
  /**
400
452
  * Retrieve the current records in the catalog. This method is required when
401
453
  * the parent component needs to access the records directly, for example, to
@@ -457,6 +509,9 @@ defineExpose({
457
509
  <template v-if="$slots['controls-sub-right']" #sub-right>
458
510
  <slot name="controls-sub-right" />
459
511
  </template>
512
+ <template v-if="$slots['selected-actions']" #selected-actions>
513
+ <slot name="selected-actions" />
514
+ </template>
460
515
  </LensCatalogControl>
461
516
  <div v-else class="control-skeleton" />
462
517
  <div v-if="!hideConditions && result && (_filters.length > 0 || _sort.length > 0)" ref="conditionBlocksEl" class="condition-blocks">
@@ -138,6 +138,9 @@ function createFilterPresetOptions(): ActionList {
138
138
  <IconX class="selected-reset-icon" />
139
139
  </button>
140
140
  </div>
141
+ <div v-if="$slots['selected-actions']" class="selected-actions">
142
+ <slot name="selected-actions" />
143
+ </div>
141
144
  </div>
142
145
  </template>
143
146
  </div>
@@ -165,6 +168,12 @@ function createFilterPresetOptions(): ActionList {
165
168
  gap: 8px;
166
169
  }
167
170
 
171
+ .selected-actions {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 8px;
175
+ }
176
+
168
177
  .selected-box {
169
178
  display: flex;
170
179
  align-items: center;
@@ -21,15 +21,21 @@ const props = defineProps<Props>()
21
21
  const fieldFactory = useFieldFactory()
22
22
 
23
23
  const field = computed(() => {
24
- return fieldFactory.make(props.fields[props.condition.field])
24
+ const fieldData = props.fields[props.condition.field]
25
+ // A still-applied filter can reference a field that's no longer in the
26
+ // field set (e.g. a stale saved filter). Keep the chip rendering rather
27
+ // than crashing on `make(undefined)`, so the active filter stays
28
+ // visible and counted instead of silently filtering the table.
29
+ return fieldData ? fieldFactory.make(fieldData) : null
25
30
  })
26
31
 
27
32
  const input = computed(() => {
28
- return field.value.filterInputByOperator(props.condition.operator)
33
+ return field.value?.filterInputByOperator(props.condition.operator) ?? null
29
34
  })
30
35
 
31
36
  const fieldText = computed(() => {
32
- return field.value.label()
37
+ // Fall back to the raw field key when the field has no definition.
38
+ return field.value ? field.value.label() : props.condition.field
33
39
  })
34
40
 
35
41
  const operatorText = computed(() => {
@@ -37,6 +43,9 @@ const operatorText = computed(() => {
37
43
  })
38
44
 
39
45
  const valueText = computedAsync(async () => {
46
+ if (!input.value) {
47
+ return String(props.condition.value ?? '')
48
+ }
40
49
  return input.value.valueToText(props.condition.value)
41
50
  }, '...')
42
51
  </script>
@@ -45,10 +54,7 @@ const valueText = computedAsync(async () => {
45
54
  <div class="LensCatalogStateFilterCondition">
46
55
  <div class="field">{{ fieldText }}</div>
47
56
  <div class="operator">{{ operatorText }}</div>
48
- <div v-if="input === null" class="value">
49
- ...
50
- </div>
51
- <div v-else class="value">
57
+ <div class="value">
52
58
  {{ valueText }}
53
59
  </div>
54
60
  </div>
@@ -57,6 +57,12 @@ const { validateAndNotify } = useValidation()
57
57
  const fieldOptions = computed(() => {
58
58
  return props.filterable.filter((key) => {
59
59
  const fieldData = props.fields[key]
60
+ // Skip keys with no field definition — e.g. `__empty__` spacer
61
+ // columns that appear in `select` / `selectable` but carry no field
62
+ // metadata. Without this guard `make(undefined)` throws.
63
+ if (!fieldData) {
64
+ return false
65
+ }
60
66
  const field = fieldFactory.make(fieldData)
61
67
  return field.availableFilterOperators().length > 0
62
68
  }).map((key) => {
@@ -89,14 +95,27 @@ const fieldOptions = computed(() => {
89
95
  // ]
90
96
  // }
91
97
  function lensFiltersToGroup() {
98
+ const conditions = props.filters.length > 0
99
+ ? pruneMissingFields(props.filters.map(lensConditionToCondition))
100
+ : []
101
+
92
102
  return {
93
103
  connector: '$and' as const,
94
- conditions: props.filters.length > 0
95
- ? props.filters.map(lensConditionToCondition)
96
- : [createEmptyCondition()]
104
+ conditions: conditions.length > 0 ? conditions : [createEmptyCondition()]
97
105
  }
98
106
  }
99
107
 
108
+ // Drop conditions that reference a field absent from `props.fields` (for
109
+ // example a stale saved filter pointing at a field that no longer
110
+ // exists). Such a condition can't be rendered or edited, and would
111
+ // otherwise pass validation with a null input and silently re-emit the
112
+ // stale filter on Apply. Groups left empty by pruning are removed too.
113
+ function pruneMissingFields(conditions: any[]): any[] {
114
+ return conditions
115
+ .map((c) => ('connector' in c ? { ...c, conditions: pruneMissingFields(c.conditions) } : c))
116
+ .filter((c) => ('connector' in c ? c.conditions.length > 0 : c.field === null || props.fields[c.field]))
117
+ }
118
+
100
119
  function lensConditionToCondition(filter: any[]) {
101
120
  const fieldOrConnector = filter[0]
102
121
 
@@ -48,6 +48,12 @@ const field = computed(() => {
48
48
  return null
49
49
  }
50
50
  const fieldData = props.fields[model.value.field]
51
+ // Guard against a condition referencing a key with no field
52
+ // definition (e.g. a stale saved filter, or a spacer key) so
53
+ // `make(undefined)` doesn't throw.
54
+ if (!fieldData) {
55
+ return null
56
+ }
51
57
  return fieldFactory.make(fieldData)
52
58
  })
53
59
 
@@ -152,6 +152,12 @@ async function onSave() {
152
152
  <SInputCheckbox v-model="freeze" />
153
153
  </div>
154
154
  </div>
155
+ <!--
156
+ Default slot — field-specific override forms (e.g. number /
157
+ decimal) inject their own `.item` rows here so they line up
158
+ visually with the base inputs.
159
+ -->
160
+ <slot />
155
161
  </div>
156
162
  </SDoc>
157
163
  </SCardBlock>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts" generic="T extends DecimalFieldData | NumberFieldData">
2
+ import { computed, reactive } from 'vue'
3
+ import SInputCheckbox from '../../../components/SInputCheckbox.vue'
4
+ import SInputNumber from '../../../components/SInputNumber.vue'
5
+ import SInputSelect from '../../../components/SInputSelect.vue'
6
+ import { useTrans } from '../../../composables/Lang'
7
+ import { type DecimalFieldData, type NumberFieldData } from '../FieldData'
8
+ import LensFormOverrideBase from './LensFormOverrideBase.vue'
9
+
10
+ const props = defineProps<{
11
+ name: string
12
+ field: T
13
+ override: T
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ cancel: []
18
+ saved: [field: Partial<T>]
19
+ }>()
20
+
21
+ const { t } = useTrans({
22
+ en: {
23
+ i_align_label: 'Alignment',
24
+ i_separator_text: 'Separate by comma',
25
+ i_abbr_label: 'Abbreviation',
26
+ i_fraction_digits: 'Fraction digits'
27
+ },
28
+ ja: {
29
+ i_align_label: '配置',
30
+ i_separator_text: 'カンマで区切る',
31
+ i_abbr_label: '省略表記',
32
+ i_fraction_digits: '小数点以下の桁数'
33
+ }
34
+ })
35
+
36
+ // Local state for the number-specific inputs. Base inputs
37
+ // (label, width, freeze) keep their own state inside
38
+ // `LensFormOverrideBase`; we receive their changes via the `saved`
39
+ // event and merge in our four extras here.
40
+ //
41
+ // `separator` keeps its original `boolean | null` value so a no-op
42
+ // submit (user opens the modal and clicks Finish without touching
43
+ // the checkbox) doesn't promote `null` to `false`. The
44
+ // `separatorModel` computed below adapts `null` to `false` only for
45
+ // the `SInputCheckbox` v-model.
46
+ const data = reactive({
47
+ align: props.override.align,
48
+ separator: props.override.separator,
49
+ abbr: props.override.abbr,
50
+ fractionDigits: props.override.fractionDigits
51
+ })
52
+
53
+ // `SInputCheckbox` accepts `boolean | 'indeterminate' | undefined`
54
+ // (not `null`), so adapt at the binding boundary. The model writes
55
+ // the user's `true`/`false` straight into `data.separator`, while
56
+ // `null` from the field-level default stays `null` until the user
57
+ // actually flips the checkbox.
58
+ const separatorModel = computed({
59
+ get: () => data.separator ?? false,
60
+ set: (v) => { data.separator = v }
61
+ })
62
+
63
+ const { t: alignOptions } = useTrans({
64
+ en: [
65
+ { label: 'Left', value: 'left' },
66
+ { label: 'Center', value: 'center' },
67
+ { label: 'Right', value: 'right' }
68
+ ],
69
+ ja: [
70
+ { label: '左寄せ', value: 'left' },
71
+ { label: '中央寄せ', value: 'center' },
72
+ { label: '右寄せ', value: 'right' }
73
+ ]
74
+ })
75
+
76
+ // `null` represents "no abbreviation". We expose it as a real option
77
+ // so users can switch back to plain rendering after picking a locale.
78
+ // `SInputSelect` accepts `null` values when the option carries
79
+ // `value: null`.
80
+ const { t: abbrOptions } = useTrans({
81
+ en: [
82
+ { label: 'None', value: null },
83
+ { label: 'English (1K, 1M, 1B)', value: 'en' },
84
+ { label: 'Japanese (1万, 1億, 1兆)', value: 'ja' }
85
+ ],
86
+ ja: [
87
+ { label: 'なし', value: null },
88
+ { label: '英語スタイル (1K, 1M, 1B)', value: 'en' },
89
+ { label: '日本語スタイル (1万, 1億, 1兆)', value: 'ja' }
90
+ ]
91
+ })
92
+
93
+ function onSaved(override: Partial<T>): void {
94
+ // Only include keys whose value diverged from the underlying field
95
+ // definition — matches what `LensFormOverrideBase` does for the
96
+ // base inputs. We mutate the payload from the base in place rather
97
+ // than re-allocating so any keys it already populated (label,
98
+ // width, freeze) carry through unchanged.
99
+ //
100
+ // Plain `!==` (not the base's `isNotNullOrSame`) is intentional: the
101
+ // four formatting options are tri-state, so clearing a previous
102
+ // `align: 'right'` back to `null` must actually persist `align: null`
103
+ // rather than silently inherit the field-level default.
104
+ const o = override as Record<string, any>
105
+ if (data.align !== props.field.align) {
106
+ o.align = data.align
107
+ }
108
+ if (data.separator !== props.field.separator) {
109
+ o.separator = data.separator
110
+ }
111
+ if (data.abbr !== props.field.abbr) {
112
+ o.abbr = data.abbr
113
+ }
114
+ if (data.fractionDigits !== props.field.fractionDigits) {
115
+ o.fractionDigits = data.fractionDigits
116
+ }
117
+ emit('saved', o as Partial<T>)
118
+ }
119
+ </script>
120
+
121
+ <template>
122
+ <LensFormOverrideBase
123
+ class="LensFormOverrideNumber"
124
+ :name
125
+ :field
126
+ :label-en="override.labelEn"
127
+ :label-ja="override.labelJa"
128
+ :width="override.width"
129
+ :freeze="override.freeze"
130
+ @cancel="$emit('cancel')"
131
+ @saved="onSaved"
132
+ >
133
+ <div class="item">
134
+ <div class="key">{{ t.i_align_label }}</div>
135
+ <div class="value">
136
+ <SInputSelect
137
+ v-model="data.align"
138
+ size="mini"
139
+ :options="alignOptions"
140
+ />
141
+ </div>
142
+ </div>
143
+ <div class="item">
144
+ <div class="key">{{ t.i_separator_text }}</div>
145
+ <div class="value">
146
+ <SInputCheckbox v-model="separatorModel" />
147
+ </div>
148
+ </div>
149
+ <div class="item">
150
+ <div class="key">{{ t.i_abbr_label }}</div>
151
+ <div class="value">
152
+ <SInputSelect
153
+ v-model="data.abbr"
154
+ size="mini"
155
+ :options="abbrOptions"
156
+ />
157
+ </div>
158
+ </div>
159
+ <div class="item">
160
+ <div class="key">{{ t.i_fraction_digits }}</div>
161
+ <div class="value">
162
+ <SInputNumber v-model="data.fractionDigits" size="mini" />
163
+ </div>
164
+ </div>
165
+ </LensFormOverrideBase>
166
+ </template>
@@ -52,14 +52,16 @@ const { t } = useTrans({
52
52
  a_select_all: 'Select all',
53
53
  a_clear_all: 'Clear all',
54
54
  a_cancel: 'Cancel',
55
- a_apply: 'Apply changes'
55
+ a_apply: 'Apply changes',
56
+ empty_column: '(Empty column)'
56
57
  },
57
58
  ja: {
58
59
  title: 'テーブルの表示を更新する',
59
60
  a_select_all: 'すべて選択',
60
61
  a_clear_all: 'すべて解除',
61
62
  a_cancel: 'キャンセル',
62
- a_apply: '変更を適用'
63
+ a_apply: '変更を適用',
64
+ empty_column: '(空列)'
63
65
  }
64
66
  })
65
67
 
@@ -90,12 +92,32 @@ useDraggable(el, selectOptions, {
90
92
  })
91
93
 
92
94
  function createSelectOptions(): SelectOption[] {
95
+ // `select` is a subsequence of `selectable` (the editor derives both
96
+ // from the same ordered list), so we walk them in lockstep to recover
97
+ // which specific `__empty__` spacer was selected. Real fields are keyed
98
+ // by membership (order-robust); spacers, which share the `__empty__`
99
+ // key and can't be told apart by `_selectDict`, are matched positionally
100
+ // via the shared cursor.
101
+ let cursor = 0
93
102
  return _selectable.value.map((s) => {
103
+ const isEmpty = s === '__empty__'
104
+ let value: boolean
105
+ if (isEmpty) {
106
+ value = _select.value[cursor] === '__empty__'
107
+ if (value) {
108
+ cursor++
109
+ }
110
+ } else {
111
+ value = !!_selectDict.value[s]
112
+ if (_select.value[cursor] === s) {
113
+ cursor++
114
+ }
115
+ }
94
116
  return {
95
117
  uid: _uid++,
96
118
  key: s,
97
- value: _selectDict.value[s],
98
- isEmpty: s === '__empty__',
119
+ value,
120
+ isEmpty,
99
121
  field: props.fields[s],
100
122
  override: _overrides[s] || {}
101
123
  }
@@ -103,6 +125,11 @@ function createSelectOptions(): SelectOption[] {
103
125
  }
104
126
 
105
127
  function getName(s: SelectOption): string {
128
+ // Empty spacer columns carry no field definition; show a localized
129
+ // placeholder label so the row is identifiable in the view editor.
130
+ if (s.isEmpty) {
131
+ return t.empty_column
132
+ }
106
133
  return lang === 'ja'
107
134
  ? s.override.labelJa || s.field?.labelJa || ''
108
135
  : s.override.labelEn || s.field?.labelEn || ''