@globalbrain/sefirot 4.44.0 → 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 (32) hide show
  1. package/README.md +5 -5
  2. package/config/vite.js +1 -24
  3. package/lib/blocks/lens/FieldData.ts +27 -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/LensFormView.vue +31 -4
  11. package/lib/blocks/lens/components/LensTable.vue +18 -5
  12. package/lib/blocks/lens/composables/SetupLens.ts +4 -0
  13. package/lib/blocks/lens/fields/BooleanField.ts +76 -0
  14. package/lib/blocks/lens/fields/DateField.ts +9 -1
  15. package/lib/blocks/lens/fields/DatetimeField.ts +8 -1
  16. package/lib/blocks/lens/fields/Field.ts +24 -1
  17. package/lib/blocks/lens/fields/RelatedManyField.ts +29 -5
  18. package/lib/blocks/lens/fields/RelatedOneField.ts +119 -0
  19. package/lib/blocks/lens/fields/TextField.ts +2 -0
  20. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +5 -1
  21. package/lib/blocks/lens/validation/RuleMapper.ts +3 -1
  22. package/lib/components/SInputFileUpload.vue +17 -2
  23. package/lib/components/SInputFileUploadItem.vue +57 -16
  24. package/lib/http/Http.ts +8 -13
  25. package/lib/support/Chart.ts +7 -3
  26. package/lib/support/Day.ts +5 -4
  27. package/lib/support/File.ts +25 -0
  28. package/lib/support/Utils.ts +13 -0
  29. package/lib/validation/validators/maxLength.ts +3 -1
  30. package/lib/validation/validators/maxTotalFileSize.ts +10 -2
  31. package/lib/validation/validators/minLength.ts +3 -1
  32. package/package.json +34 -35
@@ -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
  */
@@ -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) => {
@@ -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
 
@@ -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
- name: props.file instanceof File ? props.file.name : props.file.file.name,
40
- file: props.file instanceof File ? props.file : props.file.file,
41
- size: formatSize(props.file instanceof File ? props.file : props.file.file),
42
- indicatorState: props.file instanceof File ? null : props.file.indicatorState,
43
- canRemove: props.file instanceof File ? true : (props.file.canRemove ?? true),
44
- action: props.file instanceof File ? null : props.file.action,
45
- errorMessage: props.file instanceof File ? null : props.file.errorMessage
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.value.$touch()
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 } = await this.performRequestRaw<Blob>(url, {
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
- const { filename = 'download' } =
130
- parseContentDisposition(headers.get('Content-Disposition') || '')?.parameters || {}
122
+ let filename
123
+ try {
124
+ filename = parseContentDisposition(headers.get('Content-Disposition') || '').parameters.filename
125
+ } catch {}
131
126
 
132
- FileSaver.saveAs(blob, filename as string)
127
+ saveAs(blob, filename as string | undefined)
133
128
  }
134
129
  }
135
130
 
@@ -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 dataUrl = canvas.toDataURL('image/png')
90
- FileSaver.saveAs(dataUrl, fileName)
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
  }
@@ -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)
@@ -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
+ }
@@ -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 (typeof value === 'string' || Array.isArray(value)) && value.length <= length
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
- if (!Array.isArray(value) || !value.every((v) => v instanceof File)) { return false }
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 (typeof value === 'string' || Array.isArray(value)) && value.length >= length
4
+ try { return getLength(value) >= length } catch { return false }
3
5
  }