@ditojs/admin 2.100.0 → 3.0.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/dist/dito-admin.css +1 -1
- package/dist/dito-admin.es.js +3421 -2662
- package/dist/dito-admin.umd.js +6 -6
- package/package.json +81 -45
- package/src/DitoComponent.js +1 -1
- package/src/DitoContext.js +51 -0
- package/src/DitoTypeComponent.js +6 -1
- package/src/components/DitoAffixes.vue +6 -3
- package/src/components/DitoContainer.vue +19 -10
- package/src/components/DitoCreateButton.vue +11 -2
- package/src/components/DitoDialog.vue +21 -2
- package/src/components/DitoForm.vue +156 -83
- package/src/components/DitoLabel.vue +3 -2
- package/src/components/DitoPane.vue +55 -11
- package/src/components/DitoPanel.vue +33 -5
- package/src/components/DitoPanels.vue +2 -1
- package/src/components/DitoRoot.vue +13 -6
- package/src/components/DitoSchema.vue +278 -145
- package/src/components/DitoSchemaInlined.vue +1 -1
- package/src/components/DitoTabs.vue +16 -18
- package/src/components/DitoTrail.vue +5 -4
- package/src/components/DitoTreeItem.vue +17 -6
- package/src/components/DitoView.vue +25 -2
- package/src/mixins/DitoMixin.js +42 -7
- package/src/mixins/ItemMixin.js +3 -6
- package/src/mixins/NumberMixin.js +47 -31
- package/src/mixins/OptionsMixin.js +122 -71
- package/src/mixins/ResourceMixin.js +5 -2
- package/src/mixins/RouteMixin.js +39 -1
- package/src/mixins/SortableMixin.js +0 -13
- package/src/mixins/SourceMixin.js +142 -95
- package/src/mixins/TextMixin.js +1 -1
- package/src/mixins/TypeMixin.js +37 -22
- package/src/mixins/ValidationMixin.js +9 -27
- package/src/mixins/ValidatorMixin.js +5 -7
- package/src/mixins/ValueMixin.js +5 -2
- package/src/types/DitoTypeButton.vue +1 -1
- package/src/types/DitoTypeCheckbox.vue +1 -1
- package/src/types/DitoTypeCheckboxes.vue +1 -1
- package/src/types/DitoTypeCode.vue +8 -1
- package/src/types/DitoTypeColor.vue +20 -34
- package/src/types/DitoTypeComponent.vue +1 -1
- package/src/types/DitoTypeComputed.vue +4 -27
- package/src/types/DitoTypeDate.vue +16 -5
- package/src/types/DitoTypeLabel.vue +1 -4
- package/src/types/DitoTypeList.vue +9 -5
- package/src/types/DitoTypeMarkup.vue +25 -13
- package/src/types/DitoTypeMultiselect.vue +26 -6
- package/src/types/DitoTypeNumber.vue +1 -1
- package/src/types/DitoTypeObject.vue +10 -7
- package/src/types/DitoTypePanel.vue +19 -6
- package/src/types/DitoTypeProgress.vue +1 -1
- package/src/types/DitoTypeRadio.vue +1 -1
- package/src/types/DitoTypeSection.vue +32 -4
- package/src/types/DitoTypeSelect.vue +1 -1
- package/src/types/DitoTypeSlider.vue +1 -1
- package/src/types/DitoTypeSwitch.vue +1 -1
- package/src/types/DitoTypeText.vue +13 -15
- package/src/types/DitoTypeTextarea.vue +1 -1
- package/src/types/DitoTypeTreeList.vue +12 -7
- package/src/types/DitoTypeUpload.vue +1 -2
- package/src/utils/DataSchemaResolver.js +141 -0
- package/src/utils/DataSchemaResolver.test.js +132 -0
- package/src/utils/FormModel.js +556 -0
- package/src/utils/FormModel.test.js +808 -0
- package/src/utils/data.js +20 -0
- package/src/utils/data.test.js +28 -0
- package/src/utils/filter.js +3 -2
- package/src/utils/options.js +3 -0
- package/src/utils/schema/data.js +617 -0
- package/src/utils/schema/data.test.js +471 -0
- package/src/utils/schema/lookup.js +266 -0
- package/src/utils/schema/lookup.test.js +61 -0
- package/src/utils/schema/setup.js +354 -0
- package/src/utils/schema/structure.js +172 -0
- package/src/utils/schema/structure.test.js +57 -0
- package/src/utils/schema/types.js +34 -0
- package/src/utils/schema/validation.js +142 -0
- package/src/utils/schema/validation.test.js +395 -0
- package/src/utils/units.js +70 -3
- package/src/utils/units.test.js +71 -0
- package/types/index.d.ts +149 -59
- package/src/mixins/DataMixin.js +0 -131
- package/src/utils/schema.js +0 -1121
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import NumberMixin from '../../mixins/NumberMixin.js'
|
|
2
|
+
import { registerTypeComponent } from './types.js'
|
|
3
|
+
import {
|
|
4
|
+
getValidations,
|
|
5
|
+
getValueValidationErrors,
|
|
6
|
+
getDataValidationErrors,
|
|
7
|
+
getMatchingValidationErrors
|
|
8
|
+
} from './validation.js'
|
|
9
|
+
|
|
10
|
+
// Register minimal type options, as the actual type components can't be
|
|
11
|
+
// imported without a Vue SFC compiler:
|
|
12
|
+
registerTypeComponent('text', { defaultNested: true })
|
|
13
|
+
registerTypeComponent('password', {
|
|
14
|
+
defaultNested: true,
|
|
15
|
+
getTypeValidations: () => ({ password: true })
|
|
16
|
+
})
|
|
17
|
+
registerTypeComponent('number', { ...NumberMixin, defaultNested: true })
|
|
18
|
+
registerTypeComponent('integer', { ...NumberMixin, defaultNested: true })
|
|
19
|
+
registerTypeComponent('section', { defaultNested: false })
|
|
20
|
+
registerTypeComponent('panel', { defaultNested: false })
|
|
21
|
+
registerTypeComponent('list', {
|
|
22
|
+
defaultNested: true,
|
|
23
|
+
getSourceType: () => 'list'
|
|
24
|
+
})
|
|
25
|
+
registerTypeComponent('object', {
|
|
26
|
+
defaultNested: true,
|
|
27
|
+
getSourceType: () => 'object'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('getValidations()', () => {
|
|
31
|
+
it('returns no validations for plain schemas', () => {
|
|
32
|
+
expect(getValidations({ type: 'text' })).toEqual({})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('supports `required` as value and callback', () => {
|
|
36
|
+
expect(getValidations({ type: 'text', required: true })).toEqual({
|
|
37
|
+
required: true
|
|
38
|
+
})
|
|
39
|
+
const schema = { type: 'text', required: ({ value }) => value === 1 }
|
|
40
|
+
expect(getValidations(schema, { value: 1 })).toEqual({
|
|
41
|
+
required: true
|
|
42
|
+
})
|
|
43
|
+
expect(getValidations(schema, { value: 2 })).toEqual({})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('adds the validations of the type', () => {
|
|
47
|
+
expect(getValidations({ type: 'password' })).toEqual({
|
|
48
|
+
password: true
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('applies `schema.rules` overrides', () => {
|
|
53
|
+
expect(
|
|
54
|
+
getValidations({
|
|
55
|
+
type: 'password',
|
|
56
|
+
required: true,
|
|
57
|
+
rules: { password: undefined, required: undefined, email: true }
|
|
58
|
+
})
|
|
59
|
+
).toEqual({ email: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ignores unknown types', () => {
|
|
63
|
+
expect(getValidations({ type: 'unknown', required: true })).toEqual({
|
|
64
|
+
required: true
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('number types', () => {
|
|
69
|
+
it.each([
|
|
70
|
+
[{ type: 'number' }, {}],
|
|
71
|
+
[{ type: 'number', min: 1 }, { min: 1 }],
|
|
72
|
+
[{ type: 'number', max: 2 }, { max: 2 }],
|
|
73
|
+
[{ type: 'number', min: 1, max: 2 }, { range: [1, 2] }],
|
|
74
|
+
[{ type: 'number', range: [1, 2] }, { range: [1, 2] }],
|
|
75
|
+
[{ type: 'number', range: [1, 2], min: 0 }, { range: [0, 2] }],
|
|
76
|
+
[{ type: 'number', decimals: 2 }, { decimals: 2 }],
|
|
77
|
+
[{ type: 'number', step: 0.25 }, { decimals: 2 }],
|
|
78
|
+
[{ type: 'number', step: 5 }, { integer: true }],
|
|
79
|
+
[{ type: 'number', step: 0.5, decimals: 3 }, { decimals: 3 }],
|
|
80
|
+
[{ type: 'integer' }, { integer: true }],
|
|
81
|
+
[
|
|
82
|
+
{ type: 'integer', range: [0.5, 1.5], step: 0.5 },
|
|
83
|
+
{ range: [0, 2], integer: true }
|
|
84
|
+
]
|
|
85
|
+
])('%j', (schema, validations) => {
|
|
86
|
+
expect(getValidations(schema)).toEqual(validations)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('evaluates callbacks with the context', () => {
|
|
90
|
+
const schema = { type: 'number', max: ({ item }) => item.limit }
|
|
91
|
+
expect(getValidations(schema, { item: { limit: 3 } })).toEqual({
|
|
92
|
+
max: 3
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('getValueValidationErrors()', () => {
|
|
99
|
+
it('returns no errors for valid values', () => {
|
|
100
|
+
const validations = { required: true, range: [0, 2] }
|
|
101
|
+
expect(getValueValidationErrors(1, validations)).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('applies `required` only to empty values', () => {
|
|
105
|
+
expect(getValueValidationErrors(null, { required: true })).toEqual([
|
|
106
|
+
{ message: 'is required' }
|
|
107
|
+
])
|
|
108
|
+
expect(getValueValidationErrors('', { required: true })).toEqual([
|
|
109
|
+
{ message: 'is required' }
|
|
110
|
+
])
|
|
111
|
+
expect(getValueValidationErrors(undefined, { required: true })).toEqual([
|
|
112
|
+
{ message: 'is required' }
|
|
113
|
+
])
|
|
114
|
+
expect(getValueValidationErrors(0, { required: true })).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('applies other validations only to non-empty values', () => {
|
|
118
|
+
expect(getValueValidationErrors(null, { min: 1, email: true })).toEqual([])
|
|
119
|
+
expect(getValueValidationErrors('', { min: 1, email: true })).toEqual([])
|
|
120
|
+
expect(getValueValidationErrors(0, { min: 1 })).toEqual([
|
|
121
|
+
{ message: 'must be 1 or more' }
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('returns all errors, with messages without label prefix', () => {
|
|
126
|
+
expect(getValueValidationErrors(1.5, { min: 2, integer: true })).toEqual([
|
|
127
|
+
{ message: 'must be 2 or more' },
|
|
128
|
+
{ message: 'must be whole number' }
|
|
129
|
+
])
|
|
130
|
+
expect(getValueValidationErrors(5, { range: [1, 2] })).toEqual([
|
|
131
|
+
{ message: 'must be between 1 and 2' }
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('passes the other validations to the validators', () => {
|
|
136
|
+
// Unchanged passwords are `undefined` and satisfy `required`:
|
|
137
|
+
const validations = { required: true, password: true }
|
|
138
|
+
expect(getValueValidationErrors(undefined, validations)).toEqual([])
|
|
139
|
+
expect(getValueValidationErrors(null, validations)).toEqual([
|
|
140
|
+
{ message: 'is required' }
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('ignores unknown validations', () => {
|
|
145
|
+
expect(getValueValidationErrors('a', { unknown: true })).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('getDataValidationErrors()', () => {
|
|
150
|
+
const required = { message: 'is required' }
|
|
151
|
+
|
|
152
|
+
it('returns `null` for valid data', () => {
|
|
153
|
+
const schema = {
|
|
154
|
+
components: {
|
|
155
|
+
title: { type: 'text', required: true }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
expect(getDataValidationErrors(schema, { title: 'Title' })).toBe(null)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('validates the components of panel components', () => {
|
|
162
|
+
// Panels share the data of their schema, like unnested sections.
|
|
163
|
+
const schema = {
|
|
164
|
+
components: {
|
|
165
|
+
links: {
|
|
166
|
+
type: 'panel',
|
|
167
|
+
components: { website: { type: 'text', required: true } }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
expect(getDataValidationErrors(schema, { website: '' })).toEqual({
|
|
172
|
+
website: [required]
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns errors by data path, in the format of server errors', () => {
|
|
177
|
+
const schema = {
|
|
178
|
+
components: {
|
|
179
|
+
title: { type: 'text', required: true },
|
|
180
|
+
count: { type: 'integer', min: 1 },
|
|
181
|
+
note: { type: 'text' }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
expect(getDataValidationErrors(schema, { title: '', count: 0.5 })).toEqual({
|
|
185
|
+
title: [required],
|
|
186
|
+
count: [
|
|
187
|
+
{ message: 'must be 1 or more' },
|
|
188
|
+
{ message: 'must be whole number' }
|
|
189
|
+
]
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('prefixes errors with `dataPath`', () => {
|
|
194
|
+
const schema = {
|
|
195
|
+
components: { title: { type: 'text', required: true } }
|
|
196
|
+
}
|
|
197
|
+
expect(
|
|
198
|
+
getDataValidationErrors(schema, {}, { dataPath: 'items/0' })
|
|
199
|
+
).toEqual({
|
|
200
|
+
'items/0/title': [required]
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('validates tabs, panels and unnested components', () => {
|
|
205
|
+
const schema = {
|
|
206
|
+
components: {
|
|
207
|
+
section: {
|
|
208
|
+
type: 'section',
|
|
209
|
+
components: { a: { type: 'text', required: true } }
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
tabs: {
|
|
213
|
+
tab: {
|
|
214
|
+
type: 'tab',
|
|
215
|
+
components: { b: { type: 'text', required: true } }
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
panels: {
|
|
219
|
+
panel: {
|
|
220
|
+
type: 'panel',
|
|
221
|
+
components: { c: { type: 'text', required: true } }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
expect(getDataValidationErrors(schema, {})).toEqual({
|
|
226
|
+
a: [required],
|
|
227
|
+
b: [required],
|
|
228
|
+
c: [required]
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('validates the items of nested forms', () => {
|
|
233
|
+
const schema = {
|
|
234
|
+
components: {
|
|
235
|
+
items: {
|
|
236
|
+
type: 'list',
|
|
237
|
+
form: {
|
|
238
|
+
type: 'form',
|
|
239
|
+
components: { title: { type: 'text', required: true } }
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
object: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
forms: {
|
|
245
|
+
a: {
|
|
246
|
+
type: 'form',
|
|
247
|
+
components: { a: { type: 'text', required: true } }
|
|
248
|
+
},
|
|
249
|
+
b: {
|
|
250
|
+
type: 'form',
|
|
251
|
+
components: { b: { type: 'text', required: true } }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const data = {
|
|
258
|
+
items: [{ title: 'Title' }, { title: null }],
|
|
259
|
+
object: { type: 'b' }
|
|
260
|
+
}
|
|
261
|
+
expect(getDataValidationErrors(schema, data)).toEqual({
|
|
262
|
+
'items/1/title': [required],
|
|
263
|
+
'object/b': [required]
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('skips components whose `if` is false', () => {
|
|
268
|
+
const schema = {
|
|
269
|
+
components: {
|
|
270
|
+
show: { type: 'text' },
|
|
271
|
+
a: {
|
|
272
|
+
type: 'text',
|
|
273
|
+
required: true,
|
|
274
|
+
if: ({ item }) => item.show
|
|
275
|
+
},
|
|
276
|
+
section: {
|
|
277
|
+
type: 'section',
|
|
278
|
+
if: ({ item }) => item.show,
|
|
279
|
+
components: { b: { type: 'text', required: true } }
|
|
280
|
+
},
|
|
281
|
+
items: {
|
|
282
|
+
type: 'list',
|
|
283
|
+
if: ({ item }) => item.show,
|
|
284
|
+
form: {
|
|
285
|
+
type: 'form',
|
|
286
|
+
components: { c: { type: 'text', required: true } }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
tabs: {
|
|
291
|
+
tab: {
|
|
292
|
+
type: 'tab',
|
|
293
|
+
if: ({ item }) => item.show,
|
|
294
|
+
components: { d: { type: 'text', required: true } }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const data = { items: [{}] }
|
|
299
|
+
expect(getDataValidationErrors(schema, data)).toBe(null)
|
|
300
|
+
expect(getDataValidationErrors(schema, { ...data, show: true })).toEqual({
|
|
301
|
+
'a': [required],
|
|
302
|
+
'b': [required],
|
|
303
|
+
'items/0/c': [required],
|
|
304
|
+
'd': [required]
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('skips sources with their own resource', () => {
|
|
309
|
+
const schema = {
|
|
310
|
+
components: {
|
|
311
|
+
items: {
|
|
312
|
+
type: 'list',
|
|
313
|
+
resource: { path: 'items' },
|
|
314
|
+
form: {
|
|
315
|
+
type: 'form',
|
|
316
|
+
components: { title: { type: 'text', required: true } }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
expect(getDataValidationErrors(schema, { items: [{}] })).toBe(null)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('validates formatted values', () => {
|
|
325
|
+
const schema = {
|
|
326
|
+
components: {
|
|
327
|
+
name: {
|
|
328
|
+
type: 'text',
|
|
329
|
+
required: true,
|
|
330
|
+
format: ({ value }) => value?.trim()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
expect(getDataValidationErrors(schema, { name: ' ' })).toEqual({
|
|
335
|
+
name: [required]
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('passes `rootData` and `dataPath` to callbacks', () => {
|
|
340
|
+
const rootData = { strict: true }
|
|
341
|
+
const schema = {
|
|
342
|
+
components: {
|
|
343
|
+
name: {
|
|
344
|
+
type: 'text',
|
|
345
|
+
required: ({ rootItem, dataPath }) => (
|
|
346
|
+
rootItem.strict && dataPath === 'item/name'
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
expect(
|
|
352
|
+
getDataValidationErrors(schema, {}, { dataPath: 'item', rootData })
|
|
353
|
+
).toEqual({ 'item/name': [required] })
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe('getMatchingValidationErrors()', () => {
|
|
358
|
+
const required = [{ message: 'is required' }]
|
|
359
|
+
const errors = {
|
|
360
|
+
'title': required,
|
|
361
|
+
'items/0/title': required,
|
|
362
|
+
'items/1/name': required
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
it('matches data paths with functions', () => {
|
|
366
|
+
expect(
|
|
367
|
+
getMatchingValidationErrors(errors, dataPath => dataPath.length > 5)
|
|
368
|
+
).toEqual({
|
|
369
|
+
'items/0/title': required,
|
|
370
|
+
'items/1/name': required
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('matches data paths with regular expressions', () => {
|
|
375
|
+
expect(getMatchingValidationErrors(errors, /^items\/\d+\/title$/)).toEqual(
|
|
376
|
+
{ 'items/0/title': required }
|
|
377
|
+
)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('matches normalized data paths and arrays of them', () => {
|
|
381
|
+
expect(getMatchingValidationErrors(errors, 'items[1].name')).toEqual({
|
|
382
|
+
'items/1/name': required
|
|
383
|
+
})
|
|
384
|
+
expect(getMatchingValidationErrors(errors, ['title', 'items/1/name']))
|
|
385
|
+
.toEqual({
|
|
386
|
+
'title': required,
|
|
387
|
+
'items/1/name': required
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('returns `null` if nothing matches', () => {
|
|
392
|
+
expect(getMatchingValidationErrors(errors, 'name')).toBe(null)
|
|
393
|
+
expect(getMatchingValidationErrors(null, /.*/)).toBe(null)
|
|
394
|
+
})
|
|
395
|
+
})
|
package/src/utils/units.js
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
|
-
|
|
1
|
+
// Unit prefixes in order of their exponent, shared by parsing and formatting.
|
|
2
|
+
const prefixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Formats a number of bytes like `'1.5 MB'`, using base 10 (1 kB = 1000 bytes)
|
|
6
|
+
* unless specified. Base 2 uses IEC units (1 KiB = 1024 bytes).
|
|
7
|
+
*/
|
|
8
|
+
export function formatFileSize(size, { base = 10 } = {}) {
|
|
9
|
+
const multiplier = getMultiplier(base)
|
|
10
|
+
const absolute = Math.abs(size)
|
|
11
|
+
let exponent = Math.min(
|
|
12
|
+
absolute >= 1 ? Math.floor(Math.log(absolute) / Math.log(multiplier)) : 0,
|
|
13
|
+
prefixes.length - 1
|
|
14
|
+
)
|
|
15
|
+
let amount = roundFileSize(absolute, multiplier, exponent)
|
|
16
|
+
// Rounding can reach the next unit, e.g. 999999 bytes to 1000 kB = 1 MB.
|
|
17
|
+
if (amount >= multiplier && exponent < prefixes.length - 1) {
|
|
18
|
+
amount = roundFileSize(absolute, multiplier, ++exponent)
|
|
19
|
+
}
|
|
20
|
+
const prefix = prefixes[exponent]
|
|
21
|
+
const unit = !prefix
|
|
22
|
+
? 'B'
|
|
23
|
+
: base === 10
|
|
24
|
+
? `${prefix}B`
|
|
25
|
+
: `${prefix.toUpperCase()}iB`
|
|
26
|
+
return `${size < 0 ? '-' : ''}${amount} ${unit}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parses file sizes like `'10 MB'`, `'1.5G'`, `'512kb'` or `'100 bytes'` into
|
|
31
|
+
* a number of bytes, using base 10 (1 kB = 1000 bytes) unless specified.
|
|
32
|
+
* IEC units like `'10 MiB'` always use base 2 (1 KiB = 1024 bytes).
|
|
33
|
+
* Bit units are case-sensitive (`Kb` = kilobit), the byte units aren't.
|
|
34
|
+
*/
|
|
35
|
+
export function parseFileSize(input, { base = 10 } = {}) {
|
|
36
|
+
const [, amountString, unit] = (
|
|
37
|
+
String(input).match(/^([\d.,]*)\s*(\D*)$/) || []
|
|
38
|
+
)
|
|
39
|
+
const amount = parseFloat(amountString?.replace(',', '.'))
|
|
40
|
+
if (!isFinite(amount)) {
|
|
41
|
+
throw new Error(`Can't interpret file size: ${input}`)
|
|
42
|
+
}
|
|
43
|
+
const multiplier = getMultiplier(/^[kmgtpezy]ib?$/i.test(unit) ? 2 : base)
|
|
44
|
+
const bitMatch = unit.replace(/^bits?$/, 'b').match(/^([KMGTPEZY]?)b$/)
|
|
45
|
+
if (bitMatch) {
|
|
46
|
+
const exponent = prefixes.indexOf(bitMatch[1].replace('K', 'k'))
|
|
47
|
+
return Math.round(amount * multiplier ** exponent / 8)
|
|
48
|
+
}
|
|
49
|
+
const exponent = prefixes.findIndex(
|
|
50
|
+
prefix => (
|
|
51
|
+
prefix.toLowerCase() ===
|
|
52
|
+
unit
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/^(bytes?|b)$/, '')
|
|
55
|
+
.replace(/^(\w)(i?b|i)$/, '$1')
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if (exponent >= 0) {
|
|
59
|
+
return Math.round(amount * multiplier ** exponent)
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Invalid file size unit: ${unit}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getMultiplier(base) {
|
|
65
|
+
return base === 10 ? 1000 : 1024
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function roundFileSize(size, multiplier, exponent) {
|
|
69
|
+
// Round bytes to integers, larger units to two decimals.
|
|
70
|
+
const factor = exponent > 0 ? 100 : 1
|
|
71
|
+
return Math.round(size / multiplier ** exponent * factor) / factor
|
|
5
72
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { formatFileSize, parseFileSize } from './units.js'
|
|
2
|
+
|
|
3
|
+
describe('formatFileSize()', () => {
|
|
4
|
+
// Expected values match the previously used `filesize` package.
|
|
5
|
+
it.each([
|
|
6
|
+
[0, '0 B'],
|
|
7
|
+
[1, '1 B'],
|
|
8
|
+
[0.5, '1 B'],
|
|
9
|
+
[999, '999 B'],
|
|
10
|
+
[1000, '1 kB'],
|
|
11
|
+
[1001, '1 kB'],
|
|
12
|
+
[1234, '1.23 kB'],
|
|
13
|
+
[1500, '1.5 kB'],
|
|
14
|
+
[9999, '10 kB'],
|
|
15
|
+
[123456, '123.46 kB'],
|
|
16
|
+
[999999, '1 MB'],
|
|
17
|
+
[1234567, '1.23 MB'],
|
|
18
|
+
[1e9, '1 GB'],
|
|
19
|
+
[1.5e12, '1.5 TB'],
|
|
20
|
+
[2.5e15, '2.5 PB'],
|
|
21
|
+
[1e18, '1 EB'],
|
|
22
|
+
[1e21, '1 ZB'],
|
|
23
|
+
[-1500, '-1.5 kB']
|
|
24
|
+
])('formats %o as %o', (size, expected) => {
|
|
25
|
+
expect(formatFileSize(size)).toBe(expected)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('supports base 2', () => {
|
|
29
|
+
expect(formatFileSize(1024, { base: 2 })).toBe('1 KiB')
|
|
30
|
+
expect(formatFileSize(1536, { base: 2 })).toBe('1.5 KiB')
|
|
31
|
+
expect(formatFileSize(1048576, { base: 2 })).toBe('1 MiB')
|
|
32
|
+
expect(formatFileSize(1073741824, { base: 2 })).toBe('1 GiB')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('parseFileSize()', () => {
|
|
37
|
+
it.each([
|
|
38
|
+
['1024', 1024],
|
|
39
|
+
[1024, 1024],
|
|
40
|
+
['100 bytes', 100],
|
|
41
|
+
['100B', 100],
|
|
42
|
+
['1k', 1000],
|
|
43
|
+
['1 kB', 1000],
|
|
44
|
+
['1 KB', 1000],
|
|
45
|
+
['1kb', 1000],
|
|
46
|
+
['1.5 MB', 1.5e6],
|
|
47
|
+
['1,5M', 1.5e6],
|
|
48
|
+
['1T', 1e12],
|
|
49
|
+
['1KiB', 1024],
|
|
50
|
+
['1 Ki', 1024],
|
|
51
|
+
['1.5 MiB', 1.5 * 1024 ** 2],
|
|
52
|
+
['2 GiB', 2 * 1024 ** 3],
|
|
53
|
+
['8b', 1],
|
|
54
|
+
['8 bits', 1],
|
|
55
|
+
['1Kb', 125],
|
|
56
|
+
['1Mb', 125000]
|
|
57
|
+
])('parses %o as %o bytes', (input, expected) => {
|
|
58
|
+
expect(parseFileSize(input)).toBe(expected)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('supports base 2', () => {
|
|
62
|
+
expect(parseFileSize('1 MB', { base: 2 })).toBe(1024 ** 2)
|
|
63
|
+
expect(parseFileSize('1 MiB', { base: 2 })).toBe(1024 ** 2)
|
|
64
|
+
expect(parseFileSize('1Kb', { base: 2 })).toBe(128)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('throws on invalid input', () => {
|
|
68
|
+
expect(() => parseFileSize('MB')).toThrow()
|
|
69
|
+
expect(() => parseFileSize('10 XB')).toThrow()
|
|
70
|
+
})
|
|
71
|
+
})
|