@falcondev-oss/nuxt-layers-base 0.38.0 → 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/base.css +4 -0
- package/app/assets/css/theme.css +1 -0
- package/app/assets/css/utilities.css +4 -0
- package/app/assets/css/variants.css +3 -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/app/utils/plugins/helper/request-error.ts +82 -0
- package/app/utils/plugins/index.ts +3 -0
- package/app/utils/plugins/trpc.ts +120 -0
- package/app/utils/plugins/vue-query.ts +131 -0
- package/nuxt.config.ts +16 -0
- package/package.json +1 -1
- package/app/composables/useConfirm.ts +0 -16
- package/app/utils/plugins.ts +0 -295
- /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
package/app/assets/css/base.css
CHANGED
package/app/assets/css/theme.css
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--color-brand-secondary-900: color-mix(in oklch, var(--color-brand-secondary), black 55%);
|
|
24
24
|
--color-brand-secondary-950: color-mix(in oklch, var(--color-brand-secondary), black 64%);
|
|
25
25
|
|
|
26
|
+
/* force usage of Nuxt UI gray classes */
|
|
26
27
|
--color-slate-*: initial;
|
|
27
28
|
--color-gray-*: initial;
|
|
28
29
|
--color-zinc-*: initial;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { TRPCClientError } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
3
|
+
import type { ToastOptions } from '../../../composables/useToast'
|
|
4
|
+
import { isTRPCClientError } from '@trpc/client'
|
|
5
|
+
|
|
6
|
+
export interface ToastOpts {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
12
|
+
function isNetworkError(err: unknown) {
|
|
13
|
+
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
17
|
+
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
18
|
+
return err.data as TRPCDefaultErrorData | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
22
|
+
|
|
23
|
+
export function isRetryableError(err: unknown) {
|
|
24
|
+
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
25
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
26
|
+
|
|
27
|
+
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
31
|
+
function isSchemaIssueList(message: string) {
|
|
32
|
+
if (!message.startsWith('[')) return false
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const issues = JSON.parse(message) as unknown[]
|
|
36
|
+
return (
|
|
37
|
+
issues.length > 0 &&
|
|
38
|
+
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
39
|
+
)
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
46
|
+
BAD_REQUEST: 'Ungültige Eingabe',
|
|
47
|
+
UNAUTHORIZED: 'Nicht angemeldet',
|
|
48
|
+
FORBIDDEN: 'Keine Berechtigung',
|
|
49
|
+
NOT_FOUND: 'Nicht vorhanden',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requestErrorToast(err: unknown): ToastOpts {
|
|
53
|
+
if (isNetworkError(err))
|
|
54
|
+
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
55
|
+
|
|
56
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
57
|
+
|
|
58
|
+
// internal errors leak implementation details and mean nothing to the user
|
|
59
|
+
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
60
|
+
|
|
61
|
+
const code = errorData(err)?.code
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
65
|
+
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
70
|
+
export function toastAdd(opts: ToastOptions) {
|
|
71
|
+
if (import.meta.server) return
|
|
72
|
+
|
|
73
|
+
useToast().add({ duration: 5000, ...opts })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
77
|
+
export function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
78
|
+
toastAdd({
|
|
79
|
+
preset: 'error',
|
|
80
|
+
...(opts ?? requestErrorToast(err)),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OperationLink, TRPCLink } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter } from '@trpc/server'
|
|
3
|
+
import type { FetchOptions } from 'ofetch'
|
|
4
|
+
import type { ObjectPlugin } from '#app'
|
|
5
|
+
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
6
|
+
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
7
|
+
import { useQueryClient } from '@tanstack/vue-query'
|
|
8
|
+
import { httpSubscriptionLink, splitLink } from '@trpc/client'
|
|
9
|
+
import { observable } from '@trpc/server/observable'
|
|
10
|
+
import superjsonDefault from 'superjson'
|
|
11
|
+
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
12
|
+
import { toastRequestError } from './helper/request-error'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
16
|
+
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
17
|
+
* from the query/mutation cache instead.
|
|
18
|
+
*/
|
|
19
|
+
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
20
|
+
observable((observer) => {
|
|
21
|
+
const subscription = next(op).subscribe({
|
|
22
|
+
next: (value) => observer.next(value),
|
|
23
|
+
complete: () => observer.complete(),
|
|
24
|
+
error(err) {
|
|
25
|
+
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
26
|
+
console.error(err)
|
|
27
|
+
toastRequestError(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
observer.error(err)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
subscription.unsubscribe()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
39
|
+
|
|
40
|
+
interface TrpcNuxtPluginOptions {
|
|
41
|
+
url: string
|
|
42
|
+
/**
|
|
43
|
+
* ofetch options passed to the HTTP links.
|
|
44
|
+
* @see https://github.com/unjs/ofetch
|
|
45
|
+
*/
|
|
46
|
+
fetchOptions?: FetchOptions
|
|
47
|
+
/**
|
|
48
|
+
* ofetch options for queries, merged over `fetchOptions`.
|
|
49
|
+
*/
|
|
50
|
+
queryFetchOptions?: FetchOptions
|
|
51
|
+
/**
|
|
52
|
+
* ofetch options for mutations, merged over `fetchOptions`.
|
|
53
|
+
*/
|
|
54
|
+
mutationFetchOptions?: FetchOptions
|
|
55
|
+
/**
|
|
56
|
+
* Custom superjson instance, e.g. one with registered custom transformers.
|
|
57
|
+
* @default superjson
|
|
58
|
+
*/
|
|
59
|
+
transformer?: typeof superjsonDefault
|
|
60
|
+
/**
|
|
61
|
+
* Batching options passed to the `httpBatchLink`.
|
|
62
|
+
*/
|
|
63
|
+
batchOptions?: {
|
|
64
|
+
maxURLLength?: number
|
|
65
|
+
maxItems?: number
|
|
66
|
+
methodOverride?: 'POST'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'trpc',
|
|
72
|
+
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
73
|
+
dependsOn: ['vue-query'] as any,
|
|
74
|
+
setup() {
|
|
75
|
+
const queryClient = useQueryClient()
|
|
76
|
+
const headers = useRequestHeaders()
|
|
77
|
+
const superjson = opts.transformer ?? superjsonDefault
|
|
78
|
+
|
|
79
|
+
const trpc = createTRPCVueQueryClient<Router>({
|
|
80
|
+
queryClient,
|
|
81
|
+
trpc: {
|
|
82
|
+
links: [
|
|
83
|
+
requestErrorToastLink,
|
|
84
|
+
typedFormDataLink<AnyTRPCRouter>({ transformer: superjson }),
|
|
85
|
+
splitLink({
|
|
86
|
+
condition: (op) => op.type === 'subscription',
|
|
87
|
+
true: httpSubscriptionLink({
|
|
88
|
+
url: opts.url,
|
|
89
|
+
transformer: superjson,
|
|
90
|
+
}),
|
|
91
|
+
false: splitLink({
|
|
92
|
+
condition: (op) => op.type === 'mutation',
|
|
93
|
+
true: httpLink({
|
|
94
|
+
transformer: superjson,
|
|
95
|
+
url: opts.url,
|
|
96
|
+
headers,
|
|
97
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.mutationFetchOptions },
|
|
98
|
+
}),
|
|
99
|
+
false: httpBatchLink({
|
|
100
|
+
transformer: superjson,
|
|
101
|
+
url: opts.url,
|
|
102
|
+
headers,
|
|
103
|
+
maxURLLength: 2000,
|
|
104
|
+
...opts.batchOptions,
|
|
105
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.queryFetchOptions },
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
provide: {
|
|
115
|
+
trpc,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
} satisfies ObjectPlugin
|
|
120
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DefaultOptions,
|
|
3
|
+
DehydratedState,
|
|
4
|
+
QueryClientConfig,
|
|
5
|
+
VueQueryPluginOptions,
|
|
6
|
+
} from '@tanstack/vue-query'
|
|
7
|
+
import type { ObjectPlugin } from '#app'
|
|
8
|
+
import type { ToastOpts } from './helper/request-error'
|
|
9
|
+
import {
|
|
10
|
+
dehydrate,
|
|
11
|
+
hydrate,
|
|
12
|
+
MutationCache,
|
|
13
|
+
QueryCache,
|
|
14
|
+
QueryClient,
|
|
15
|
+
useIsFetching,
|
|
16
|
+
VueQueryPlugin,
|
|
17
|
+
} from '@tanstack/vue-query'
|
|
18
|
+
import defu from 'defu'
|
|
19
|
+
import { useState } from '#app'
|
|
20
|
+
import { isRetryableError, toastAdd, toastRequestError } from './helper/request-error'
|
|
21
|
+
|
|
22
|
+
interface VueQueryNuxtPluginOptions {
|
|
23
|
+
queryDefaultOptions?: DefaultOptions
|
|
24
|
+
vuePluginOptions?: VueQueryPluginOptions
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CustomMeta {
|
|
28
|
+
queryMeta: {
|
|
29
|
+
toast?: {
|
|
30
|
+
error?: ToastOpts
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
mutationMeta: {
|
|
34
|
+
toast?: {
|
|
35
|
+
success?: ToastOpts
|
|
36
|
+
error?: ToastOpts
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module '@tanstack/vue-query' {
|
|
42
|
+
interface Register extends CustomMeta {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'vue-query',
|
|
48
|
+
setup(nuxt) {
|
|
49
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
50
|
+
|
|
51
|
+
const queryClient = new QueryClient(
|
|
52
|
+
defu<QueryClientConfig, QueryClientConfig[]>(
|
|
53
|
+
{ defaultOptions: opts?.queryDefaultOptions },
|
|
54
|
+
{
|
|
55
|
+
defaultOptions: {
|
|
56
|
+
queries: {
|
|
57
|
+
retry(failureCount, error) {
|
|
58
|
+
if (!isRetryableError(error)) return false
|
|
59
|
+
|
|
60
|
+
return failureCount < 3
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
queryCache: new QueryCache({
|
|
65
|
+
onError(err, query) {
|
|
66
|
+
console.error(err)
|
|
67
|
+
|
|
68
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
mutationCache: new MutationCache({
|
|
72
|
+
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
73
|
+
if (mutation.meta?.toast?.success) {
|
|
74
|
+
toastAdd({
|
|
75
|
+
preset: 'success',
|
|
76
|
+
...mutation.meta.toast.success,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onError(err, _input, __onMutateRes, mutation) {
|
|
81
|
+
console.error(err)
|
|
82
|
+
|
|
83
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
91
|
+
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
92
|
+
|
|
93
|
+
if (import.meta.server) {
|
|
94
|
+
nuxt.hooks.hook('app:rendered', () => {
|
|
95
|
+
try {
|
|
96
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (import.meta.client) {
|
|
104
|
+
nuxt.hooks.hook('app:created', () => {
|
|
105
|
+
try {
|
|
106
|
+
hydrate(queryClient, vueQueryState.value)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// global nuxt loading indicator
|
|
114
|
+
const loadingIndicator = useLoadingIndicator()
|
|
115
|
+
const isFetching = useIsFetching()
|
|
116
|
+
|
|
117
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
118
|
+
watch(isFetching, () => {
|
|
119
|
+
if (isFetching.value > 0) {
|
|
120
|
+
loadingIndicator.start()
|
|
121
|
+
timeout = setTimeout(() => {
|
|
122
|
+
loadingIndicator.set(0)
|
|
123
|
+
}, 300)
|
|
124
|
+
} else {
|
|
125
|
+
if (timeout) clearTimeout(timeout)
|
|
126
|
+
loadingIndicator.finish()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
} satisfies ObjectPlugin
|
|
131
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -43,7 +43,19 @@ 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: {
|
|
54
|
+
dirs: [
|
|
55
|
+
// layer configs resolve `~` against the consuming project, so use absolute paths
|
|
56
|
+
path.join(currentDir, './app/types'),
|
|
57
|
+
path.join(currentDir, './app/utils/*/index.ts'),
|
|
58
|
+
],
|
|
47
59
|
imports: [
|
|
48
60
|
{
|
|
49
61
|
from: '@falcondev-oss/form-vue',
|
|
@@ -60,6 +72,10 @@ export default defineNuxtConfig({
|
|
|
60
72
|
},
|
|
61
73
|
],
|
|
62
74
|
},
|
|
75
|
+
sourcemap: {
|
|
76
|
+
client: 'hidden',
|
|
77
|
+
server: true,
|
|
78
|
+
},
|
|
63
79
|
|
|
64
80
|
// runtime
|
|
65
81
|
experimental: {
|
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
|
-
})
|
package/app/utils/plugins.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import type { DehydratedState, QueryClientConfig, VueQueryPluginOptions } from '@tanstack/vue-query'
|
|
2
|
-
import type { OperationLink, TRPCClientError, TRPCLink } from '@trpc/client'
|
|
3
|
-
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
4
|
-
import type { FetchOptions } from 'ofetch'
|
|
5
|
-
import type { ObjectPlugin } from '#app'
|
|
6
|
-
import type { ToastOptions } from '../composables/useToast'
|
|
7
|
-
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
8
|
-
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
9
|
-
import {
|
|
10
|
-
dehydrate,
|
|
11
|
-
hydrate,
|
|
12
|
-
MutationCache,
|
|
13
|
-
QueryCache,
|
|
14
|
-
QueryClient,
|
|
15
|
-
useIsFetching,
|
|
16
|
-
useQueryClient,
|
|
17
|
-
VueQueryPlugin,
|
|
18
|
-
} from '@tanstack/vue-query'
|
|
19
|
-
import { httpSubscriptionLink, isTRPCClientError, splitLink } from '@trpc/client'
|
|
20
|
-
import { observable } from '@trpc/server/observable'
|
|
21
|
-
import defu from 'defu'
|
|
22
|
-
import superjson from 'superjson'
|
|
23
|
-
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
24
|
-
import { useState } from '#app'
|
|
25
|
-
|
|
26
|
-
interface VueQueryNuxtPluginOptions {
|
|
27
|
-
queryClientOptions?: QueryClientConfig
|
|
28
|
-
vuePluginOptions?: VueQueryPluginOptions
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ToastOpts {
|
|
32
|
-
title?: string
|
|
33
|
-
description?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface CustomMeta {
|
|
37
|
-
queryMeta: {
|
|
38
|
-
toast?: {
|
|
39
|
-
error?: ToastOpts
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
mutationMeta: {
|
|
43
|
-
toast?: {
|
|
44
|
-
success?: ToastOpts
|
|
45
|
-
error?: ToastOpts
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
declare module '@tanstack/vue-query' {
|
|
51
|
-
interface Register extends CustomMeta {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
55
|
-
function isNetworkError(err: unknown) {
|
|
56
|
-
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
60
|
-
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
61
|
-
return err.data as TRPCDefaultErrorData | undefined
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
65
|
-
|
|
66
|
-
function isRetryableError(err: unknown) {
|
|
67
|
-
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
68
|
-
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
69
|
-
|
|
70
|
-
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
74
|
-
function isSchemaIssueList(message: string) {
|
|
75
|
-
if (!message.startsWith('[')) return false
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const issues = JSON.parse(message) as unknown[]
|
|
79
|
-
return (
|
|
80
|
-
issues.length > 0 &&
|
|
81
|
-
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
82
|
-
)
|
|
83
|
-
} catch {
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
89
|
-
BAD_REQUEST: 'Ungültige Eingabe',
|
|
90
|
-
UNAUTHORIZED: 'Nicht angemeldet',
|
|
91
|
-
FORBIDDEN: 'Keine Berechtigung',
|
|
92
|
-
NOT_FOUND: 'Nicht vorhanden',
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function requestErrorToast(err: unknown): ToastOpts {
|
|
96
|
-
if (isNetworkError(err))
|
|
97
|
-
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
98
|
-
|
|
99
|
-
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
100
|
-
|
|
101
|
-
// internal errors leak implementation details and mean nothing to the user
|
|
102
|
-
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
103
|
-
|
|
104
|
-
const code = errorData(err)?.code
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
108
|
-
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
113
|
-
function toastAdd(opts: ToastOptions) {
|
|
114
|
-
if (import.meta.server) return
|
|
115
|
-
|
|
116
|
-
useToast().add({ duration: 5000, ...opts })
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
120
|
-
function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
121
|
-
toastAdd({
|
|
122
|
-
preset: 'error',
|
|
123
|
-
...(opts ?? requestErrorToast(err)),
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
128
|
-
return {
|
|
129
|
-
name: 'vue-query',
|
|
130
|
-
setup(nuxt) {
|
|
131
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
132
|
-
|
|
133
|
-
const queryClient = new QueryClient(
|
|
134
|
-
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
135
|
-
defaultOptions: {
|
|
136
|
-
queries: {
|
|
137
|
-
retry(failureCount, error) {
|
|
138
|
-
if (!isRetryableError(error)) return false
|
|
139
|
-
|
|
140
|
-
return failureCount < 3
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
queryCache: new QueryCache({
|
|
145
|
-
onError(err, query) {
|
|
146
|
-
console.error(err)
|
|
147
|
-
|
|
148
|
-
toastRequestError(err, query.meta?.toast?.error)
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
mutationCache: new MutationCache({
|
|
152
|
-
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
153
|
-
if (mutation.meta?.toast?.success) {
|
|
154
|
-
toastAdd({
|
|
155
|
-
preset: 'success',
|
|
156
|
-
...mutation.meta.toast.success,
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
onError(err, _input, __onMutateRes, mutation) {
|
|
161
|
-
console.error(err)
|
|
162
|
-
|
|
163
|
-
toastRequestError(err, mutation.meta?.toast?.error)
|
|
164
|
-
},
|
|
165
|
-
}),
|
|
166
|
-
}),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
170
|
-
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
171
|
-
|
|
172
|
-
if (import.meta.server) {
|
|
173
|
-
nuxt.hooks.hook('app:rendered', () => {
|
|
174
|
-
try {
|
|
175
|
-
vueQueryState.value = dehydrate(queryClient)
|
|
176
|
-
} catch (err) {
|
|
177
|
-
console.error('[vue-query] dehydrating state failed:', err)
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (import.meta.client) {
|
|
183
|
-
nuxt.hooks.hook('app:created', () => {
|
|
184
|
-
try {
|
|
185
|
-
hydrate(queryClient, vueQueryState.value)
|
|
186
|
-
} catch (err) {
|
|
187
|
-
console.error('[vue-query] hydrating state failed:', err)
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// global nuxt loading indicator
|
|
193
|
-
const loadingIndicator = useLoadingIndicator()
|
|
194
|
-
const isFetching = useIsFetching()
|
|
195
|
-
|
|
196
|
-
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
197
|
-
watch(isFetching, () => {
|
|
198
|
-
if (isFetching.value > 0) {
|
|
199
|
-
loadingIndicator.start()
|
|
200
|
-
timeout = setTimeout(() => {
|
|
201
|
-
loadingIndicator.set(0)
|
|
202
|
-
}, 300)
|
|
203
|
-
} else {
|
|
204
|
-
if (timeout) clearTimeout(timeout)
|
|
205
|
-
loadingIndicator.finish()
|
|
206
|
-
}
|
|
207
|
-
})
|
|
208
|
-
},
|
|
209
|
-
} satisfies ObjectPlugin
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
214
|
-
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
215
|
-
* from the query/mutation cache instead.
|
|
216
|
-
*/
|
|
217
|
-
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
218
|
-
observable((observer) => {
|
|
219
|
-
const subscription = next(op).subscribe({
|
|
220
|
-
next: (value) => observer.next(value),
|
|
221
|
-
complete: () => observer.complete(),
|
|
222
|
-
error(err) {
|
|
223
|
-
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
224
|
-
console.error(err)
|
|
225
|
-
toastRequestError(err)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
observer.error(err)
|
|
229
|
-
},
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
return () => {
|
|
233
|
-
subscription.unsubscribe()
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
237
|
-
|
|
238
|
-
interface TrpcNuxtPluginOptions {
|
|
239
|
-
url: string
|
|
240
|
-
/**
|
|
241
|
-
* ofetch options passed to the HTTP links.
|
|
242
|
-
* @see https://github.com/unjs/ofetch
|
|
243
|
-
*/
|
|
244
|
-
fetchOptions?: FetchOptions
|
|
245
|
-
}
|
|
246
|
-
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
247
|
-
return {
|
|
248
|
-
name: 'trpc',
|
|
249
|
-
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
250
|
-
dependsOn: ['vue-query'] as any,
|
|
251
|
-
setup() {
|
|
252
|
-
const queryClient = useQueryClient()
|
|
253
|
-
|
|
254
|
-
const trpc = createTRPCVueQueryClient<Router>({
|
|
255
|
-
queryClient,
|
|
256
|
-
trpc: {
|
|
257
|
-
links: [
|
|
258
|
-
requestErrorToastLink,
|
|
259
|
-
splitLink({
|
|
260
|
-
condition: (op) => op.type === 'subscription',
|
|
261
|
-
true: httpSubscriptionLink({
|
|
262
|
-
url: opts.url,
|
|
263
|
-
transformer: superjson,
|
|
264
|
-
}),
|
|
265
|
-
false: splitLink({
|
|
266
|
-
condition: (op) => op.type === 'mutation',
|
|
267
|
-
true: [
|
|
268
|
-
typedFormDataLink<AnyTRPCRouter>({
|
|
269
|
-
transformer: superjson,
|
|
270
|
-
}),
|
|
271
|
-
httpLink({
|
|
272
|
-
transformer: superjson,
|
|
273
|
-
url: opts.url,
|
|
274
|
-
fetchOptions: opts.fetchOptions,
|
|
275
|
-
}),
|
|
276
|
-
],
|
|
277
|
-
false: httpBatchLink({
|
|
278
|
-
transformer: superjson,
|
|
279
|
-
url: opts.url,
|
|
280
|
-
maxURLLength: 2000,
|
|
281
|
-
fetchOptions: opts.fetchOptions,
|
|
282
|
-
}),
|
|
283
|
-
}),
|
|
284
|
-
}),
|
|
285
|
-
],
|
|
286
|
-
},
|
|
287
|
-
})
|
|
288
|
-
return {
|
|
289
|
-
provide: {
|
|
290
|
-
trpc,
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
} satisfies ObjectPlugin
|
|
295
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|