@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.
- package/README.md +5 -5
- package/config/vite.js +1 -24
- package/lib/blocks/lens/FieldData.ts +46 -0
- package/lib/blocks/lens/Rule.ts +6 -0
- package/lib/blocks/lens/components/LensCatalog.vue +57 -2
- package/lib/blocks/lens/components/LensCatalogControl.vue +9 -0
- package/lib/blocks/lens/components/LensCatalogStateFilterCondition.vue +13 -7
- package/lib/blocks/lens/components/LensFormFilter.vue +22 -3
- package/lib/blocks/lens/components/LensFormFilterCondition.vue +6 -0
- package/lib/blocks/lens/components/LensFormOverrideBase.vue +6 -0
- package/lib/blocks/lens/components/LensFormOverrideNumber.vue +166 -0
- package/lib/blocks/lens/components/LensFormView.vue +31 -4
- package/lib/blocks/lens/components/LensTable.vue +18 -5
- package/lib/blocks/lens/composables/SetupLens.ts +6 -0
- package/lib/blocks/lens/fields/BooleanField.ts +76 -0
- package/lib/blocks/lens/fields/DateField.ts +9 -1
- package/lib/blocks/lens/fields/DatetimeField.ts +8 -1
- package/lib/blocks/lens/fields/DecimalField.ts +60 -0
- package/lib/blocks/lens/fields/Field.ts +24 -1
- package/lib/blocks/lens/fields/NumberField.ts +25 -4
- package/lib/blocks/lens/fields/RelatedManyField.ts +29 -5
- package/lib/blocks/lens/fields/RelatedOneField.ts +119 -0
- package/lib/blocks/lens/fields/TextField.ts +2 -0
- package/lib/blocks/lens/fields/support/Renderers.ts +78 -0
- package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +5 -1
- package/lib/blocks/lens/validation/RuleMapper.ts +3 -1
- package/lib/components/SInputFileUpload.vue +17 -2
- package/lib/components/SInputFileUploadItem.vue +57 -16
- package/lib/components/STableCell.vue +1 -0
- package/lib/components/STableCellNumber.vue +25 -2
- package/lib/composables/Table.ts +6 -0
- package/lib/http/Http.ts +8 -13
- package/lib/support/Chart.ts +7 -3
- package/lib/support/Day.ts +5 -4
- package/lib/support/File.ts +25 -0
- package/lib/support/Num.ts +3 -2
- package/lib/support/Utils.ts +13 -0
- package/lib/validation/validators/maxLength.ts +3 -1
- package/lib/validation/validators/maxTotalFileSize.ts +10 -2
- package/lib/validation/validators/minLength.ts +3 -1
- 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
|
|
30
|
+
$ pnpm story
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Serve Histoire at http://localhost:4010.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
$ pnpm
|
|
36
|
+
$ pnpm docs
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Serve documentation website at http://localhost:4011.
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
$ pnpm
|
|
42
|
+
$ pnpm lint
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
Lint files using a rule of Standard JS.
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
$ pnpm
|
|
48
|
+
$ pnpm test
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Run the tests.
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
$ pnpm
|
|
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 {
|
package/lib/blocks/lens/Rule.ts
CHANGED
|
@@ -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]
|
|
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]
|
|
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
|
-
|
|
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
|
|
33
|
+
return field.value?.filterInputByOperator(props.condition.operator) ?? null
|
|
29
34
|
})
|
|
30
35
|
|
|
31
36
|
const fieldText = computed(() => {
|
|
32
|
-
|
|
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
|
|
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:
|
|
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
|
|
98
|
-
isEmpty
|
|
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 || ''
|