@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
@@ -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
- if (!fetched) {
47
- return requested
48
- }
49
- const fetchedSet = new Set(fetched)
50
- return requested.filter((k) => fetchedSet.has(k))
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: `${this.data.width}px`,
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 options = data.map((item: any) => ({
34
- label: item[this.data.resourceTitle],
35
- value: item[this.data.filterKey]
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: v.map((item: any) => ({
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
- return this.castValueToStringOrNull(value)
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
- export type ModelValue<T extends ModelType> = T extends 'file' ? File : FileObject
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
- const files = _value.value.map((file) => (file instanceof File ? file : file.file))
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