@globalbrain/sefirot 4.44.0 → 4.46.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 +27 -0
- package/lib/blocks/lens/Rule.ts +6 -0
- package/lib/blocks/lens/components/LensCatalog.vue +61 -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/LensFormView.vue +31 -4
- package/lib/blocks/lens/components/LensTable.vue +18 -5
- package/lib/blocks/lens/composables/SetupLens.ts +4 -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/Field.ts +24 -1
- 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/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/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/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,6 +17,7 @@ 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
|
|
@@ -26,6 +27,7 @@ export interface FieldDataRegistry {
|
|
|
26
27
|
link: LinkFieldData
|
|
27
28
|
number: NumberFieldData
|
|
28
29
|
related_many: RelatedManyFieldData
|
|
30
|
+
related_one: RelatedOneFieldData
|
|
29
31
|
select: SelectFieldData
|
|
30
32
|
slack_message: SlackMessageFieldData
|
|
31
33
|
text: TextFieldData
|
|
@@ -48,6 +50,14 @@ export interface FieldDataBase {
|
|
|
48
50
|
rules: Rule[]
|
|
49
51
|
}
|
|
50
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
|
+
|
|
51
61
|
export interface ContentFieldData extends FieldDataBase {
|
|
52
62
|
type: 'content'
|
|
53
63
|
bodyEn: string
|
|
@@ -131,10 +141,25 @@ export interface SelectFieldDataOption {
|
|
|
131
141
|
export interface RelatedManyFieldData extends FieldDataBase {
|
|
132
142
|
type: 'related_many'
|
|
133
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
|
|
134
157
|
resourceEndpointMethod: 'get' | 'post'
|
|
135
158
|
resourceEndpointPath: string
|
|
136
159
|
resourceEndpointDataKey: string | null
|
|
137
160
|
resourceTitle: string
|
|
161
|
+
resourceImage?: string | null
|
|
162
|
+
displayAs?: 'text' | 'avatar' | null
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
export interface SlackMessageFieldData extends FieldDataBase {
|
|
@@ -147,6 +172,8 @@ export interface TextFieldData extends FieldDataBase {
|
|
|
147
172
|
placeholderJa: string | null
|
|
148
173
|
helpEn: string | null
|
|
149
174
|
helpJa: string | null
|
|
175
|
+
unitBefore: string | null
|
|
176
|
+
unitAfter: string | null
|
|
150
177
|
}
|
|
151
178
|
|
|
152
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
|
|
@@ -3,6 +3,7 @@ import { useDebounceFn, useElementSize } from '@vueuse/core'
|
|
|
3
3
|
import { computed, ref, watch } from 'vue'
|
|
4
4
|
import SDivider from '../../../components/SDivider.vue'
|
|
5
5
|
import { useQuery } from '../../../composables/Api'
|
|
6
|
+
import { useLang } from '../../../composables/Lang'
|
|
6
7
|
import { usePower } from '../../../composables/Power'
|
|
7
8
|
import { type FieldData } from '../FieldData'
|
|
8
9
|
import { type LensQuery, type LensQuerySort } from '../LensQuery'
|
|
@@ -102,6 +103,13 @@ export interface Props {
|
|
|
102
103
|
// the catalog will hide entire catalog component and renders the given
|
|
103
104
|
// `#empty-state` slot instead.
|
|
104
105
|
showEmptyState?: boolean
|
|
106
|
+
|
|
107
|
+
// Whether to populate the selectable column list from every field the
|
|
108
|
+
// backend exposes for the entity, rather than only the columns passed
|
|
109
|
+
// in `selectable` / `select`. Use this when the catalog should let the
|
|
110
|
+
// user add any available column to the view (e.g. a saved-view editor),
|
|
111
|
+
// not just toggle the ones already selected.
|
|
112
|
+
loadSelectable?: boolean
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -116,6 +124,7 @@ const emit = defineEmits<{
|
|
|
116
124
|
'select-updated': [select: string[]]
|
|
117
125
|
'selectable-updated': [selectable: string[]]
|
|
118
126
|
'filters-updated': [filters: any[]]
|
|
127
|
+
'query-filter-updated': [filter: any[]]
|
|
119
128
|
'sort-updated': [sort: LensQuerySort[]]
|
|
120
129
|
'overrides-updated': [overrides: Record<string, Partial<FieldData>>]
|
|
121
130
|
'cell-clicked': [value: any, record: any]
|
|
@@ -155,6 +164,13 @@ const queryFilter = computed(() => {
|
|
|
155
164
|
})]
|
|
156
165
|
})
|
|
157
166
|
|
|
167
|
+
// Surface the free-text query filter so callers can fold it into their
|
|
168
|
+
// own export / side-channel requests (the search box lives inside the
|
|
169
|
+
// catalog, so the parent has no other way to observe it).
|
|
170
|
+
watch(queryFilter, (filter) => {
|
|
171
|
+
emit('query-filter-updated', filter)
|
|
172
|
+
})
|
|
173
|
+
|
|
158
174
|
const _filters = ref(props.filters ?? [])
|
|
159
175
|
|
|
160
176
|
const _sort = ref<LensQuerySort[]>([])
|
|
@@ -165,12 +181,15 @@ const _overrides = ref(props.overrides ?? {})
|
|
|
165
181
|
const page = ref(1)
|
|
166
182
|
const perPage = ref(100)
|
|
167
183
|
|
|
184
|
+
const lang = useLang()
|
|
185
|
+
|
|
168
186
|
const { data: result, execute: refresh, loading } = useQuery(async (http) => {
|
|
169
187
|
const input = {
|
|
170
188
|
entity: props.entity ?? '__no_entity__',
|
|
171
189
|
select: withIndexField(_select.value),
|
|
172
190
|
filters: createInputFilters(queryFilter.value, _filters.value),
|
|
173
191
|
sort: _sort.value.length > 0 ? _sort.value : defaultSort.value ?? [],
|
|
192
|
+
settings: { lang },
|
|
174
193
|
page: page.value,
|
|
175
194
|
perPage: perPage.value
|
|
176
195
|
}
|
|
@@ -262,6 +281,20 @@ watch(result, (res) => {
|
|
|
262
281
|
if (_selectable.value.length === 0) {
|
|
263
282
|
_selectable.value = withoutIndexField(res!.query.select)
|
|
264
283
|
}
|
|
284
|
+
// When `loadSelectable` is set, widen the selectable column list to
|
|
285
|
+
// every field the backend exposes for the entity (minus the index
|
|
286
|
+
// field), so the column picker can offer columns that aren't part of
|
|
287
|
+
// the current selection. Existing entries are preserved and order is
|
|
288
|
+
// kept stable by appending only the not-yet-present keys.
|
|
289
|
+
if (props.loadSelectable && res!.fields) {
|
|
290
|
+
const present = new Set(_selectable.value)
|
|
291
|
+
for (const key of withoutIndexField(Object.keys(res!.fields))) {
|
|
292
|
+
if (!present.has(key)) {
|
|
293
|
+
_selectable.value.push(key)
|
|
294
|
+
present.add(key)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
265
298
|
}, { once: true })
|
|
266
299
|
|
|
267
300
|
// Columns to render in the table. We always defer to `_select` (caller
|
|
@@ -328,14 +361,21 @@ function onInlineFilterUpdated(filter: any[]) {
|
|
|
328
361
|
emit('filters-updated', _filters.value)
|
|
329
362
|
}
|
|
330
363
|
|
|
364
|
+
// A filter value "clears" the filter when it's nullish or an empty
|
|
365
|
+
// array. Scalar operators (e.g. a boolean `=`) carry a single value, so
|
|
366
|
+
// `false` is a real value to keep — only `null` clears.
|
|
367
|
+
function isEmptyFilterValue(value: any): boolean {
|
|
368
|
+
return value == null || (Array.isArray(value) && value.length === 0)
|
|
369
|
+
}
|
|
370
|
+
|
|
331
371
|
function applyNewFilter(filter: any[]) {
|
|
332
|
-
if (filter[2]
|
|
372
|
+
if (!isEmptyFilterValue(filter[2])) {
|
|
333
373
|
_filters.value.push(filter)
|
|
334
374
|
}
|
|
335
375
|
}
|
|
336
376
|
|
|
337
377
|
function replaceFilter(index: number, filter: any[]) {
|
|
338
|
-
if (filter[2]
|
|
378
|
+
if (isEmptyFilterValue(filter[2])) {
|
|
339
379
|
_filters.value.splice(index, 1)
|
|
340
380
|
return
|
|
341
381
|
}
|
|
@@ -395,7 +435,23 @@ function onNext() {
|
|
|
395
435
|
doRefresh()
|
|
396
436
|
}
|
|
397
437
|
|
|
438
|
+
// Re-runs the current query against the endpoint, preserving the
|
|
439
|
+
// catalog's in-memory state (select / filters / sort / page). Exposed
|
|
440
|
+
// so callers can reflect server-side changes — e.g. after a bulk action
|
|
441
|
+
// mutates rows — without remounting the component.
|
|
442
|
+
async function refreshCatalog(): Promise<void> {
|
|
443
|
+
// A refresh is requested precisely when server-side data may have
|
|
444
|
+
// changed while the query input did not (e.g. after a bulk action).
|
|
445
|
+
// Clear the memoized input so the equality shortcut in the fetcher
|
|
446
|
+
// misses and a real request is issued instead of returning the stale
|
|
447
|
+
// cached result.
|
|
448
|
+
prevFetchInput = null
|
|
449
|
+
await doRefresh()
|
|
450
|
+
}
|
|
451
|
+
|
|
398
452
|
defineExpose({
|
|
453
|
+
refresh: refreshCatalog,
|
|
454
|
+
|
|
399
455
|
/**
|
|
400
456
|
* Retrieve the current records in the catalog. This method is required when
|
|
401
457
|
* the parent component needs to access the records directly, for example, to
|
|
@@ -457,6 +513,9 @@ defineExpose({
|
|
|
457
513
|
<template v-if="$slots['controls-sub-right']" #sub-right>
|
|
458
514
|
<slot name="controls-sub-right" />
|
|
459
515
|
</template>
|
|
516
|
+
<template v-if="$slots['selected-actions']" #selected-actions>
|
|
517
|
+
<slot name="selected-actions" />
|
|
518
|
+
</template>
|
|
460
519
|
</LensCatalogControl>
|
|
461
520
|
<div v-else class="control-skeleton" />
|
|
462
521
|
<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
|
|
|
@@ -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 || ''
|
|
@@ -43,11 +43,16 @@ const records = computed(() => props.result?.data ?? [])
|
|
|
43
43
|
const columnKeys = computed(() => {
|
|
44
44
|
const requested = props.select ?? props.result?.query.select ?? []
|
|
45
45
|
const fetched = props.result?.query.select
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
const fetchedSet = fetched ? new Set(fetched) : null
|
|
47
|
+
const visible = fetchedSet
|
|
48
|
+
? requested.filter((k) => fetchedSet.has(k))
|
|
49
|
+
: requested
|
|
50
|
+
// Give each `__empty__` spacer a unique render key. The select can
|
|
51
|
+
// contain several `__empty__` entries (blank spacer columns, also used
|
|
52
|
+
// to inject empty columns into Excel exports); without unique keys they
|
|
53
|
+
// would collide on the same `columns` map entry and only render once.
|
|
54
|
+
let emptyIndex = 0
|
|
55
|
+
return visible.map((k) => (k === '__empty__' ? `__empty__::${emptyIndex++}` : k))
|
|
51
56
|
})
|
|
52
57
|
|
|
53
58
|
const orders = computed(() => [
|
|
@@ -79,6 +84,14 @@ const columns = computedAsync(async () => {
|
|
|
79
84
|
|
|
80
85
|
// Build the list of columns based on the resolved column key list.
|
|
81
86
|
for (const key of keys) {
|
|
87
|
+
// Render `__empty__` spacer keys (uniquified to `__empty__::N`) as
|
|
88
|
+
// blank columns, matching the empty columns the backend injects into
|
|
89
|
+
// Excel exports. These carry no field definition.
|
|
90
|
+
if (key.startsWith('__empty__')) {
|
|
91
|
+
columns[key] = { width: '128px', cell: { type: 'empty' } }
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
const _fieldData = cloneDeep(r.fields[key])
|
|
83
96
|
|
|
84
97
|
if (!_fieldData) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FieldDataType } from '../FieldData'
|
|
2
2
|
import { type FieldDataFor, type FieldProvider, FieldRegistry } from '../FieldRegistry'
|
|
3
3
|
import { provideFieldRegistry } from '../composables/FieldRegistry'
|
|
4
|
+
import { BooleanField } from '../fields/BooleanField'
|
|
4
5
|
import { ContentField } from '../fields/ContentField'
|
|
5
6
|
import { DateField } from '../fields/DateField'
|
|
6
7
|
import { DatetimeField } from '../fields/DatetimeField'
|
|
@@ -10,6 +11,7 @@ import { IdField } from '../fields/IdField'
|
|
|
10
11
|
import { LinkField } from '../fields/LinkField'
|
|
11
12
|
import { NumberField } from '../fields/NumberField'
|
|
12
13
|
import { RelatedManyField } from '../fields/RelatedManyField'
|
|
14
|
+
import { RelatedOneField } from '../fields/RelatedOneField'
|
|
13
15
|
import { SelectField } from '../fields/SelectField'
|
|
14
16
|
import { SlackMessageField } from '../fields/SlackMessageField'
|
|
15
17
|
import { TextField } from '../fields/TextField'
|
|
@@ -32,6 +34,7 @@ export function useSetupLens(): SetupLens {
|
|
|
32
34
|
registerDefaultFields()
|
|
33
35
|
|
|
34
36
|
function registerDefaultFields(): void {
|
|
37
|
+
fieldRegistry.register('boolean', (ctx, field) => new BooleanField(ctx, field))
|
|
35
38
|
fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
|
|
36
39
|
fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
|
|
37
40
|
fieldRegistry.register('datetime', (ctx, field) => new DatetimeField(ctx, field))
|
|
@@ -41,6 +44,7 @@ export function useSetupLens(): SetupLens {
|
|
|
41
44
|
fieldRegistry.register('number', (ctx, field) => new NumberField(ctx, field))
|
|
42
45
|
fieldRegistry.register('id', (ctx, field) => new IdField(ctx, field))
|
|
43
46
|
fieldRegistry.register('related_many', (ctx, field) => new RelatedManyField(ctx, field, resourceFetcher))
|
|
47
|
+
fieldRegistry.register('related_one', (ctx, field) => new RelatedOneField(ctx, field, resourceFetcher))
|
|
44
48
|
fieldRegistry.register('select', (ctx, field) => new SelectField(ctx, field))
|
|
45
49
|
fieldRegistry.register('slack_message', (ctx, field) => new SlackMessageField(ctx, field))
|
|
46
50
|
fieldRegistry.register('text', (ctx, field) => new TextField(ctx, field))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type DropdownSection } from '../../../composables/Dropdown'
|
|
2
|
+
import { type TableCell } from '../../../composables/Table'
|
|
3
|
+
import { type BooleanFieldData } from '../FieldData'
|
|
4
|
+
import { type FilterOperator } from '../FilterOperator'
|
|
5
|
+
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
6
|
+
import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
|
|
7
|
+
import { Field } from './Field'
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
en: { true: 'Yes', false: 'No' },
|
|
11
|
+
ja: { true: 'はい', false: 'いいえ' }
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
export class BooleanField extends Field<BooleanFieldData> {
|
|
15
|
+
override tableCell(v: any, _r: any): TableCell {
|
|
16
|
+
return {
|
|
17
|
+
type: 'text',
|
|
18
|
+
value: this.formatValue(v)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async tableFilterMenu(filters: any[], onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
|
|
23
|
+
// A boolean has exactly two states, so the inline menu is a single
|
|
24
|
+
// select on `=`. Re-clicking the active value clears the filter.
|
|
25
|
+
const selected = this.eqFilterValueFor(this.data.key, filters)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
type: 'filter',
|
|
29
|
+
search: false,
|
|
30
|
+
selected,
|
|
31
|
+
options: this.filterOptions(),
|
|
32
|
+
onClick: (v) => { onFilterUpdated?.([this.data.key, '=', selected === v ? null : v]) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
37
|
+
// A boolean is a two-state value, so `=` / `!=` express the filter
|
|
38
|
+
// more naturally than set membership. The single-value cast preserves
|
|
39
|
+
// the boolean (it no longer stringifies), so option matching and the
|
|
40
|
+
// backend comparison stay correct.
|
|
41
|
+
const selectOne = new SelectFilterInput().options(() => Promise.resolve(this.filterOptions()))
|
|
42
|
+
return {
|
|
43
|
+
'=': selectOne,
|
|
44
|
+
'!=': selectOne
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
override dataListItemComponent(): any {
|
|
49
|
+
return this.defineDataListItemComponent((value) => this.formatValue(value))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override formInputComponent(): any {
|
|
53
|
+
throw new Error('Not implemented.')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected filterOptions(): Array<{ value: boolean; label: string }> {
|
|
57
|
+
return [
|
|
58
|
+
{ value: true, label: this.formatValue(true)! },
|
|
59
|
+
{ value: false, label: this.formatValue(false)! }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected formatValue(v: any): string | null {
|
|
64
|
+
if (v === null || v === undefined) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
if (v) {
|
|
68
|
+
return this.ctx.lang === 'ja'
|
|
69
|
+
? (this.data.labelTrueJa ?? DEFAULTS.ja.true)
|
|
70
|
+
: (this.data.labelTrueEn ?? DEFAULTS.en.true)
|
|
71
|
+
}
|
|
72
|
+
return this.ctx.lang === 'ja'
|
|
73
|
+
? (this.data.labelFalseJa ?? DEFAULTS.ja.false)
|
|
74
|
+
: (this.data.labelFalseEn ?? DEFAULTS.en.false)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -20,9 +20,17 @@ export class DateField extends Field<DateFieldData> {
|
|
|
20
20
|
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
21
21
|
const text = new TextFilterInput()
|
|
22
22
|
|
|
23
|
+
// Comparison operators (`>`, `>=`, `<`, `<=`) back date-range filters
|
|
24
|
+
// such as `invested_date >= X` AND `invested_date < Y`. The value is
|
|
25
|
+
// an ISO date string, edited as text — same input the `=` / `!=`
|
|
26
|
+
// operators already use for this field.
|
|
23
27
|
return {
|
|
24
28
|
'=': text,
|
|
25
|
-
'!=': text
|
|
29
|
+
'!=': text,
|
|
30
|
+
'>': text,
|
|
31
|
+
'>=': text,
|
|
32
|
+
'<': text,
|
|
33
|
+
'<=': text
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
36
|
|