tantee-nuxt-commons 0.0.167 → 0.0.169

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/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0"
6
6
  },
7
- "version": "0.0.167",
7
+ "version": "0.0.169",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -45,6 +45,7 @@ watch(() => alert?.hasAlert(), (hasAlert) => {
45
45
  elevation="2"
46
46
  theme="dark"
47
47
  variant="flat"
48
+ v-bind="currentItem.alertIcon ? { icon: currentItem.alertIcon } : {}"
48
49
  @click:close="renewAlert()"
49
50
  >
50
51
  {{ currentItem.statusCode ? currentItem.statusCode + ' ' : '' }} {{ currentItem.message }} {{ !isEmpty(currentItem.data) ? currentItem.data : '' }}
@@ -73,7 +73,7 @@ const loadFormData = () => {
73
73
  formData.value = cloneDeep(formDataOriginalValue.value)
74
74
  }
75
75
  else {
76
- formData.value = Object.assign({}, props.initialData)
76
+ formData.value = Object.assign({}, cloneDeep(props.initialData))
77
77
  }
78
78
  }
79
79
 
@@ -85,7 +85,7 @@ const loadFormData = () => {
85
85
  formDataOriginalValue.value = cloneDeep(props.formData)
86
86
  }
87
87
  else {
88
- formData.value = Object.assign({}, props.initialData)
88
+ formData.value = Object.assign({}, cloneDeep(props.initialData))
89
89
  }
90
90
  isSavedAndStay.value = false
91
91
  }
@@ -78,7 +78,7 @@ const loadFormData = () => {
78
78
  formDataOriginalValue.value = cloneDeep(props.formData)
79
79
  }
80
80
  else {
81
- formData.value = Object.assign({}, props.initialData)
81
+ formData.value = Object.assign({}, cloneDeep(props.initialData))
82
82
  }
83
83
  }
84
84
 
@@ -1,225 +1,172 @@
1
1
  <script lang="ts" setup>
2
- import { castArray } from 'lodash-es'
3
- import { ref, shallowRef, watch, computed } from 'vue'
4
- import { VTextField } from 'vuetify/components/VTextField'
5
- import { useAlert } from '../../composables/alert'
6
- import { type Base64File } from '../../composables/assetFile'
7
- import { useAssetFile } from '../../composables/assetFile'
2
+ import {isEqual, uniqWith} from 'lodash-es'
3
+ import {ref, watch} from 'vue'
4
+ import {VTextField} from 'vuetify/components/VTextField'
5
+ import {useAlert} from '../../composables/alert'
8
6
 
9
7
  const alert = useAlert()
10
- const { hydrateAssetFile, base64ToFile, fileToBase64, downloadBase64File } = useAssetFile()
11
8
 
12
- const fileToBase64WithMaxSize = (file: File) => fileToBase64(file, props.maxSize)
9
+ interface Base64String {
10
+ base64String?: string
11
+ fileName: string
12
+ originalFileName?: string
13
+ id?: number
14
+ }
13
15
 
14
16
  interface Props extends /* @vue-ignore */ InstanceType<typeof VTextField['$props']> {
15
17
  accept?: string
16
18
  multiple?: boolean
17
19
  maxSize?: number
18
- modelValue?: Base64File | Base64File[] | null
20
+ modelValue?: Base64String | Base64String[]
19
21
  downloadable?: boolean
20
- autoHydrate?: boolean
21
22
  }
22
23
 
23
24
  const props = withDefaults(defineProps<Props>(), {
24
25
  accept: '*',
25
26
  multiple: false,
26
27
  maxSize: 5,
27
- downloadable: false,
28
- autoHydrate: false,
28
+ downloadable: false
29
29
  })
30
30
 
31
31
  const emit = defineEmits<{
32
- (e: 'update:modelValue', value: Base64File | Base64File[] | null): void
32
+ (e: 'update:modelValue', value: Base64String | Base64String[]): void
33
33
  }>()
34
34
 
