@globalbrain/sefirot 4.43.4 → 4.44.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/lib/blocks/lens/FieldData.ts +19 -0
- package/lib/blocks/lens/components/LensFormOverrideBase.vue +6 -0
- package/lib/blocks/lens/components/LensFormOverrideNumber.vue +166 -0
- package/lib/blocks/lens/components/LensTable.vue +28 -4
- package/lib/blocks/lens/composables/SetupLens.ts +2 -0
- package/lib/blocks/lens/fields/DecimalField.ts +60 -0
- package/lib/blocks/lens/fields/NumberField.ts +25 -4
- package/lib/blocks/lens/fields/support/Renderers.ts +78 -0
- package/lib/components/STableCell.vue +1 -0
- package/lib/components/STableCellNumber.vue +25 -2
- package/lib/composables/Table.ts +6 -0
- package/lib/support/Num.ts +3 -2
- package/package.json +1 -1
|
@@ -20,6 +20,7 @@ export interface FieldDataRegistry {
|
|
|
20
20
|
content: ContentFieldData
|
|
21
21
|
date: DateFieldData
|
|
22
22
|
datetime: DatetimeFieldData
|
|
23
|
+
decimal: DecimalFieldData
|
|
23
24
|
file_upload: FileUploadFieldData
|
|
24
25
|
id: IdFieldData
|
|
25
26
|
link: LinkFieldData
|
|
@@ -88,6 +89,24 @@ export interface LinkFieldData extends FieldDataBase {
|
|
|
88
89
|
|
|
89
90
|
export interface NumberFieldData extends FieldDataBase {
|
|
90
91
|
type: 'number'
|
|
92
|
+
align: 'left' | 'center' | 'right' | null
|
|
93
|
+
separator: boolean | null
|
|
94
|
+
abbr: 'en' | 'ja' | null
|
|
95
|
+
fractionDigits: number | null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A `decimal` field is rendered identically to a `number` field on the
|
|
100
|
+
* client. The distinction exists so backends can preserve arbitrary
|
|
101
|
+
* precision (e.g. sending the value as a string instead of a JS number)
|
|
102
|
+
* without losing the type-level signal at the spec layer.
|
|
103
|
+
*/
|
|
104
|
+
export interface DecimalFieldData extends FieldDataBase {
|
|
105
|
+
type: 'decimal'
|
|
106
|
+
align: 'left' | 'center' | 'right' | null
|
|
107
|
+
separator: boolean | null
|
|
108
|
+
abbr: 'en' | 'ja' | null
|
|
109
|
+
fractionDigits: number | null
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
export interface SelectFieldData extends FieldDataBase {
|
|
@@ -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>
|
|
@@ -34,7 +34,21 @@ const fieldFactory = useFieldFactory()
|
|
|
34
34
|
|
|
35
35
|
const records = computed(() => props.result?.data ?? [])
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Resolve the list of columns to render. We intersect the caller's
|
|
38
|
+
// requested `select` with what the latest response actually fetched so
|
|
39
|
+
// that toggling a column on through "Manage table view" doesn't surface
|
|
40
|
+
// a new column synchronously against still-stale records. Without this,
|
|
41
|
+
// fields like `SelectField` blow up trying to render `undefined` for
|
|
42
|
+
// the brand-new column before the refresh response lands.
|
|
43
|
+
const columnKeys = computed(() => {
|
|
44
|
+
const requested = props.select ?? props.result?.query.select ?? []
|
|
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))
|
|
51
|
+
})
|
|
38
52
|
|
|
39
53
|
const orders = computed(() => [
|
|
40
54
|
...columnKeys.value,
|
|
@@ -48,6 +62,14 @@ const columns = computedAsync(async () => {
|
|
|
48
62
|
return {}
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
// Snapshot the column keys at the start of the run. `columnKeys` is a
|
|
66
|
+
// computed off reactive props; if it changes mid-await (e.g. the user
|
|
67
|
+
// toggles a column off in "Manage table view"), subsequent reads
|
|
68
|
+
// through the live ref would jump to the new, shorter array and
|
|
69
|
+
// produce `undefined` for indices past the new length — crashing
|
|
70
|
+
// `Object.assign(_fieldData, ...)` below.
|
|
71
|
+
const keys = [...columnKeys.value]
|
|
72
|
+
|
|
51
73
|
// Prepare base columns that has `__last_empty__` to fill the end space.
|
|
52
74
|
const columns: TableColumns<any, any, any> = {
|
|
53
75
|
__last_empty__: {
|
|
@@ -56,11 +78,13 @@ const columns = computedAsync(async () => {
|
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
// Build the list of columns based on the resolved column key list.
|
|
59
|
-
for (const
|
|
60
|
-
const key = columnKeys.value[i]
|
|
61
|
-
|
|
81
|
+
for (const key of keys) {
|
|
62
82
|
const _fieldData = cloneDeep(r.fields[key])
|
|
63
83
|
|
|
84
|
+
if (!_fieldData) {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
const overriddenFieldData = Object.assign(
|
|
65
89
|
_fieldData,
|
|
66
90
|
props.overrides?.[key] ?? {}
|
|
@@ -4,6 +4,7 @@ import { provideFieldRegistry } from '../composables/FieldRegistry'
|
|
|
4
4
|
import { ContentField } from '../fields/ContentField'
|
|
5
5
|
import { DateField } from '../fields/DateField'
|
|
6
6
|
import { DatetimeField } from '../fields/DatetimeField'
|
|
7
|
+
import { DecimalField } from '../fields/DecimalField'
|
|
7
8
|
import { FileUploadField } from '../fields/FileUploadField'
|
|
8
9
|
import { IdField } from '../fields/IdField'
|
|
9
10
|
import { LinkField } from '../fields/LinkField'
|
|
@@ -34,6 +35,7 @@ export function useSetupLens(): SetupLens {
|
|
|
34
35
|
fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
|
|
35
36
|
fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
|
|
36
37
|
fieldRegistry.register('datetime', (ctx, field) => new DatetimeField(ctx, field))
|
|
38
|
+
fieldRegistry.register('decimal', (ctx, field) => new DecimalField(ctx, field))
|
|
37
39
|
fieldRegistry.register('file_upload', (ctx, field) => new FileUploadField(ctx, field, fileDownloader))
|
|
38
40
|
fieldRegistry.register('link', (ctx, field) => new LinkField(ctx, field))
|
|
39
41
|
fieldRegistry.register('number', (ctx, field) => new NumberField(ctx, field))
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -54,6 +54,7 @@ const valueIsImagePath = computed(() => {
|
|
|
54
54
|
:icon="computedCell.icon"
|
|
55
55
|
:number="computedCell.value ?? value"
|
|
56
56
|
:separator="computedCell.separator"
|
|
57
|
+
:maximum-fraction-digits="computedCell.maximumFractionDigits"
|
|
57
58
|
:link="computedCell.link"
|
|
58
59
|
:color="computedCell.color"
|
|
59
60
|
:icon-color="computedCell.iconColor"
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { type Component, computed } from 'vue'
|
|
3
3
|
import { type TableCellValueColor } from '../composables/Table'
|
|
4
|
-
import { format } from '../support/Num'
|
|
5
4
|
import SLink from './SLink.vue'
|
|
6
5
|
|
|
7
6
|
const props = defineProps<{
|
|
@@ -11,6 +10,7 @@ const props = defineProps<{
|
|
|
11
10
|
icon?: Component
|
|
12
11
|
number?: number | null
|
|
13
12
|
separator?: boolean
|
|
13
|
+
maximumFractionDigits?: number | null
|
|
14
14
|
color?: TableCellValueColor
|
|
15
15
|
iconColor?: TableCellValueColor
|
|
16
16
|
link?: string | null
|
|
@@ -25,6 +25,29 @@ const classes = computed(() => [
|
|
|
25
25
|
_color,
|
|
26
26
|
{ link: !!(props.link || props.onClick) }
|
|
27
27
|
])
|
|
28
|
+
|
|
29
|
+
// We format the value inline (rather than via `Num.format`) so we can
|
|
30
|
+
// thread `useGrouping` and `maximumFractionDigits` through the same
|
|
31
|
+
// `toLocaleString` call. `Num.format` is kept for callers that just
|
|
32
|
+
// want the default separator-on / unbounded-digits behavior.
|
|
33
|
+
//
|
|
34
|
+
// When neither `separator` nor `maximumFractionDigits` is requested we
|
|
35
|
+
// return the raw number and let Vue's template interpolation stringify
|
|
36
|
+
// it. `toLocaleString` would otherwise round very small numbers to
|
|
37
|
+
// `"0"` (e.g. `1e-25` → `"0"` at 20 digits), losing information that
|
|
38
|
+
// the plain `String(number)` form preserves via scientific notation.
|
|
39
|
+
const formatted = computed(() => {
|
|
40
|
+
if (props.number == null) {
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
if (!props.separator && props.maximumFractionDigits == null) {
|
|
44
|
+
return props.number
|
|
45
|
+
}
|
|
46
|
+
return props.number.toLocaleString('en-US', {
|
|
47
|
+
useGrouping: props.separator === true,
|
|
48
|
+
maximumFractionDigits: props.maximumFractionDigits ?? 20
|
|
49
|
+
})
|
|
50
|
+
})
|
|
28
51
|
</script>
|
|
29
52
|
|
|
30
53
|
<template>
|
|
@@ -40,7 +63,7 @@ const classes = computed(() => [
|
|
|
40
63
|
<component :is="icon" class="svg" />
|
|
41
64
|
</div>
|
|
42
65
|
<div class="value" :class="_color">
|
|
43
|
-
{{
|
|
66
|
+
{{ formatted }}
|
|
44
67
|
</div>
|
|
45
68
|
</SLink>
|
|
46
69
|
</div>
|
package/lib/composables/Table.ts
CHANGED
|
@@ -115,6 +115,12 @@ export interface TableCellNumber<V = any, R = any> extends TableCellBase {
|
|
|
115
115
|
icon?: Component
|
|
116
116
|
value?: number | null
|
|
117
117
|
separator?: boolean
|
|
118
|
+
/**
|
|
119
|
+
* Caps the displayed fractional digits using
|
|
120
|
+
* `Intl.NumberFormat`-style "maximum fractional digits" semantics.
|
|
121
|
+
* `null` / `undefined` shows the value as-is (no rounding).
|
|
122
|
+
*/
|
|
123
|
+
maximumFractionDigits?: number | null
|
|
118
124
|
link?: string | null
|
|
119
125
|
color?: TableCellValueColor
|
|
120
126
|
iconColor?: TableCellValueColor
|
package/lib/support/Num.ts
CHANGED
|
@@ -2,8 +2,9 @@ export function format(value: number): string {
|
|
|
2
2
|
return value.toLocaleString('en-US', { maximumFractionDigits: 20 })
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
export function abbreviate(value: number, precision = 0): string {
|
|
6
|
-
|
|
5
|
+
export function abbreviate(value: number, precision = 0, lang: 'en' | 'ja' = 'en'): string {
|
|
6
|
+
const locale = lang === 'ja' ? 'ja-JP' : 'en-US'
|
|
7
|
+
return value.toLocaleString(locale, {
|
|
7
8
|
notation: 'compact',
|
|
8
9
|
maximumFractionDigits: precision
|
|
9
10
|
})
|