@edgedev/create-edge-app 1.1.28 → 1.2.29

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.
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
- import { computed, defineProps, inject, nextTick, onBeforeMount, reactive, watch } from 'vue'
2
+ import { computed, defineProps, inject, nextTick, onBeforeMount, reactive, ref, watch } from 'vue'
3
+ import { toTypedSchema } from '@vee-validate/zod'
3
4
  import { useToast } from '@/components/ui/toast/use-toast'
4
5
  const props = defineProps({
5
6
  metaFields: {
@@ -15,7 +16,9 @@ const props = defineProps({
15
16
  const { toast } = useToast()
16
17
 
17
18
  const edgeFirebase = inject('edgeFirebase')
18
- // const edgeGlobal = inject('edgeGlobal')
19
+ const edgeGlobal = inject('edgeGlobal')
20
+
21
+ const formRef = ref(null)
19
22
 
20
23
  const state = reactive({
21
24
  meta: {},
@@ -24,10 +27,142 @@ const state = reactive({
24
27
  loaded: true,
25
28
  loading: false,
26
29
  })
27
- const onSubmit = async () => {
30
+
31
+ const cloneMeta = (meta) => {
32
+ if (edgeGlobal?.dupObject)
33
+ return edgeGlobal.dupObject(meta || {})
34
+ return JSON.parse(JSON.stringify(meta || {}))
35
+ }
36
+
37
+ const disabledNoteText = 'Contact admin to change.'
38
+
39
+ const getDisabledNote = (field) => {
40
+ if (!field?.disabled)
41
+ return ''
42
+ return field?.disabledNote || disabledNoteText
43
+ }
44
+
45
+ const mergeDisabledNote = (text, field) => {
46
+ const note = getDisabledNote(field)
47
+ if (!note)
48
+ return text || ''
49
+ if (text)
50
+ return `${text} ${note}`
51
+ return note
52
+ }
53
+
54
+ const WIDTHS = {
55
+ 1: 'md:col-span-1',
56
+ 2: 'md:col-span-2',
57
+ 3: 'md:col-span-3',
58
+ 4: 'md:col-span-4',
59
+ 5: 'md:col-span-5',
60
+ 6: 'md:col-span-6',
61
+ 7: 'md:col-span-7',
62
+ 8: 'md:col-span-8',
63
+ 9: 'md:col-span-9',
64
+ 10: 'md:col-span-10',
65
+ 11: 'md:col-span-11',
66
+ 12: 'md:col-span-12',
67
+ }
68
+
69
+ const numColsToTailwind = cols => WIDTHS[cols] || 'md:col-span-12'
70
+
71
+ const resolvedSchema = computed(() => {
72
+ if (!props.formSchema)
73
+ return undefined
74
+ if (props.formSchema?.safeParse && props.formSchema?._def)
75
+ return toTypedSchema(props.formSchema)
76
+ return props.formSchema
77
+ })
78
+
79
+ const defaultFields = [
80
+ { field: 'profilephoto', value: '' },
81
+ { field: 'name', value: '' },
82
+ { field: 'phone', value: '' },
83
+ ]
84
+
85
+ const filteredMetaFields = computed(() => {
86
+ const skipFields = new Set(['profilephoto', 'name', 'phone', 'email', 'role'])
87
+ return (props.metaFields || []).filter(field => !skipFields.has(field.field))
88
+ })
89
+
90
+ const initializeDefaults = (meta) => {
91
+ defaultFields.forEach((field) => {
92
+ if (!(field.field in meta))
93
+ meta[field.field] = field.value
94
+ })
95
+ filteredMetaFields.value.forEach((field) => {
96
+ const current = getByPath(meta, field.field, undefined)
97
+ if (current === undefined)
98
+ setByPath(meta, field.field, field.value)
99
+ })
100
+ }
101
+
102
+ // Helpers to read/write nested keys like "profile.firstName" on plain objects
103
+ function getByPath(obj, path, fallback = undefined) {
104
+ if (!obj || !path)
105
+ return fallback
106
+ const parts = String(path).split('.')
107
+ let cur = obj
108
+ for (const p of parts) {
109
+ if (cur == null || typeof cur !== 'object' || !(p in cur))
110
+ return fallback
111
+ cur = cur[p]
112
+ }
113
+ return cur
114
+ }
115
+
116
+ function setByPath(obj, path, value) {
117
+ if (!obj || !path)
118
+ return
119
+ const parts = String(path).split('.')
120
+ let cur = obj
121
+ for (let i = 0; i < parts.length; i++) {
122
+ const key = parts[i]
123
+ if (i === parts.length - 1) {
124
+ cur[key] = value
125
+ }
126
+ else {
127
+ if (cur[key] == null || typeof cur[key] !== 'object')
128
+ cur[key] = {}
129
+ cur = cur[key]
130
+ }
131
+ }
132
+ }
133
+
134
+ const mergeDeep = (target, source) => {
135
+ if (!source || typeof source !== 'object')
136
+ return target
137
+ Object.keys(source).forEach((key) => {
138
+ const value = source[key]
139
+ if (Array.isArray(value)) {
140
+ target[key] = [...value]
141
+ return
142
+ }
143
+ if (value && typeof value === 'object') {
144
+ if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key]))
145
+ target[key] = {}
146
+ mergeDeep(target[key], value)
147
+ return
148
+ }
149
+ target[key] = value
150
+ })
151
+ return target
152
+ }
153
+
154
+ const syncFormValues = (meta) => {
155
+ if (formRef.value?.setValues)
156
+ formRef.value.setValues(meta || {})
157
+ }
158
+
159
+ const onSubmit = async (values = {}) => {
28
160
  state.loading = true
29
- await edgeFirebase.setUserMeta(state.meta)
30
- edgeGlobal.edgeState.changeTracker = {}
161
+ const mergedMeta = mergeDeep(cloneMeta(state.meta), values || {})
162
+ state.meta = mergedMeta
163
+ await edgeFirebase.setUserMeta(mergedMeta)
164
+ if (edgeGlobal?.edgeState)
165
+ edgeGlobal.edgeState.changeTracker = {}
31
166
  state.loaded = false
32
167
  toast({
33
168
  title: 'Updated Successfully',
@@ -44,79 +179,228 @@ const currentMeta = computed(() => {
44
179
  })
45
180
 
46
181
  onBeforeMount(() => {
47
- state.meta = currentMeta.value
48
- props.metaFields.forEach((field) => {
49
- if (!(field.field in state.meta)) {
50
- state.meta[field.field] = field.value
51
- }
52
- })
182
+ state.meta = cloneMeta(currentMeta.value)
183
+ initializeDefaults(state.meta)
184
+ syncFormValues(state.meta)
53
185
  })
54
186
 
55
187
  watch(currentMeta, async () => {
56
- state.meta = currentMeta.value
57
- edgeGlobal.edgeState.changeTracker = {}
188
+ state.meta = cloneMeta(currentMeta.value)
189
+ initializeDefaults(state.meta)
190
+ syncFormValues(state.meta)
191
+ if (edgeGlobal?.edgeState)
192
+ edgeGlobal.edgeState.changeTracker = {}
58
193
  state.loaded = false
59
194
  await nextTick()
60
195
  state.loaded = true
61
196
  })
62
197
  const route = useRoute()
198
+ const menuIcon = computed(() => {
199
+ if (edgeGlobal?.iconFromMenu)
200
+ return edgeGlobal.iconFromMenu(route)
201
+ return null
202
+ })
63
203
  </script>
64
204
 
65
205
  <template>
66
206
  <Card class="w-full flex-1 bg-muted/50 mx-auto w-full border-none shadow-none pt-2">
67
- <slot name="header">
68
- <edge-menu class="bg-secondary text-foreground rounded-none sticky top-0 py-6">
69
- <template #start>
70
- <slot name="header-start">
71
- <component :is="edgeGlobal.iconFromMenu(route)" class="mr-2" />
72
- <span class="capitalize">My Profile</span>
73
- </slot>
74
- </template>
75
- <template #center>
76
- <slot name="header-center">
77
- <div class="w-full px-6" />
78
- </slot>
79
- </template>
80
- <template #end>
81
- <slot name="header-end">
82
- <div />
83
- </slot>
84
- </template>
85
- </edge-menu>
86
- </slot>
87
- <CardContent v-if="state.loaded" class="p-3 w-full overflow-y-auto scroll-area">
88
- <edge-shad-form
89
- v-model="state.form"
90
- :schema="props.formSchema"
91
- @submit="onSubmit"
92
- >
93
- <CardContent>
94
- <div v-for="field in props.metaFields" :key="field.field" class="mb-3">
95
- <edge-g-input
96
- v-model="state.meta[field.field]"
97
- :name="field.field"
98
- :field-type="field.type"
99
- :label="field.label"
100
- parent-tracker-id="profile-settings"
101
- :hint="field.hint"
102
- />
207
+ <edge-shad-form
208
+ ref="formRef"
209
+ :schema="resolvedSchema"
210
+ :initial-values="state.meta"
211
+ @submit="onSubmit"
212
+ >
213
+ <slot name="header">
214
+ <edge-menu class="bg-secondary text-foreground rounded-none sticky top-0 py-3">
215
+ <template #start>
216
+ <slot name="header-start">
217
+ <component :is="menuIcon" class="mr-2" />
218
+ <span class="capitalize">My Profile</span>
219
+ </slot>
220
+ </template>
221
+ <template #center>
222
+ <slot name="header-center">
223
+ <div class="w-full px-6" />
224
+ </slot>
225
+ </template>
226
+ <template #end>
227
+ <slot name="header-end">
228
+ <edge-shad-button
229
+ type="submit"
230
+ :disabled="state.loading"
231
+ class="text-white bg-slate-800 hover:bg-slate-400"
232
+ >
233
+ <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
234
+ Update Profile
235
+ </edge-shad-button>
236
+ </slot>
237
+ </template>
238
+ </edge-menu>
239
+ </slot>
240
+ <CardContent v-if="state.loaded" class="p-3 w-full overflow-y-auto scroll-area">
241
+ <CardContent class="space-y-6">
242
+ <div class="rounded-xl border bg-card p-4 space-y-4 shadow-sm">
243
+ <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
244
+ Profile Details
245
+ </div>
246
+ <div class="flex flex-col gap-4 md:flex-row md:items-stretch">
247
+ <div class="w-full md:w-[260px] self-stretch">
248
+ <edge-image-picker
249
+ v-model="state.meta.profilephoto"
250
+ label="Profile Photo"
251
+ dialog-title="Select Profile Photo"
252
+ :site="`${clearwater - hub - images}-${edgeFirebase.user.uid}`"
253
+ :default-tags="['Headshots']"
254
+ height-class="h-full min-h-[180px]"
255
+ :include-cms-all="false"
256
+ />
257
+ </div>
258
+ <div class="flex-1 space-y-4">
259
+ <edge-shad-input
260
+ v-model="state.meta.name"
261
+ name="name"
262
+ label="Name"
263
+ />
264
+ <edge-shad-input
265
+ v-model="state.meta.email"
266
+ name="email"
267
+ label="Email"
268
+ disabled
269
+ description="This field is tied to your username and can only be updated on the Account page."
270
+ />
271
+ <edge-shad-input
272
+ v-model="state.meta.phone"
273
+ name="phone"
274
+ label="Phone"
275
+ :mask-options="{ mask: '(###) ###-####' }"
276
+ />
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ <div class="rounded-xl border bg-card p-4 space-y-4 shadow-sm">
282
+ <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
283
+ Additional Details
284
+ </div>
285
+ <div class="grid grid-cols-12 gap-4">
286
+ <div
287
+ v-for="field in filteredMetaFields"
288
+ :key="field.field"
289
+ class="col-span-12 mb-3"
290
+ :class="numColsToTailwind(field.cols)"
291
+ >
292
+ <edge-image-picker
293
+ v-if="field?.type === 'imagePicker'"
294
+ :model-value="getByPath(state.meta, field.field, '')"
295
+ :label="field?.label || 'Photo'"
296
+ :dialog-title="field?.dialogTitle || 'Select Image'"
297
+ :site="field?.site || 'clearwater-hub-images'"
298
+ :include-cms-all="false"
299
+ :default-tags="field?.tags || []"
300
+ :height-class="field?.heightClass || 'h-[160px]'"
301
+ :disabled="field?.disabled || false"
302
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
303
+ />
304
+ <p v-if="field?.type === 'imagePicker' && field?.disabled" class="mt-1 text-xs text-muted-foreground">
305
+ {{ getDisabledNote(field) }}
306
+ </p>
307
+ <div v-else-if="field?.type === 'richText'" class="profile-richtext">
308
+ <edge-shad-html
309
+ :model-value="getByPath(state.meta, field.field, '')"
310
+ :name="field.field"
311
+ :label="field?.label"
312
+ :disabled="field?.disabled || false"
313
+ :description="mergeDisabledNote(field?.description, field)"
314
+ :enabled-toggles="field?.enabledToggles || ['bold', 'italic', 'strike', 'bulletlist', 'orderedlist', 'underline']"
315
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
316
+ />
317
+ </div>
318
+ <edge-shad-select-tags
319
+ v-else-if="field?.type === 'selectTags'"
320
+ :model-value="getByPath(state.meta, field.field, [])"
321
+ :name="field.field"
322
+ :label="field?.label"
323
+ :description="mergeDisabledNote(field?.description, field)"
324
+ :items="field?.items || []"
325
+ :item-title="field?.itemTitle || 'title'"
326
+ :item-value="field?.itemValue || 'name'"
327
+ :allow-additions="field?.allowAdditions || false"
328
+ :placeholder="field?.placeholder"
329
+ :disabled="field?.disabled || false"
330
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
331
+ />
332
+ <div v-else-if="field?.type === 'boolean'" class="space-y-1 -mt-3">
333
+ <div class="text-sm font-medium leading-none opacity-0 select-none h-4">
334
+ {{ field?.label || '' }}
335
+ </div>
336
+ <edge-g-input
337
+ :model-value="getByPath(state.meta, field.field, false)"
338
+ :name="field.field"
339
+ :field-type="field?.type"
340
+ :label="field?.label"
341
+ parent-tracker-id="profile-settings"
342
+ :disable-tracking="true"
343
+ :disabled="field?.disabled || false"
344
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
345
+ />
346
+ <p v-if="mergeDisabledNote(field?.hint, field)" class="text-xs text-muted-foreground">
347
+ {{ mergeDisabledNote(field?.hint, field) }}
348
+ </p>
349
+ </div>
350
+ <edge-g-input
351
+ v-else-if="field?.type === 'textarea'"
352
+ :model-value="getByPath(state.meta, field.field, '')"
353
+ :name="field.field"
354
+ :field-type="field?.type"
355
+ :label="field?.label"
356
+ parent-tracker-id="profile-settings"
357
+ :hint="mergeDisabledNote(field?.hint, field)"
358
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
359
+ :disable-tracking="true"
360
+ :bindings="{ class: 'h-60' }"
361
+ :mask-options="field?.maskOptions"
362
+ :disabled="field?.disabled || false"
363
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
364
+ />
365
+ <edge-shad-tags
366
+ v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
367
+ :model-value="getByPath(state.meta, field.field, '')"
368
+ :name="field.field"
369
+ :label="field?.label"
370
+ parent-tracker-id="profile-settings"
371
+ :description="mergeDisabledNote(field?.description || field?.hint, field)"
372
+ :disable-tracking="true"
373
+ :disabled="field?.disabled || false"
374
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
375
+ />
376
+ <edge-shad-input
377
+ v-else
378
+ :model-value="getByPath(state.meta, field.field, '')"
379
+ :name="field.field"
380
+ :type="field?.type === 'number' ? 'number' : 'text'"
381
+ :label="field?.label"
382
+ :mask-options="field?.maskOptions"
383
+ :description="mergeDisabledNote(field?.description, field)"
384
+ :disabled="field?.disabled || false"
385
+ @update:model-value="val => setByPath(state.meta, field.field, val)"
386
+ />
387
+ </div>
388
+ </div>
103
389
  </div>
104
390
  </CardContent>
105
- <CardFooter>
106
- <edge-shad-button
107
- type="submit"
108
- :disabled="state.loading"
109
- class="text-white bg-slate-800 hover:bg-slate-400"
110
- >
111
- <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
112
- Save
113
- </edge-shad-button>
114
- </CardFooter>
115
- </edge-shad-form>
116
- </CardContent>
391
+ </CardContent>
392
+ </edge-shad-form>
117
393
  </Card>
118
394
  </template>
119
395
 
120
396
  <style lang="scss" scoped>
397
+ :deep(.profile-richtext .tiptap) {
398
+ min-height: 220px;
399
+ padding: 0.75rem 1rem;
400
+ }
121
401
 
402
+ :deep(.profile-richtext .tiptap p) {
403
+ margin-top: 0;
404
+ margin-bottom: 1rem;
405
+ }
122
406
  </style>