@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
|
@@ -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
|
|
|
@@ -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
|
*/
|
|
@@ -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) => {
|
|
@@ -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
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import IconFileText from '~icons/ph/file-text'
|
|
3
3
|
import IconTrash from '~icons/ph/trash'
|
|
4
4
|
import { type ValidationRuleWithParams } from '@vuelidate/core'
|
|
5
|
-
import { type Component, computed } from 'vue'
|
|
5
|
+
import { type Component, computed, watch } from 'vue'
|
|
6
6
|
import { useValidation } from '../composables/Validation'
|
|
7
7
|
import { formatSize } from '../support/File'
|
|
8
8
|
import SButton, { type Mode as ButtonMode } from './SButton.vue'
|
|
@@ -27,7 +27,7 @@ export interface Action {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const props = defineProps<{
|
|
30
|
-
file: File | FileObject
|
|
30
|
+
file: File | FileObject | string
|
|
31
31
|
rules?: Record<string, ValidationRuleWithParams>
|
|
32
32
|
}>()
|
|
33
33
|
|
|
@@ -35,23 +35,64 @@ defineEmits<{
|
|
|
35
35
|
remove: []
|
|
36
36
|
}>()
|
|
37
37
|
|
|
38
|
-
const _file = computed(() =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
const _file = computed(() => {
|
|
39
|
+
const value = props.file
|
|
40
|
+
|
|
41
|
+
if (value instanceof File) {
|
|
42
|
+
return {
|
|
43
|
+
name: value.name,
|
|
44
|
+
file: value as File | null,
|
|
45
|
+
size: formatSize(value) as string | null,
|
|
46
|
+
indicatorState: null as IndicatorState | null,
|
|
47
|
+
canRemove: true,
|
|
48
|
+
action: null as Action | null,
|
|
49
|
+
errorMessage: null as string | null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A plain string references an already-uploaded file: show its basename
|
|
54
|
+
// and skip the size (unknown for server-side files).
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
return {
|
|
57
|
+
name: value.split('/').pop() || value,
|
|
58
|
+
file: null,
|
|
59
|
+
size: null,
|
|
60
|
+
indicatorState: null,
|
|
61
|
+
canRemove: true,
|
|
62
|
+
action: null,
|
|
63
|
+
errorMessage: null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: value.file.name,
|
|
69
|
+
file: value.file as File | null,
|
|
70
|
+
size: formatSize(value.file) as string | null,
|
|
71
|
+
indicatorState: value.indicatorState ?? null,
|
|
72
|
+
canRemove: value.canRemove ?? true,
|
|
73
|
+
action: value.action ?? null,
|
|
74
|
+
errorMessage: value.errorMessage ?? null
|
|
75
|
+
}
|
|
76
|
+
})
|
|
47
77
|
|
|
78
|
+
// Rules are passed as a getter so validation reacts when the item's
|
|
79
|
+
// identity changes — the parent keys items by index, so a single instance
|
|
80
|
+
// can be reused for a different item after a removal. Per-file rules apply
|
|
81
|
+
// to newly selected `File`s only; an already-uploaded `string` reference
|
|
82
|
+
// has no local file to validate, so it's skipped (the server validates it).
|
|
48
83
|
const { validation } = useValidation(() => ({
|
|
49
84
|
file: _file.value.file
|
|
50
|
-
}), {
|
|
51
|
-
file: props.rules ?? {}
|
|
52
|
-
})
|
|
85
|
+
}), () => ({
|
|
86
|
+
file: typeof props.file === 'string' ? {} : (props.rules ?? {})
|
|
87
|
+
}))
|
|
53
88
|
|
|
54
|
-
validation
|
|
89
|
+
// Surface validation immediately, and re-touch whenever the item changes:
|
|
90
|
+
// when an index-keyed instance is reused for a different item, switching
|
|
91
|
+
// rules resets the dirty state, so a post-flush re-touch is needed to keep
|
|
92
|
+
// the new item's errors visible.
|
|
93
|
+
watch(() => props.file, () => {
|
|
94
|
+
validation.value.$touch()
|
|
95
|
+
}, { immediate: true, flush: 'post' })
|
|
55
96
|
</script>
|
|
56
97
|
|
|
57
98
|
<template>
|
|
@@ -84,7 +125,7 @@ validation.value.$touch()
|
|
|
84
125
|
/>
|
|
85
126
|
</div>
|
|
86
127
|
<div class="meta">
|
|
87
|
-
<div class="size">
|
|
128
|
+
<div v-if="_file.size" class="size">
|
|
88
129
|
{{ _file.size }}
|
|
89
130
|
</div>
|
|
90
131
|
<div class="delete">
|
package/lib/http/Http.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { parse as parseContentDisposition } from '@tinyhttp/content-disposition'
|
|
2
2
|
import { parse as parseCookie } from '@tinyhttp/cookie'
|
|
3
|
-
import FileSaver from 'file-saver'
|
|
4
3
|
import { FetchError, type FetchOptions, type FetchResponse } from 'ofetch'
|
|
5
4
|
import { stringify } from 'qs'
|
|
5
|
+
import { saveAs } from '../support/File'
|
|
6
6
|
import { objectToFormData } from '../support/Http'
|
|
7
7
|
|
|
8
8
|
type Config = ReturnType<typeof import('../stores/HttpConfig').useHttpConfig>
|
|
@@ -116,20 +116,15 @@ export class Http {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
async download(url: string, options?: FetchOptions): Promise<void> {
|
|
119
|
-
const { _data: blob, headers } =
|
|
120
|
-
method: 'GET',
|
|
121
|
-
responseType: 'blob',
|
|
122
|
-
...options
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
if (!blob) {
|
|
126
|
-
throw new Error('No blob')
|
|
127
|
-
}
|
|
119
|
+
const { _data: blob, headers } =
|
|
120
|
+
await this.performRequestRaw<Blob>(url, { method: 'GET', responseType: 'blob', ...options })
|
|
128
121
|
|
|
129
|
-
|
|
130
|
-
|
|
122
|
+
let filename
|
|
123
|
+
try {
|
|
124
|
+
filename = parseContentDisposition(headers.get('Content-Disposition') || '').parameters.filename
|
|
125
|
+
} catch {}
|
|
131
126
|
|
|
132
|
-
|
|
127
|
+
saveAs(blob, filename as string | undefined)
|
|
133
128
|
}
|
|
134
129
|
}
|
|
135
130
|
|
package/lib/support/Chart.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* https://github.com/radix-ui/colors/blob/main/LICENSE
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import FileSaver from 'file-saver'
|
|
10
9
|
import html2canvas from 'html2canvas'
|
|
10
|
+
import { saveAs } from './File'
|
|
11
11
|
|
|
12
12
|
export const c = {
|
|
13
13
|
text1: 'light-dark(#1c2024, #edeef0)',
|
|
@@ -86,6 +86,10 @@ export async function exportAsPng(_el: any, fileName = 'chart.png', delay = 0):
|
|
|
86
86
|
}
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
const
|
|
90
|
-
|
|
89
|
+
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
|
90
|
+
if (!blob) {
|
|
91
|
+
throw new Error('Failed to export chart as PNG: unable to create blob from canvas')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
saveAs(blob, fileName)
|
|
91
95
|
}
|
package/lib/support/Day.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import dayjs, { type ConfigType, type Dayjs } from 'dayjs'
|
|
2
|
-
import PluginRelativeTime from 'dayjs/plugin/relativeTime'
|
|
3
|
-
import PluginTimezone from 'dayjs/plugin/timezone'
|
|
4
|
-
import PluginUtc from 'dayjs/plugin/utc'
|
|
1
|
+
import dayjs, { type ConfigType, type Dayjs } from 'dayjs/esm'
|
|
2
|
+
import PluginRelativeTime from 'dayjs/esm/plugin/relativeTime'
|
|
3
|
+
import PluginTimezone from 'dayjs/esm/plugin/timezone'
|
|
4
|
+
import PluginUtc from 'dayjs/esm/plugin/utc'
|
|
5
|
+
import 'dayjs/esm/locale/ja'
|
|
5
6
|
|
|
6
7
|
dayjs.extend(PluginUtc)
|
|
7
8
|
dayjs.extend(PluginTimezone)
|
package/lib/support/File.ts
CHANGED
|
@@ -13,3 +13,28 @@ export function formatSize(files: File | File[]): string {
|
|
|
13
13
|
const i = Math.min(Math.floor(Math.log(size) / Math.log(1000)), units.length - 1)
|
|
14
14
|
return `${(size / 1000 ** i).toFixed(2)} ${units[i]}`
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
export function saveAs(blob: Blob | undefined, filename: string | undefined): void {
|
|
18
|
+
if (typeof window === 'undefined') {
|
|
19
|
+
throw new TypeError('saveAs can only be used in a browser environment.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!(blob instanceof Blob)) {
|
|
23
|
+
throw new TypeError('The first argument must be a Blob.')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const url = URL.createObjectURL(blob)
|
|
27
|
+
const anchor = document.createElement('a')
|
|
28
|
+
anchor.href = url
|
|
29
|
+
anchor.download = filename || 'download'
|
|
30
|
+
anchor.rel = 'noopener'
|
|
31
|
+
anchor.style.display = 'none'
|
|
32
|
+
|
|
33
|
+
document.body.appendChild(anchor)
|
|
34
|
+
anchor.click()
|
|
35
|
+
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
anchor.remove()
|
|
38
|
+
URL.revokeObjectURL(url)
|
|
39
|
+
}, 4e4)
|
|
40
|
+
}
|
package/lib/support/Utils.ts
CHANGED
|
@@ -14,3 +14,16 @@ export function isObject(value: unknown): value is Record<PropertyKey, unknown>
|
|
|
14
14
|
export function isString(value: unknown): value is string {
|
|
15
15
|
return typeof value === 'string'
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
export function getLength(value: unknown): number {
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
// Count Unicode code points, not UTF-16 code units (which `String.length`
|
|
21
|
+
// returns). This matches how the database measures column length (MySQL
|
|
22
|
+
// `CHAR_LENGTH`, PostgreSQL `length`) and the backend's PHP `mb_strlen`.
|
|
23
|
+
return [...value].length
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.length
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError('Value must be a string or an array')
|
|
29
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getLength } from '../../support/Utils'
|
|
2
|
+
|
|
1
3
|
export function maxLength(value: unknown, length: number): boolean {
|
|
2
|
-
return (
|
|
4
|
+
try { return getLength(value) <= length } catch { return false }
|
|
3
5
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export function maxTotalFileSize(value: unknown, size: string): boolean {
|
|
2
|
-
|
|
2
|
+
// The model may mix freshly selected `File`s with `string` references to
|
|
3
|
+
// already-uploaded files (see `SInputFileUpload`). Reject anything else,
|
|
4
|
+
// but allow string references through — only `File`s contribute to the
|
|
5
|
+
// total, since an already-uploaded file has no client-side size (the
|
|
6
|
+
// server validates its real size). This means the total under-counts kept
|
|
7
|
+
// files, which is accepted for now.
|
|
8
|
+
if (!Array.isArray(value) || !value.every((v) => v instanceof File || typeof v === 'string')) {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
const factor = /gb/i.test(size) ? 1e9 : /mb/i.test(size) ? 1e6 : /kb/i.test(size) ? 1e3 : 1
|
|
5
|
-
const total = value.reduce((total, file) => total + file.size, 0)
|
|
13
|
+
const total = value.reduce((total, file) => total + (file instanceof File ? file.size : 0), 0)
|
|
6
14
|
|
|
7
15
|
return total <= factor * Number.parseFloat(size.replace(/[^\d.]/g, ''))
|
|
8
16
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getLength } from '../../support/Utils'
|
|
2
|
+
|
|
1
3
|
export function minLength(value: unknown, length: number): boolean {
|
|
2
|
-
return (
|
|
4
|
+
try { return getLength(value) >= length } catch { return false }
|
|
3
5
|
}
|