35
- /** UI ref */
36
- const fileInput = ref<HTMLInputElement | null>(null)
37
-
38
- /** Internal sources (always arrays) */
39
- const assets = ref<Base64File[]>([]) // items with server id (or ones we keep as “assets”)
40
- const files = shallowRef<File[]>([]) // native File objects picked by the user
35
+ const allFiles = ref<File[]>([])
36
+ const allAssets = ref<Base64String[]>([])
37
+ const combinedBase64String = ref<Base64String[] | Base64String>([])
38
+ const fileInput = ref()
41
39
 
42
- /** Cache to avoid re-reading the same File repeatedly */
43
- const fileCache = new WeakMap<File, Promise<Base64File>>()
44
-
45
- /** Re-entrancy guard to break the emit -> props watcher -> syncFromModel loop */
46
- let internalSync = false
40
+ function openWindowUpload() {
41
+ if (props.multiple || (!allFiles.value?.length && !allAssets.value?.length)) {
42
+ fileInput.value?.click()
43
+ }
44
+ }
47
45
 
48
- /** Build a stable dedupe key */
49
- function base64FileKey(x: Base64File): string {
50
- if (x.id != null) return `id:${x.id}`
51
- const name = x.fileName ?? ''
52
- const len = x.base64String?.length ?? 0
53
- return `n:${name}|l:${len}`
46
+ function addFiles(files: File | File[]) {
47
+ if (Array.isArray(files)) allFiles.value?.push(...files)
48
+ else allFiles.value?.push(files)
54
49
  }
55
50
 
56
- /** Dedupe by key (preserves order) */
57
- function uniqByKey(arr: Base64File[]): Base64File[] {
58
- const seen = new Set<string>()
59
- const out: Base64File[] = []
60
- for (const it of arr) {
61
- const k = base64FileKey(it)
62
- if (!seen.has(k)) {
63
- seen.add(k)
64
- out.push(it)
65
- }
66
- }
67
- return out
51
+ function removeFileByIndex(i: number | string) {
52
+ const index = Number(i)
53
+ if (index >= 0 && index < allFiles.value.length) allFiles.value.splice(index, 1)
68
54
  }
69
55
 
70
- /** Shallow equal by key sequence */
71
- function arraysEqualByKey(a: Base64File[], b: Base64File[]): boolean {
72
- if (a === b) return true
73
- if (a.length !== b.length) return false
74
- for (let i = 0; i < a.length; i++) {
75
- if (base64FileKey(a[i]) !== base64FileKey(b[i])) return false
76
- }
77
- return true
56
+ function removeAssetByIndex(i: number | string) {
57
+ const index = Number(i)
58
+ if (index >= 0 && index < allAssets.value.length) allAssets.value.splice(index, 1)
78
59
  }
79
60
 
80
- /** Normalize incoming modelValue into arrays for internal use */
81
- async function syncFromModel() {
82
- const mv = props.modelValue
83
- if (!mv) {
84
- assets.value = []
85
- files.value = []
86
- return
87
- }
61
+ function fileToBase64(file: File) {
62
+ const maxSize = props.maxSize * 1048576
88
63
 
89
- const asArray = castArray(mv) as Base64File[]
64
+ return new Promise<Base64String>((resolve, reject) => {
65
+ if (file.size > maxSize) reject(`File (${file.name}) size exceeds the ${props.maxSize} MB limit.`)
90
66
 
91
- // Split into “asset with id” vs “inline base64” (no id)
92
- const incomingAssets = asArray.filter(a => a.id != null)
93
- const inlineBase64 = asArray.filter(a => a.id == null && a.base64String)
67
+ const reader = new FileReader()
68
+ reader.onload = (event) => {
69
+ resolve({ fileName: file.name, base64String: event.target?.result as string })
70
+ }
71
+ reader.onerror = reject
72
+ reader.readAsDataURL(file)
73
+ })
74
+ }
94
75
 
95
- // Convert inline base64 to File for consistent UX
96
- const inlineFiles: File[] = []
97
- for (const item of inlineBase64) {
98
- const f = base64ToFile(item.base64String as string, item.fileName)
99
- if (f) inlineFiles.push(f)
100
- }
76
+ function base64ToFile(base64Data: string, filename: string, defaultContentType: string = "application/octet-stream") {
77
+ const matchResult = base64Data.match(/data:([^;]*);base64,(.*)/);
78
+ let contentType: string;
79
+ let base64Payload: string;
101
80
 
102
- assets.value = incomingAssets
103
- files.value = inlineFiles
81
+ if (matchResult) {
82
+ [contentType, base64Payload] = matchResult.slice(1)
83
+ } else {
84
+ contentType = defaultContentType
85
+ base64Payload = base64Data
86
+ }
104
87
 
105
- // Optionally hydrate assets that need it
106
- if (props.autoHydrate) {
107
- const needsHydration = assets.value.filter(a => a.id != null && !a.base64String)
108
- if (needsHydration.length) {
109
- await Promise.allSettled(needsHydration.map(hydrateAssetFile))
88
+ try {
89
+ const binaryStr = atob(base64Payload)
90
+ const bytes = new Uint8Array(binaryStr.length)
91
+ for (let i = 0; i < binaryStr.length; i++) {
92
+ bytes[i] = binaryStr.charCodeAt(i)
110
93
  }
94
+ return new File([bytes], filename, { type: contentType });
95
+ } catch (error) {
96
+ console.error("Invalid base64 data", error);
97
+ return undefined;
111
98
  }
112
99
  }
113
100
 
114
- /** Open chooser */
115
- function openWindowUpload() {
116
- if (props.multiple || (assets.value.length === 0 && files.value.length === 0)) {
117
- fileInput.value?.click()
118
- }
119
- }
101
+ function downloadBase64File(base64Data: string, filename: string): void {
102
+ const file = base64ToFile(base64Data,filename)
120
103
 
121
- /** Add files from input (single or multiple) */
122
- function addFiles(payload: File | File[]) {
123
- if (Array.isArray(payload)) files.value = [...files.value, ...payload]
124
- else files.value = [...files.value, payload]
125
- }
104
+ if (file) {
105
+ const link = document.createElement("a");
106
+ link.href = URL.createObjectURL(file);
107
+ link.download = filename;
126
108
 
127
- /** Remove chips */
128
- function removeFileByIndex(i: number | string) {
129
- const idx = Number(i)
130
- if (idx >= 0 && idx < files.value.length) {
131
- const copy = files.value.slice()
132
- copy.splice(idx, 1)
133
- files.value = copy
134
- }
135
- }
136
- function removeAssetByIndex(i: number | string) {
137
- const idx = Number(i)
138
- if (idx >= 0 && idx < assets.value.length) {
139
- const copy = assets.value.slice()
140
- copy.splice(idx, 1)
141
- assets.value = copy
142
- }
143
- }
109
+ // Append the link to the body temporarily and trigger the download
110
+ document.body.appendChild(link);
111
+ link.click();
144
112
 
145
- /** Convert current files → Base64File[] (cached) */
146
- async function filesToBase64Files(list: File[]): Promise<Base64File[]> {
147
- const tasks = list.map(f => {
148
- let p = fileCache.get(f)
149
- if (!p) {
150
- p = fileToBase64WithMaxSize(f)
151
- fileCache.set(f, p)
152
- }
153
- return p
154
- })
155
- return Promise.all(tasks)
113
+ // Cleanup
114
+ document.body.removeChild(link);
115
+ URL.revokeObjectURL(link.href);
116
+ }
156
117
  }
157
118
 
158
- /** Combined result (array form) */
159
- const combinedArray = ref<Base64File[]>([])
160
-
161
- /** Dirty flag for <v-text-field> */
162
- const isDirty = computed(() =>
163
- props.multiple ? combinedArray.value.length > 0 : combinedArray.value[0] != null
164
- )
165
119
 
166
- /** Sync from props.modelValue (guarded) */
167
120
  watch(
168
121
  () => props.modelValue,
169
122
  () => {
170
- if (internalSync) return
171
- void syncFromModel()
123
+ if (Array.isArray(props.modelValue)) {
124
+ allAssets.value = props.modelValue.filter((item) => item.id !== undefined)
125
+ allFiles.value = props.modelValue
126
+ .filter((item) => item.id === undefined && item.base64String !== undefined)
127
+ .map((base64) => base64ToFile(base64.base64String as string, base64.fileName))
128
+ .filter((item) => item !== undefined) as File[]
129
+ } else if (props.modelValue) {
130
+ allAssets.value = props.modelValue.id !== undefined ? [props.modelValue] : []
131
+ allFiles.value =
132
+ props.modelValue.id === undefined && props.modelValue.base64String !== undefined
133
+ ? [base64ToFile(props.modelValue.base64String, props.modelValue.fileName)].filter(
134
+ (item) => item !== undefined
135
+ ) as File[]
136
+ : []
137
+ } else {
138
+ allAssets.value = []
139
+ allFiles.value = []
140
+ }
172
141
  },
173
142
  { deep: true, immediate: true }
174
143
  )
175
144
 
176
- /** Rebuild combined and emit (guarded, deduped, only-on-change) */
177
- watch([assets, files], async () => {
178
- try {
179
- const base64FromFiles = await filesToBase64Files(files.value)
180
-
181
- const mergedArray = props.multiple
182
- ? [...assets.value, ...base64FromFiles]
183
- : (assets.value[0] ?? base64FromFiles[0] ?? null)
184
- ? [assets.value[0] ?? base64FromFiles[0]]
185
- : []
186
-
187
- const nextCombined = props.multiple ? uniqByKey(mergedArray) : mergedArray
188
-
189
- if (!arraysEqualByKey(combinedArray.value, nextCombined)) {
190
- combinedArray.value = nextCombined
191
-
192
- internalSync = true
193
- try {
194
- if (props.multiple) {
195
- emit('update:modelValue', combinedArray.value)
196
- } else {
197
- emit('update:modelValue', combinedArray.value[0] ?? null)
198
- }
199
- } finally {
200
- // let Vue flush the emit before re-enabling the external sync
201
- queueMicrotask(() => { internalSync = false })
202
- }
203
- }
204
- } catch (error: any) {
205
- alert?.addAlert({ message: String(error), alertType: 'error' })
206
- files.value = []
207
- const fallback = props.multiple
208
- ? [...assets.value]
209
- : (assets.value[0] ? [assets.value[0]] : [])
210
-
211
- if (!arraysEqualByKey(combinedArray.value, fallback)) {
212
- combinedArray.value = fallback
213
- internalSync = true
214
- try {
215
- if (props.multiple) emit('update:modelValue', combinedArray.value)
216
- else emit('update:modelValue', combinedArray.value[0] ?? null)
217
- } finally {
218
- queueMicrotask(() => { internalSync = false })
219
- }
220
- }
145
+ watch([allAssets, allFiles], () => {
146
+ if (allFiles.value.length) {
147
+ const base64Promises = allFiles.value.map(fileToBase64)
148
+ Promise.all(base64Promises)
149
+ .then((base64Strings) => {
150
+ combinedBase64String.value = props.multiple
151
+ ? [...allAssets.value, ...base64Strings]
152
+ : base64Strings[0] || allAssets.value[0] || null
153
+ })
154
+ .catch((error) => {
155
+ alert?.addAlert({ message: error, alertType: 'error' })
156
+ allFiles.value = []
157
+ })
158
+ } else {
159
+ combinedBase64String.value = props.multiple
160
+ ? [...allAssets.value]
161
+ : allAssets.value[0] || null
221
162
  }
222
163
  }, { deep: true, immediate: true })
164
+
165
+ watch(combinedBase64String, (newValue, oldValue) => {
166
+ if (!isEqual(newValue, oldValue)) {
167
+ emit('update:modelValue', props.multiple ? uniqWith(newValue as Base64String[], isEqual) : newValue)
168
+ }
169
+ }, { deep: true })
223
170
  </script>
224
171
 
225
172
  <template>
@@ -227,36 +174,28 @@ watch([assets, files], async () => {
227
174
  v-bind="$attrs"
228
175
  label="Upload files"
229
176
  readonly
230
- :dirty="isDirty"
231
- v-on="isDirty && !props.multiple ? {} : { click: openWindowUpload }"
177
+ :dirty="Array.isArray(combinedBase64String) ? combinedBase64String.length > 0 : !!combinedBase64String"
178
+ v-on="Array.isArray(combinedBase64String) && combinedBase64String.length > 0 ? {} : { click: openWindowUpload }"
232
179
  >
233
180
  <template #default>
234
- <!-- Server/asset items -->
235
181
  <v-chip
236
- v-for="(asset, index) in assets"
237
- :key="`${asset.id ?? asset.fileName}-${index}`"
182
+ v-for="(asset, index) in allAssets"
183
+ :key="asset.fileName"
238
184
  color="green"
239
185
  variant="flat"
240
186
  closable
241
187
  @click:close="removeAssetByIndex(index)"
242
188
  >
243
189
  {{ asset.originalFileName || asset.fileName }}
244
- <template #append v-if="props.downloadable">
190
+ <template #append v-if="downloadable">
245
191
  <slot name="download" :item="asset">
246
- <v-icon
247
- v-if="asset.base64String"
248
- @click.stop="downloadBase64File(asset.base64String, asset.originalFileName || asset.fileName)"
249
- >
250
- mdi mdi-download
251
- </v-icon>
192
+ <v-icon @click="downloadBase64File(asset.base64String || '',asset.originalFileName || asset.fileName)" v-if="asset.base64String">mdi mdi-download</v-icon>
252
193
  </slot>
253
194
  </template>
254
195
  </v-chip>
255
-
256
- <!-- Local uploaded files -->
257
196
  <v-chip
258
- v-for="(file, index) in files"
259
- :key="`${file.name}-${file.size}-${index}`"
197
+ v-for="(file, index) in allFiles"
198
+ :key="file.name"
260
199
  color="primary"
261
200
  variant="flat"
262
201
  closable
@@ -266,14 +205,12 @@ watch([assets, files], async () => {
266
205
  </v-chip>
267
206
  </template>
268
207
 
269
- <!-- Add more button for multi mode -->
270
- <template v-if="props.multiple && combinedArray.length > 0" #append-inner>
208
+ <template v-if="props.multiple && Array.isArray(combinedBase64String) && combinedBase64String.length > 0" #append-inner>
271
209
  <VBtn variant="text" :icon="true" @click="openWindowUpload">
272
210
  <v-icon>mdi mdi-plus</v-icon>
273
211
  </VBtn>
274
212
  </template>
275
213
  </v-text-field>
276
-
277
214
  <v-file-input
278
215
  ref="fileInput"
279
216
  :accept="props.accept"
@@ -281,4 +218,4 @@ watch([assets, files], async () => {
281
218
  style="display: none"
282
219
  @update:model-value="addFiles"
283
220
  />
284
- </template>
221
+ </template>
@@ -497,8 +497,6 @@ defineExpose({
497
497
  show-first-last-page
498
498
  @first="footerProps.setPage(1)"
499
499
  @last="footerProps.setPage(footerProps.pageCount)"
500
- @next="footerProps.nextPage"
501
- @prev="footerProps.prevPage"
502
500
  @update:model-value="footerProps.setPage"
503
501
  />
504
502
  </v-row>
@@ -34,6 +34,7 @@ interface Props {
34
34
  parentTemplates?: string|string[]
35
35
  dirtyClass?: string
36
36
  dirtyOnCreate?: boolean
37
+ sanitizeDelay?: number
37
38
  }
38
39
 
39
40
  const props = withDefaults(defineProps<Props>(), {
@@ -43,7 +44,8 @@ const props = withDefaults(defineProps<Props>(), {
43
44
  decoration: () => { return {} },
44
45
  parentTemplates: (): string[] => [],
45
46
  dirtyClass: "form-data-dirty",
46
- dirtyOnCreate: false
47
+ dirtyOnCreate: false,
48
+ sanitizeDelay: 2000,
47
49
  })
48
50
 
49
51
  const emit = defineEmits(['update:modelValue'])
@@ -87,7 +89,7 @@ function isBlankString(v: unknown): v is string {
87
89
  return isString(v) && v.trim().length === 0
88
90
  }
89
91
 
90
- const sanitizeBlankStrings = debounce(sanitizeBlankStringsRaw, 500)
92
+ const sanitizeBlankStrings = debounce(sanitizeBlankStringsRaw, props.sanitizeDelay)
91
93
 
92
94
  function sanitizeBlankStringsRaw(val: any, original?: any): void {
93
95
  if (!original && props.originalData) {
@@ -125,6 +127,71 @@ function sanitizeBlankStringsRaw(val: any, original?: any): void {
125
127
  }
126
128
  }
127
129
 
130
+ function autoSanitizedDisplay(item: any, separator: string = ","): string | undefined {
131
+ const isEmptyScalar = (v: any) =>
132
+ v === null ||
133
+ v === undefined ||
134
+ (typeof v === "string" && v.trim() === "") ||
135
+ (typeof v === "number" && Number.isNaN(v));
136
+
137
+ const toStr = (v: any): string | undefined => {
138
+ if (isEmptyScalar(v)) return undefined;
139
+ if (typeof v === "string") return v.trim();
140
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
141
+ if (typeof v === "symbol") return v.description ?? String(v);
142
+ if (typeof v === "function") return v.name ? `[Function ${v.name}]` : "[Function]";
143
+ return undefined;
144
+ };
145
+
146
+ // 1) empty -> undefined
147
+ if (isEmptyScalar(item)) return undefined;
148
+
149
+ // 2) array -> recurse + join
150
+ if (Array.isArray(item)) {
151
+ const parts = item
152
+ .map((x) => autoSanitizedDisplay(x, separator))
153
+ .filter((s): s is string => typeof s === "string" && s.trim() !== "");
154
+ return parts.length ? parts.join(separator) : undefined;
155
+ }
156
+
157
+ // simple scalars
158
+ const scalar = toStr(item);
159
+ if (scalar !== undefined) return scalar;
160
+
161
+ // 3) object
162
+ if (typeof item === "object") {
163
+ // 3.1 label
164
+ if ("label" in item) {
165
+ const v = autoSanitizedDisplay((item as any).label, separator) ?? toStr((item as any).label);
166
+ if (v !== undefined) return v;
167
+ }
168
+ // 3.2 value
169
+ if ("value" in item) {
170
+ const v = autoSanitizedDisplay((item as any).value, separator) ?? toStr((item as any).value);
171
+ if (v !== undefined) return v;
172
+ }
173
+
174
+ // 3.3 stringify attributes as key:value (recurse values)
175
+ const entries = Object.entries(item as Record<string, any>)
176
+ .map(([k, v]) => {
177
+ const rendered = autoSanitizedDisplay(v, separator) ?? toStr(v);
178
+ if (rendered === undefined || rendered.trim() === "") return undefined;
179
+ return `${k}:${rendered}`;
180
+ })
181
+ .filter((x): x is string => typeof x === "string" && x.trim() !== "");
182
+
183
+ return entries.length ? entries.join(separator) : undefined;
184
+ }
185
+
186
+ // fallback
187
+ try {
188
+ const s = String(item);
189
+ return s.trim() ? s : undefined;
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ }
194
+
128
195
  watch(formData, (newValue) => {
129
196
  sanitizeBlankStrings(newValue)
130
197
  emit('update:modelValue', newValue)
@@ -219,6 +286,7 @@ function buildFormComponent() {
219
286
  resetValidate: () => formPadTemplate.value.resetValidate(),
220
287
  isValid,
221
288
  ...templateScriptFunction.value(props, ctx, ...Object.values(vueFunctions)),
289
+ autoSanitizedDisplay
222
290
  }
223
291
  },
224
292
  template: componentTemplate,
@@ -279,6 +347,7 @@ defineExpose({
279
347
  :disabled="disabled"
280
348
  :readonly="readonly"
281
349
  :class="$attrs.class"
350
+ autocomplete="off"
282
351
  >
283
352
  <template #default="formProvided">
284
353
  <slot