@falcondev-oss/nuxt-layers-base 0.38.1 → 0.39.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/app/assets/css/utilities.css +4 -0
- package/app/components/modals/ConfirmModal.vue +65 -0
- package/app/components/modals/EntityCrudModal.vue +109 -0
- package/app/components/u/USwitchCard.vue +51 -0
- package/app/components/util/SlotRef.vue +17 -0
- package/app/components/util/TemplateText.vue +20 -0
- package/app/composables/confirm.ts +22 -0
- package/app/composables/usePreventPageLeave.ts +5 -4
- package/app/index.d.ts +7 -0
- package/app/utils/display.ts +15 -1
- package/nuxt.config.ts +7 -0
- package/package.json +1 -1
- package/app/composables/useConfirm.ts +0 -16
- /package/app/components/{Define.vue → util/Define.vue} +0 -0
- /package/app/components/{ForwardSlots.tsx → util/ForwardSlots.tsx} +0 -0
- /package/app/components/{Sync.vue → util/Sync.vue} +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts" generic="const O extends RadioGroupItem[], R extends ConfirmModalResult<O>">
|
|
2
|
+
import type { ButtonProps, RadioGroupItem, RadioGroupProps } from '@nuxt/ui'
|
|
3
|
+
import type { ComponentPublicInstance } from 'vue'
|
|
4
|
+
import type { ConfirmModalResult } from '~/composables/confirm'
|
|
5
|
+
import type { templateParts } from '~/utils/display'
|
|
6
|
+
|
|
7
|
+
export type ConfirmModalProps<O extends RadioGroupItem[]> = {
|
|
8
|
+
title: string
|
|
9
|
+
confirmLabel: string
|
|
10
|
+
confirmDisabled?: () => boolean
|
|
11
|
+
text?: string | ReturnType<typeof templateParts>
|
|
12
|
+
slotRef?: ComponentPublicInstance
|
|
13
|
+
options?: O
|
|
14
|
+
color?: ButtonProps['color']
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
defineProps<ConfirmModalProps<O>>()
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
close: [result: R]
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const selectedOption = ref<RadioGroupProps<O>['modelValue']>()
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<UModal
|
|
28
|
+
:title
|
|
29
|
+
:close="{ onClick: () => emit('close', false as R) }"
|
|
30
|
+
:dismissible="false"
|
|
31
|
+
:ui="{ header: 'border-b-0!', body: 'pt-0!' }"
|
|
32
|
+
>
|
|
33
|
+
<template #body>
|
|
34
|
+
<div class="flex flex-col gap-4">
|
|
35
|
+
<p v-if="text && typeof text === 'string'">{{ text }}</p>
|
|
36
|
+
<TemplateText v-else-if="text && typeof text === 'object'" :text />
|
|
37
|
+
|
|
38
|
+
<SlotRef :slot-ref />
|
|
39
|
+
|
|
40
|
+
<URadioGroup
|
|
41
|
+
v-model="selectedOption"
|
|
42
|
+
:items="options"
|
|
43
|
+
variant="table"
|
|
44
|
+
:color="color ?? 'error'"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
<template #footer>
|
|
49
|
+
<div class="flex w-full justify-end gap-2">
|
|
50
|
+
<UButton
|
|
51
|
+
color="neutral"
|
|
52
|
+
variant="soft"
|
|
53
|
+
label="Abbrechen"
|
|
54
|
+
@click="() => emit('close', false as R)"
|
|
55
|
+
/>
|
|
56
|
+
<UButton
|
|
57
|
+
:color="color ?? 'error'"
|
|
58
|
+
:label="confirmLabel"
|
|
59
|
+
:disabled="(!!options?.length && selectedOption === undefined) || confirmDisabled?.()"
|
|
60
|
+
@click="() => emit('close', (selectedOption ?? true) as R)"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
</UModal>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script
|
|
2
|
+
setup
|
|
3
|
+
lang="ts"
|
|
4
|
+
generic="
|
|
5
|
+
Schema extends FormSchema,
|
|
6
|
+
QueryData extends NonNullable<FormSourceValues<Schema>>,
|
|
7
|
+
Select extends QueryData = QueryData,
|
|
8
|
+
Id extends string | null = string | null
|
|
9
|
+
"
|
|
10
|
+
>
|
|
11
|
+
import type { FormSchema, FormSourceValues, FormSubmitValues } from '@falcondev-oss/form-core'
|
|
12
|
+
import type { DefaultError, UseQueryOptions } from '@tanstack/vue-query'
|
|
13
|
+
import type { If, IsNever } from 'type-fest'
|
|
14
|
+
import { useForm } from '@falcondev-oss/form-vue'
|
|
15
|
+
import { useQuery } from '@tanstack/vue-query'
|
|
16
|
+
import modalTheme from '#build/ui/modal'
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
id: Id
|
|
20
|
+
name: string
|
|
21
|
+
schema: Schema
|
|
22
|
+
getQueryOptions: (
|
|
23
|
+
id: Id,
|
|
24
|
+
) => UseQueryOptions<If<IsNever<Select>, QueryData, any>, DefaultError, Select>
|
|
25
|
+
newValues: NonNullable<FormSourceValues<Schema>>
|
|
26
|
+
mutate: (values: FormSubmitValues<Schema> & { id: Id }) => Promise<unknown>
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
close: []
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
defineSlots<{
|
|
34
|
+
default: (props: { form: ReturnType<typeof useForm<Schema>>; data: typeof data.value }) => any
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
const toast = useToast()
|
|
38
|
+
const appConfig = useAppConfig()
|
|
39
|
+
|
|
40
|
+
const { data, isLoading } = useQuery(computed(() => props.getQueryOptions(props.id)))
|
|
41
|
+
|
|
42
|
+
const form = useForm({
|
|
43
|
+
schema: props.schema,
|
|
44
|
+
sourceValues: () => {
|
|
45
|
+
if (isLoading.value) return
|
|
46
|
+
|
|
47
|
+
return data.value ?? props.newValues
|
|
48
|
+
},
|
|
49
|
+
async submit({ values }) {
|
|
50
|
+
await props.mutate({
|
|
51
|
+
...values,
|
|
52
|
+
id: props.id,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
emit('close')
|
|
56
|
+
toast.add({
|
|
57
|
+
title: `${props.name} ${props.id ? 'aktualisiert' : 'erstellt'}`,
|
|
58
|
+
color: 'success',
|
|
59
|
+
icon: 'lucide:check',
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const formActionsTeleportId = useId()
|
|
65
|
+
const closeHandle = ref<(() => void) | null>(null)
|
|
66
|
+
|
|
67
|
+
// const { tryLeave, onLeave } = usePreventLeave()
|
|
68
|
+
// onLeave(() => {
|
|
69
|
+
// console.warn('leave')
|
|
70
|
+
// closeHandle.value?.()
|
|
71
|
+
// })
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<UModal :close="false" :title="`${name} ${id ? 'bearbeiten' : 'erstellen'}`" :dismissible="false">
|
|
76
|
+
<template #actions>
|
|
77
|
+
<UButton
|
|
78
|
+
:class="modalTheme.slots.close"
|
|
79
|
+
:icon="appConfig.ui.icons.close"
|
|
80
|
+
color="neutral"
|
|
81
|
+
variant="ghost"
|
|
82
|
+
aria-label="Schließen"
|
|
83
|
+
loading-auto
|
|
84
|
+
@click="
|
|
85
|
+
async () => {
|
|
86
|
+
// if (!(await tryLeave())) return
|
|
87
|
+
closeHandle?.()
|
|
88
|
+
}
|
|
89
|
+
"
|
|
90
|
+
/>
|
|
91
|
+
</template>
|
|
92
|
+
<template #body="{ close }">
|
|
93
|
+
<Sync v-model:output="closeHandle" :input="close" />
|
|
94
|
+
<UForm
|
|
95
|
+
class="flex flex-col gap-2"
|
|
96
|
+
:form
|
|
97
|
+
:submit-label="id ? 'Speichern' : 'Erstellen'"
|
|
98
|
+
:actions-teleport-to="`#${formActionsTeleportId}`"
|
|
99
|
+
>
|
|
100
|
+
<slot :form :data />
|
|
101
|
+
</UForm>
|
|
102
|
+
</template>
|
|
103
|
+
<template #footer>
|
|
104
|
+
<div :id="formActionsTeleportId" class="flex w-full justify-end gap-2">
|
|
105
|
+
<UButton color="neutral" variant="soft" label="Abbrechen" @click="() => emit('close')" />
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
</UModal>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
title: string
|
|
4
|
+
icon?: string
|
|
5
|
+
loading?: boolean
|
|
6
|
+
alwaysExpanded?: boolean
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
toggle: [value: boolean, controller: AbortController]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
defineSlots<{
|
|
14
|
+
default: () => any
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const model = defineModel<boolean>({ required: true })
|
|
18
|
+
|
|
19
|
+
const expanded = computed(() => props.alwaysExpanded || model.value)
|
|
20
|
+
|
|
21
|
+
function onToggle(value: boolean) {
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
emit('toggle', value, controller)
|
|
24
|
+
if (controller.signal.aborted) return
|
|
25
|
+
|
|
26
|
+
model.value = value
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UCard
|
|
32
|
+
class="m-px"
|
|
33
|
+
variant="subtle"
|
|
34
|
+
:ui="{
|
|
35
|
+
header: `text-sm font-semibold py-2 px-3! ${expanded ? '' : 'border-b-0'}`,
|
|
36
|
+
body: `p-3! ${expanded ? '' : 'hidden'}`,
|
|
37
|
+
}"
|
|
38
|
+
>
|
|
39
|
+
<template #header>
|
|
40
|
+
<div class="flex items-center justify-between gap-8">
|
|
41
|
+
<span class="flex items-center gap-2">
|
|
42
|
+
<UIcon v-if="icon" :name="icon" class="text-muted size-5" />
|
|
43
|
+
{{ title }}
|
|
44
|
+
</span>
|
|
45
|
+
<USwitch :model-value="model" :loading @update:model-value="(value) => onToggle(value)" />
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<slot />
|
|
50
|
+
</UCard>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ComponentPublicInstance } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
slotRef?: ComponentPublicInstance
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
defineSlots<{
|
|
9
|
+
default: () => any
|
|
10
|
+
}>()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<component :is="slotRef.$slots.default" v-if="slotRef" />
|
|
15
|
+
<!-- eslint-disable-next-line vue/no-lone-template -->
|
|
16
|
+
<template v-else />
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { templateParts } from '~/utils/display'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
text: ReturnType<typeof templateParts>
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<p>
|
|
11
|
+
<span v-for="(string, index) in text.strings" :key="index">
|
|
12
|
+
<span>{{ string }}</span>
|
|
13
|
+
<strong v-if="text.values[index]" class="font-semibold">
|
|
14
|
+
<UBadge variant="soft" color="neutral" size="lg" class="h-6! p-1! font-semibold">
|
|
15
|
+
{{ text.values[index] }}
|
|
16
|
+
</UBadge>
|
|
17
|
+
</strong>
|
|
18
|
+
</span>
|
|
19
|
+
</p>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RadioGroupItem } from '@nuxt/ui'
|
|
2
|
+
import type { ConfirmModalProps } from '~/components/modals/ConfirmModal.vue'
|
|
3
|
+
import { LazyConfirmModal } from '#components'
|
|
4
|
+
|
|
5
|
+
export type ConfirmModalSuccess<O extends RadioGroupItem[]> = 0 extends O['length']
|
|
6
|
+
? true
|
|
7
|
+
: {
|
|
8
|
+
[K in keyof O]: O[K] extends { value: infer V } ? V : never
|
|
9
|
+
}[number]
|
|
10
|
+
|
|
11
|
+
export type ConfirmModalResult<O extends RadioGroupItem[]> = false | ConfirmModalSuccess<O>
|
|
12
|
+
|
|
13
|
+
export const useConfirm = createGlobalState(() => {
|
|
14
|
+
const overlay = useOverlay()
|
|
15
|
+
|
|
16
|
+
return async <const O extends RadioGroupItem[]>(props: ConfirmModalProps<O>) =>
|
|
17
|
+
overlay
|
|
18
|
+
.create(LazyConfirmModal, {
|
|
19
|
+
destroyOnClose: true,
|
|
20
|
+
})
|
|
21
|
+
.open(props).result as Promise<ConfirmModalResult<O>>
|
|
22
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { onBeforeRouteLeave } from '#app'
|
|
2
|
-
import { useConfirm } from './
|
|
2
|
+
import { useConfirm } from './confirm'
|
|
3
3
|
|
|
4
4
|
export function usePreventPageLeave(
|
|
5
5
|
preventPageLeave: MaybeRefOrGetter<boolean> = true,
|
|
@@ -10,6 +10,7 @@ export function usePreventPageLeave(
|
|
|
10
10
|
) {
|
|
11
11
|
useEventListener('beforeunload', (event) => {
|
|
12
12
|
if (!toValue(preventPageLeave)) return
|
|
13
|
+
// eslint-disable-next-line ts/no-unsafe-member-access
|
|
13
14
|
event.returnValue = opts?.leaveDescription
|
|
14
15
|
return opts?.leaveDescription
|
|
15
16
|
})
|
|
@@ -19,11 +20,11 @@ export function usePreventPageLeave(
|
|
|
19
20
|
onBeforeRouteLeave(async (_, __, next) => {
|
|
20
21
|
if (!toValue(preventPageLeave)) return next()
|
|
21
22
|
|
|
22
|
-
const allowLeave = await confirm
|
|
23
|
+
const allowLeave = await confirm({
|
|
23
24
|
title: opts?.leaveTitle || 'Ungespeicherte Änderungen',
|
|
24
|
-
|
|
25
|
+
text:
|
|
25
26
|
opts?.leaveDescription || 'Es gibt ungespeicherte Änderungen. Seite trotzdem verlassen?',
|
|
26
|
-
|
|
27
|
+
confirmLabel: 'Verlassen',
|
|
27
28
|
})
|
|
28
29
|
if (allowLeave) return next()
|
|
29
30
|
})
|
package/app/index.d.ts
ADDED
package/app/utils/display.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function formatEuros(amount: number) {
|
|
2
2
|
return new Intl.NumberFormat('de-DE', {
|
|
3
3
|
style: 'currency',
|
|
4
4
|
currency: 'EUR',
|
|
5
5
|
}).format(amount)
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
export function pluralize(count: number, singular: string, plural: string) {
|
|
9
|
+
return count === 1 ? singular : plural
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function templateParts(
|
|
13
|
+
strings: TemplateStringsArray,
|
|
14
|
+
...values: unknown[]
|
|
15
|
+
): { strings: string[]; values: string[] } {
|
|
16
|
+
return {
|
|
17
|
+
strings: [...strings],
|
|
18
|
+
values: values.map(String),
|
|
19
|
+
}
|
|
20
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -43,6 +43,13 @@ export default defineNuxtConfig({
|
|
|
43
43
|
strict: true,
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
|
+
components: [
|
|
47
|
+
{
|
|
48
|
+
// layer configs resolve `~` against the consuming project, so use absolute paths
|
|
49
|
+
path: path.join(currentDir, './app/components'),
|
|
50
|
+
pathPrefix: false,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
46
53
|
imports: {
|
|
47
54
|
dirs: [
|
|
48
55
|
// layer configs resolve `~` against the consuming project, so use absolute paths
|
package/package.json
CHANGED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { OverlayModalConfirm } from '#components'
|
|
2
|
-
|
|
3
|
-
export const useConfirm = createGlobalState(() => {
|
|
4
|
-
const overlay = useOverlay()
|
|
5
|
-
|
|
6
|
-
return {
|
|
7
|
-
confirmDestructive: async (props: {
|
|
8
|
-
description: string
|
|
9
|
-
submitLabel: string
|
|
10
|
-
title: string
|
|
11
|
-
}) => {
|
|
12
|
-
const modal = overlay.create(OverlayModalConfirm)
|
|
13
|
-
return modal.open(props).result as Promise<boolean>
|
|
14
|
-
},
|
|
15
|
-
}
|
|
16
|
-
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|