@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
|
@@ -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,14 +1,17 @@
|
|
|
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'
|
|
8
|
+
import { DecimalField } from '../fields/DecimalField'
|
|
7
9
|
import { FileUploadField } from '../fields/FileUploadField'
|
|
8
10
|
import { IdField } from '../fields/IdField'
|
|
9
11
|
import { LinkField } from '../fields/LinkField'
|
|
10
12
|
import { NumberField } from '../fields/NumberField'
|
|
11
13
|
import { RelatedManyField } from '../fields/RelatedManyField'
|
|
14
|
+
import { RelatedOneField } from '../fields/RelatedOneField'
|
|
12
15
|
import { SelectField } from '../fields/SelectField'
|
|
13
16
|
import { SlackMessageField } from '../fields/SlackMessageField'
|
|
14
17
|
import { TextField } from '../fields/TextField'
|
|
@@ -31,14 +34,17 @@ export function useSetupLens(): SetupLens {
|
|
|
31
34
|
registerDefaultFields()
|
|
32
35
|
|
|
33
36
|
function registerDefaultFields(): void {
|
|
37
|
+
fieldRegistry.register('boolean', (ctx, field) => new BooleanField(ctx, field))
|
|
34
38
|
fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
|
|
35
39
|
fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
|
|
36
40
|
fieldRegistry.register('datetime', (ctx, field) => new DatetimeField(ctx, field))
|
|
41
|
+
fieldRegistry.register('decimal', (ctx, field) => new DecimalField(ctx, field))
|
|
37
42
|
fieldRegistry.register('file_upload', (ctx, field) => new FileUploadField(ctx, field, fileDownloader))
|
|
38
43
|
fieldRegistry.register('link', (ctx, field) => new LinkField(ctx, field))
|
|
39
44
|
fieldRegistry.register('number', (ctx, field) => new NumberField(ctx, field))
|
|
40
45
|
fieldRegistry.register('id', (ctx, field) => new IdField(ctx, field))
|
|
41
46
|
fieldRegistry.register('related_many', (ctx, field) => new RelatedManyField(ctx, field, resourceFetcher))
|
|
47
|
+
fieldRegistry.register('related_one', (ctx, field) => new RelatedOneField(ctx, field, resourceFetcher))
|
|
42
48
|
fieldRegistry.register('select', (ctx, field) => new SelectField(ctx, field))
|
|
43
49
|
fieldRegistry.register('slack_message', (ctx, field) => new SlackMessageField(ctx, field))
|
|
44
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
|
|
|
@@ -17,9 +17,16 @@ export class DatetimeField extends Field<DatetimeFieldData> {
|
|
|
17
17
|
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
18
18
|
const text = new TextFilterInput()
|
|
19
19
|
|
|
20
|
+
// Comparison operators (`>`, `>=`, `<`, `<=`) back datetime-range
|
|
21
|
+
// filters. The value is an ISO datetime string, edited as text — the
|
|
22
|
+
// same input the `=` / `!=` operators already use for this field.
|
|
20
23
|
return {
|
|
21
24
|
'=': text,
|
|
22
|
-
'!=': text
|
|
25
|
+
'!=': text,
|
|
26
|
+
'>': text,
|
|
27
|
+
'>=': text,
|
|
28
|
+
'<': text,
|
|
29
|
+
'<=': text
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineComponent, h } from 'vue'
|
|
2
|
+
import { type TableCell } from '../../../composables/Table'
|
|
3
|
+
import { type DecimalFieldData } from '../FieldData'
|
|
4
|
+
import { type FilterOperator } from '../FilterOperator'
|
|
5
|
+
import LensFormOverrideNumber from '../components/LensFormOverrideNumber.vue'
|
|
6
|
+
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
7
|
+
import { NumberFilterInput } from '../filter-inputs/NumberFilterInput'
|
|
8
|
+
import { Field } from './Field'
|
|
9
|
+
import { renderNumberLikeTableCell } from './support/Renderers'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A decimal field is rendered identically to a number field on the
|
|
13
|
+
* client — `renderNumberLikeTableCell` covers both. The type exists so
|
|
14
|
+
* backends can preserve arbitrary precision (e.g. send the value as a
|
|
15
|
+
* string instead of a JS number) without losing the type-level signal
|
|
16
|
+
* at the spec layer.
|
|
17
|
+
*/
|
|
18
|
+
export class DecimalField extends Field<DecimalFieldData> {
|
|
19
|
+
override tableCell(v: any, _r: any): TableCell {
|
|
20
|
+
return renderNumberLikeTableCell(this.data, v)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
24
|
+
const number = new NumberFilterInput()
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
'=': number,
|
|
28
|
+
'!=': number
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Renders the override form with extra inputs for `align`,
|
|
34
|
+
* `separator`, `abbr` and `fractionDigits` on top of the base
|
|
35
|
+
* label / width / freeze controls.
|
|
36
|
+
*/
|
|
37
|
+
override overrideForm(): any {
|
|
38
|
+
return defineComponent((props) => {
|
|
39
|
+
return () => h(LensFormOverrideNumber, {
|
|
40
|
+
name: props.name,
|
|
41
|
+
field: props.field,
|
|
42
|
+
override: props.override
|
|
43
|
+
})
|
|
44
|
+
}, {
|
|
45
|
+
props: [
|
|
46
|
+
'name',
|
|
47
|
+
'field',
|
|
48
|
+
'override'
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override dataListItemComponent(): any {
|
|
54
|
+
throw new Error('Not implemented.')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override formInputComponent() {
|
|
58
|
+
throw new Error('Not implemented.')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -11,6 +11,12 @@ import LensFormOverrideBase from '../components/LensFormOverrideBase.vue'
|
|
|
11
11
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
12
12
|
import * as RuleMapper from '../validation/RuleMapper'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Fallback column width (px) used when a field's definition does not
|
|
16
|
+
* specify one (i.e. `width` is 0). Keeps columns visible by default.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_COLUMN_WIDTH = 168
|
|
19
|
+
|
|
14
20
|
export abstract class Field<T extends FieldData> {
|
|
15
21
|
/**
|
|
16
22
|
* The field context, that holds global app context
|
|
@@ -66,7 +72,11 @@ export abstract class Field<T extends FieldData> {
|
|
|
66
72
|
return {
|
|
67
73
|
label: this.label(),
|
|
68
74
|
freeze: this.data.freeze,
|
|
69
|
-
width
|
|
75
|
+
// A width of 0 means "unset" — fall back to a sensible default so
|
|
76
|
+
// the column stays visible. Without this, fields whose backend
|
|
77
|
+
// definition omits an explicit width would render as a 0px (hidden)
|
|
78
|
+
// column until the user manually drag-resizes it.
|
|
79
|
+
width: `${this.data.width || DEFAULT_COLUMN_WIDTH}px`,
|
|
70
80
|
cell: (v, r) => this.tableCell(v, r)
|
|
71
81
|
}
|
|
72
82
|
}
|
|
@@ -120,6 +130,19 @@ export abstract class Field<T extends FieldData> {
|
|
|
120
130
|
return []
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Returns the "=" filter value for the given key from the filters
|
|
135
|
+
* array, or `null` when there is none.
|
|
136
|
+
*/
|
|
137
|
+
protected eqFilterValueFor(key: string, filters: any[]): any {
|
|
138
|
+
for (const f of filters) {
|
|
139
|
+
if (f[0] === key && f[1] === '=') {
|
|
140
|
+
return f[2]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
/**
|
|
124
147
|
* Returns the table cell definition for the field.
|
|
125
148
|
*/
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import { defineComponent, h } from 'vue'
|
|
1
2
|
import { type TableCell } from '../../../composables/Table'
|
|
2
3
|
import { type NumberFieldData } from '../FieldData'
|
|
3
4
|
import { type FilterOperator } from '../FilterOperator'
|
|
5
|
+
import LensFormOverrideNumber from '../components/LensFormOverrideNumber.vue'
|
|
4
6
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
5
7
|
import { NumberFilterInput } from '../filter-inputs/NumberFilterInput'
|
|
6
8
|
import { Field } from './Field'
|
|
9
|
+
import { renderNumberLikeTableCell } from './support/Renderers'
|
|
7
10
|
|
|
8
11
|
export class NumberField extends Field<NumberFieldData> {
|
|
9
12
|
override tableCell(v: any, _r: any): TableCell {
|
|
10
|
-
return
|
|
11
|
-
type: 'number',
|
|
12
|
-
value: v
|
|
13
|
-
}
|
|
13
|
+
return renderNumberLikeTableCell(this.data, v)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
@@ -22,6 +22,27 @@ export class NumberField extends Field<NumberFieldData> {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Renders the override form with extra inputs for `align`,
|
|
27
|
+
* `separator`, `abbr` and `fractionDigits` on top of the base
|
|
28
|
+
* label / width / freeze controls.
|
|
29
|
+
*/
|
|
30
|
+
override overrideForm(): any {
|
|
31
|
+
return defineComponent((props) => {
|
|
32
|
+
return () => h(LensFormOverrideNumber, {
|
|
33
|
+
name: props.name,
|
|
34
|
+
field: props.field,
|
|
35
|
+
override: props.override
|
|
36
|
+
})
|
|
37
|
+
}, {
|
|
38
|
+
props: [
|
|
39
|
+
'name',
|
|
40
|
+
'field',
|
|
41
|
+
'override'
|
|
42
|
+
]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
override dataListItemComponent(): any {
|
|
26
47
|
throw new Error('Not implemented.')
|
|
27
48
|
}
|
|
@@ -30,10 +30,19 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
30
30
|
const res = await this.fetcher(method, url)
|
|
31
31
|
const data = key ? res[key] : res
|
|
32
32
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const isAvatar = this.data.displayAs === 'avatars'
|
|
34
|
+
|
|
35
|
+
const options = data.map((item: any) => isAvatar
|
|
36
|
+
? {
|
|
37
|
+
type: 'avatar' as const,
|
|
38
|
+
label: item[this.data.resourceTitle],
|
|
39
|
+
image: this.data.resourceImage ? item[this.data.resourceImage] : null,
|
|
40
|
+
value: item[this.data.filterKey]
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
label: item[this.data.resourceTitle],
|
|
44
|
+
value: item[this.data.filterKey]
|
|
45
|
+
})
|
|
37
46
|
|
|
38
47
|
return {
|
|
39
48
|
type: 'filter',
|
|
@@ -45,9 +54,24 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
|
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
override tableCell(v: any, _r: any): TableCell {
|
|
57
|
+
const items = (v ?? []) as any[]
|
|
58
|
+
|
|
59
|
+
if (this.data.displayAs === 'avatars') {
|
|
60
|
+
return {
|
|
61
|
+
type: 'avatars',
|
|
62
|
+
avatars: items.map((item) => ({
|
|
63
|
+
image: this.data.image ? item[this.data.image] : null,
|
|
64
|
+
name: item[this.data.title]
|
|
65
|
+
})),
|
|
66
|
+
avatarCount: 6,
|
|
67
|
+
nameCount: 0,
|
|
68
|
+
tooltip: true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
return {
|
|
49
73
|
type: 'pills',
|
|
50
|
-
pills:
|
|
74
|
+
pills: items.map((item) => ({
|
|
51
75
|
label: item[this.data.title],
|
|
52
76
|
value: item[this.data.filterKey]
|
|
53
77
|
}))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { xor } from 'lodash-es'
|
|
2
|
+
import { type DropdownSection } from '../../../composables/Dropdown'
|
|
3
|
+
import { type TableCell } from '../../../composables/Table'
|
|
4
|
+
import { type RelatedOneFieldData } from '../FieldData'
|
|
5
|
+
import { type FilterOperator } from '../FilterOperator'
|
|
6
|
+
import { type ResourceFetcher } from '../ResourceFetcher'
|
|
7
|
+
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
8
|
+
import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
|
|
9
|
+
import { Field } from './Field'
|
|
10
|
+
|
|
11
|
+
export class RelatedOneField extends Field<RelatedOneFieldData> {
|
|
12
|
+
fetcher: ResourceFetcher
|
|
13
|
+
|
|
14
|
+
constructor(ctx: any, data: RelatedOneFieldData, fetcher: ResourceFetcher) {
|
|
15
|
+
super(ctx, data)
|
|
16
|
+
this.fetcher = fetcher
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override async tableFilterMenu(filters: any[], onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
|
|
20
|
+
const method = this.data.resourceEndpointMethod
|
|
21
|
+
const url = this.data.resourceEndpointPath
|
|
22
|
+
const key = this.data.resourceEndpointDataKey
|
|
23
|
+
|
|
24
|
+
if (!url) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const selected = this.inFilterValueFor(this.data.key, filters)
|
|
29
|
+
|
|
30
|
+
const res = await this.fetcher(method, url)
|
|
31
|
+
const data = key ? res[key] : res
|
|
32
|
+
|
|
33
|
+
const isAvatar = this.data.displayAs === 'avatar'
|
|
34
|
+
|
|
35
|
+
const options = data.map((item: any) => isAvatar
|
|
36
|
+
? {
|
|
37
|
+
type: 'avatar' as const,
|
|
38
|
+
label: item[this.data.resourceTitle],
|
|
39
|
+
image: this.data.resourceImage ? item[this.data.resourceImage] : null,
|
|
40
|
+
value: item[this.data.filterKey]
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
label: item[this.data.resourceTitle],
|
|
44
|
+
value: item[this.data.filterKey]
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
type: 'filter',
|
|
49
|
+
search: true,
|
|
50
|
+
selected,
|
|
51
|
+
options,
|
|
52
|
+
onClick: (v) => { onFilterUpdated?.([this.data.key, 'in', xor(selected, [v])]) }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override tableCell(v: any, _r: any): TableCell {
|
|
57
|
+
if (v === null || v === undefined) {
|
|
58
|
+
if (this.data.displayAs === 'avatar') {
|
|
59
|
+
return { type: 'avatar', image: null, name: '' }
|
|
60
|
+
}
|
|
61
|
+
return { type: 'text', value: null }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.data.displayAs === 'avatar') {
|
|
65
|
+
return {
|
|
66
|
+
type: 'avatar',
|
|
67
|
+
image: this.data.image ? (v[this.data.image] ?? null) : null,
|
|
68
|
+
// Empty string (not null) for the same reason as the text branch
|
|
69
|
+
// below — a null name falls back to the raw row value in the cell
|
|
70
|
+
// renderer and breaks SAvatar.
|
|
71
|
+
name: v[this.data.title] ?? ''
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
type: 'text',
|
|
77
|
+
// Empty string (not null) when the title is missing: the table
|
|
78
|
+
// renderer falls back to the raw row value on a null cell value,
|
|
79
|
+
// which would render the relation object as `[object Object]`.
|
|
80
|
+
value: v[this.data.title] ?? ''
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
|
|
85
|
+
const method = this.data.resourceEndpointMethod
|
|
86
|
+
const url = this.data.resourceEndpointPath
|
|
87
|
+
const key = this.data.resourceEndpointDataKey
|
|
88
|
+
|
|
89
|
+
if (!url) {
|
|
90
|
+
return {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const optionsResolver = async () => {
|
|
94
|
+
const res = await this.fetcher(method, url)
|
|
95
|
+
const data = key ? res[key] : res
|
|
96
|
+
return data.map((item: any) => ({
|
|
97
|
+
value: item[this.data.filterKey],
|
|
98
|
+
label: item[this.data.resourceTitle]
|
|
99
|
+
}))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const selectOne = new SelectFilterInput().options(optionsResolver)
|
|
103
|
+
const selectMany = new SelectFilterInput().options(optionsResolver).multiple()
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
'=': selectOne,
|
|
107
|
+
'!=': selectOne,
|
|
108
|
+
'in': selectMany
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override dataListItemComponent(): any {
|
|
113
|
+
throw new Error('Not implemented.')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override formInputComponent() {
|
|
117
|
+
throw new Error('Not implemented.')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -38,6 +38,8 @@ export class TextField extends Field<TextFieldData> {
|
|
|
38
38
|
'label': this.formInputLabel(),
|
|
39
39
|
'placeholder': this.placeholder() || undefined,
|
|
40
40
|
'help': this.help() || undefined,
|
|
41
|
+
'unitBefore': this.data.unitBefore || undefined,
|
|
42
|
+
'unitAfter': this.data.unitAfter || undefined,
|
|
41
43
|
'modelValue': props.modelValue,
|
|
42
44
|
'validation': props.validation,
|
|
43
45
|
'onUpdate:modelValue': (value: any) => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type TableCell } from '../../../../composables/Table'
|
|
2
|
+
import { abbreviate } from '../../../../support/Num'
|
|
3
|
+
import { type DecimalFieldData, type NumberFieldData } from '../../FieldData'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `Intl.NumberFormat`'s `maximumFractionDigits` accepts integers in
|
|
7
|
+
* the range `0..20` (inclusive) and throws `RangeError` outside it.
|
|
8
|
+
* The override UI can in principle accept any number — including
|
|
9
|
+
* negative values via `SInputNumber` — so we clamp defensively on
|
|
10
|
+
* the rendering path. `null` / `undefined` stays untouched so the
|
|
11
|
+
* cell component can route through the "no formatting" path.
|
|
12
|
+
*/
|
|
13
|
+
function clampFractionDigits(digits: number | null | undefined): number | null {
|
|
14
|
+
if (digits == null) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
return Math.max(0, Math.min(20, Math.trunc(digits)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Coerces an incoming cell value to a finite number, or `null` when
|
|
22
|
+
* the value is missing / blank / non-numeric. We intentionally treat
|
|
23
|
+
* empty and whitespace-only strings the same as `null` — otherwise
|
|
24
|
+
* `Number('') === 0` would render blank numeric cells as a literal
|
|
25
|
+
* `0`, which is a regression from the pre-formatting renderer that
|
|
26
|
+
* passed values through untouched. `NaN` (from `Number('abc')` and
|
|
27
|
+
* friends) collapses to `null` too so the cell renders blank instead
|
|
28
|
+
* of the string `"NaN"`.
|
|
29
|
+
*/
|
|
30
|
+
function toNumberOrNull(v: any): number | null {
|
|
31
|
+
if (v == null) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
if (typeof v === 'string' && v.trim() === '') {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const num = Number(v)
|
|
38
|
+
return Number.isFinite(num) ? num : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Renders a `number` or `decimal` field as a table cell. The two share
|
|
43
|
+
* the same rendering rules so this helper covers both.
|
|
44
|
+
*
|
|
45
|
+
* - When `abbr` is `null`, we emit a `number` cell. The cell component
|
|
46
|
+
* applies `separator` and `maximumFractionDigits` via
|
|
47
|
+
* `toLocaleString`. The raw incoming value is coerced with `Number()`
|
|
48
|
+
* so decimal fields delivered as strings still render.
|
|
49
|
+
* - When `abbr` is `'en'` or `'ja'`, we emit a `text` cell with the
|
|
50
|
+
* compact-notation string baked in. We fall back to a 1-digit
|
|
51
|
+
* precision when `fractionDigits` is `null` — matches what the
|
|
52
|
+
* default user-facing column-format UI would set.
|
|
53
|
+
*/
|
|
54
|
+
export function renderNumberLikeTableCell(
|
|
55
|
+
data: NumberFieldData | DecimalFieldData,
|
|
56
|
+
v: any
|
|
57
|
+
): TableCell {
|
|
58
|
+
const num = toNumberOrNull(v)
|
|
59
|
+
const cap = clampFractionDigits(data.fractionDigits)
|
|
60
|
+
|
|
61
|
+
if (data.abbr != null) {
|
|
62
|
+
return {
|
|
63
|
+
type: 'text',
|
|
64
|
+
align: data.align ?? 'left',
|
|
65
|
+
value: num != null
|
|
66
|
+
? abbreviate(num, cap ?? 1, data.abbr)
|
|
67
|
+
: null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
type: 'number',
|
|
73
|
+
align: data.align ?? 'left',
|
|
74
|
+
value: num,
|
|
75
|
+
separator: data.separator ?? false,
|
|
76
|
+
maximumFractionDigits: cap
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -35,7 +35,11 @@ export class SelectFilterInput extends FilterInput {
|
|
|
35
35
|
if (Array.isArray(value)) {
|
|
36
36
|
return value[0]
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Preserve the value's type. Option values can be non-string (e.g.
|
|
39
|
+
// numeric related-record ids); stringifying here would break strict
|
|
40
|
+
// option matching in the dropdown summary and send the wrong type to
|
|
41
|
+
// the backend.
|
|
42
|
+
return value ?? null
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
protected castValueMany(value: any): any {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ValidationArgs, type ValidationRuleWithParams } from '@vuelidate/core'
|
|
2
2
|
import { type Day, day } from '../../../support/Day'
|
|
3
|
-
import { after, afterOrEqual, before, beforeOrEqual, maxLength, required } from '../../../validation/rules'
|
|
3
|
+
import { after, afterOrEqual, before, beforeOrEqual, maxLength, required, slackChannelName } from '../../../validation/rules'
|
|
4
4
|
import { type Rule } from '../Rule'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -19,6 +19,8 @@ function mapRule(rule: Rule): ValidationRuleWithParams {
|
|
|
19
19
|
return maxLength(rule.length)
|
|
20
20
|
case 'required':
|
|
21
21
|
return required()
|
|
22
|
+
case 'slack_channel_name':
|
|
23
|
+
return slackChannelName({ offset: rule.offset })
|
|
22
24
|
case 'before':
|
|
23
25
|
return before(resolveDate(rule.date))
|
|
24
26
|
case 'before_or_equal':
|
|
@@ -17,7 +17,14 @@ export type Size = 'mini' | 'small' | 'medium'
|
|
|
17
17
|
export type { Color }
|
|
18
18
|
|
|
19
19
|
export type ModelType = 'file' | 'object'
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* In `file` mode an item is either a freshly selected `File` or a `string`
|
|
23
|
+
* referencing a file that was already uploaded (e.g. its stored path or
|
|
24
|
+
* basename) — mirroring `SInputImage`'s `File | string` model. `object`
|
|
25
|
+
* mode wraps a `File` with display metadata.
|
|
26
|
+
*/
|
|
27
|
+
export type ModelValue<T extends ModelType> = T extends 'file' ? File | string : FileObject
|
|
21
28
|
|
|
22
29
|
export interface FileObject {
|
|
23
30
|
file: File
|
|
@@ -103,7 +110,15 @@ const totalFileCountText = computed(() => {
|
|
|
103
110
|
})
|
|
104
111
|
|
|
105
112
|
const totalFileSizeText = computed(() => {
|
|
106
|
-
|
|
113
|
+
// Only locally selected files contribute to the total size. Already
|
|
114
|
+
// uploaded files (plain `string` references) have no known size on the
|
|
115
|
+
// client, so the displayed total under-counts when the list mixes
|
|
116
|
+
// uploaded references with newly selected files. Accepted for now —
|
|
117
|
+
// surfacing accurate sizes would require the size to travel with the
|
|
118
|
+
// reference (or an extra lookup).
|
|
119
|
+
const files = _value.value
|
|
120
|
+
.map((file) => (file instanceof File ? file : typeof file === 'string' ? null : file.file))
|
|
121
|
+
.filter((file): file is File => file instanceof File)
|
|
107
122
|
return formatSize(files)
|
|
108
123
|
})
|
|
109
124
|
|