UNPKG

@ozmos/viper-vue

Version:

Vue plugin for Viper

481 lines (434 loc) 13.5 kB
import { type QueryClient, useMutation, useQuery } from "@tanstack/vue-query"; import { type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type Ref, computed, inject, ref, toValue, } from "vue"; type BaseBindings = string[]; type BaseProps = Record<string, { result: unknown; bindings: BaseBindings }>; type BaseActions = Record< string, { args: unknown; result: unknown; bindings: BaseBindings } >; type BaseParams = Record<string, string>; interface BasePageType { props: BaseProps; actions: BaseActions; params: BaseParams; } interface BasePage extends BasePageType { hashes: Record<string, string>; title?: string | null; } interface PageInit { formatTitle?: (title: string) => string; queryClient: QueryClient; } type HeaderFunction = () => Promise<Record<string, string>>; // Helper types for conditional binding requirements type BindOptions<T extends { bindings: BaseBindings }> = T["bindings"]["length"] extends 0 ? { bind?: never } : { bind: { [K in T["bindings"][number]]: | MaybeRef<string | number | null> | ComputedRef<string | number | null>; }; }; // Type aliases for cleaner TanStack Query integration type QueryOptions<T> = Partial<Parameters<typeof useQuery<T>>[0]>; type QueryStringOptions = { qs?: Record<string, MaybeRefOrGetter<string | number | unknown[] | null>>; }; type MutationOptions<TResult, TArgs> = Parameters< typeof useMutation<TResult, unknown, TArgs> >[0]; // Clean parameter types for function signatures type UseQueryParams<T extends { bindings: BaseBindings; result: unknown }> = T["bindings"]["length"] extends 0 ? [ options?: QueryOptions<T["result"]> & BindOptions<T> & QueryStringOptions, ] : [ options: QueryOptions<T["result"]> & BindOptions<T> & QueryStringOptions, ]; type UseMutationParams< T extends { bindings: BaseBindings; result: unknown; args: unknown }, > = T["bindings"]["length"] extends 0 ? [options?: MutationOptions<T["result"], T["args"]> & BindOptions<T>] : [options: MutationOptions<T["result"], T["args"]> & BindOptions<T>]; type UseFormParams< T extends { bindings: BaseBindings; result: unknown; args: unknown }, > = T["bindings"]["length"] extends 0 ? [ options?: MutationOptions<T["result"], T["args"]> & { state: T["args"]; } & BindOptions<T>, ] : [ options: MutationOptions<T["result"], T["args"]> & { state: T["args"]; } & BindOptions<T>, ]; type UseFormDataParams< T extends { bindings: BaseBindings; result: unknown; args: unknown }, > = T["bindings"]["length"] extends 0 ? [ options?: MutationOptions<T["result"], T["args"]> & { state: T["args"]; files: string[]; } & BindOptions<T>, ] : [ options: MutationOptions<T["result"], T["args"]> & { state: T["args"]; files: string[]; } & BindOptions<T>, ]; export class Page { params = ref<Record<string, string>>({}); formatTitle = (title: string) => title; props: Record<string, unknown> = {}; actions = {}; hashes: Record<string, string> = {}; queryClient: QueryClient; headerFunctions: HeaderFunction[] = []; constructor(config: PageInit) { this.queryClient = config.queryClient; if (config.formatTitle) { this.formatTitle = config.formatTitle; } } mergeHeaders(func: HeaderFunction) { this.headerFunctions.push(func); return () => { this.headerFunctions = this.headerFunctions.filter((f) => f !== func); }; } async getHeaders() { const headers = await Promise.all( this.headerFunctions.map((func) => func()), ); return headers.reduce((acc, header) => ({ ...acc, ...header }), {}); } updateFromPageJson(json: BasePage) { this.props = json.props; this.actions = json.actions; this.hashes = json.hashes; this.setPageTitle(json.title || ""); this.params.value = json.params; for (const key in this.props) { this.queryClient.setQueryData([this.hashes[key]], () => this.props[key]); } } setPageTitle(title: string) { document.title = this.formatTitle(title || ""); } } export function usePage<P extends BasePageType>() { type Props = P["props"]; type Actions = P["actions"]; type Params = P["params"]; const page = inject("viperPage") as Page; async function viperFetch({ bind, body, headers, method = "GET", qs = {}, }: { bind?: Record<string, unknown>; body?: string | FormData; headers?: Record<string, string>; method?: string; qs?: Pick<QueryStringOptions, "qs">["qs"]; }) { const boundHeaders: Record<string, string> = {}; const bindKeys = []; const bindValues = []; for (const [key, value] of Object.entries(bind ?? {})) { if (toValue(value)) { bindKeys.push(key); bindValues.push(toValue(value)); } } if (bindKeys.length > 0) { boundHeaders["X-Viper-Bind-Keys"] = bindKeys.join(","); boundHeaders["X-Viper-Bind-Values"] = bindValues.join(","); } const url = new URL(window.location.href); if (qs) { for (const [key, value] of Object.entries(qs)) { const raw = toValue(value); if (Array.isArray(raw)) { for (const item of raw) { url.searchParams.append(`${key}[]`, String(item)); } } else { url.searchParams.set(key, String(raw)); } } } return fetch(url.toString(), { method, credentials: "include", headers: { ...(await page.getHeaders()), ...headers, ...boundHeaders, ...(body instanceof FormData ? {} : { "Content-Type": "application/json", }), Accept: "application/json", "X-Viper-Request": "true", "X-XSRF-TOKEN": decodeURIComponent(getXsrfToken() || ""), }, body, }); } return { setPageTitle: page.setPageTitle, params: page.params as Ref<Params>, useQuery<K extends keyof Props>(key: K, ...args: UseQueryParams<Props[K]>) { const options = args[0]; const { bind, qs, ...opts } = options ?? {}; const enabled = ref(false); const queryKey = computed(() => { return [ page.hashes[key as string], ...Object.entries(bind ?? {}) .map(([key, value]) => `bind:${key}:${toValue(value)}`) .filter(Boolean), ...(qs ? Object.entries(qs).map( ([key, value]) => `qs:${key}:${toValue(value)}`, ) : []), ]; }); const query = useQuery<Props[K]["result"]>({ ...opts, enabled, initialData: () => page.props[key as string] as Props[K]["result"], queryKey, queryFn: async () => { const res = await viperFetch({ bind, headers: { "X-Viper-Only": key as string, }, qs, }); enabled.value = true; if (!res.ok) { throw await res.json(); } const data = await res.json(); return data.props[key]; }, }); // Trust that initialData makes this non-null and preserve TanStack Query's return type return { ...query, data: query.data as Ref<Props[K]["result"]>, }; }, useMutation<K extends keyof Actions>( key: K, ...args: UseMutationParams<Actions[K]> ) { const options = args[0] || { bind: {} }; type Result = Actions[K]["result"]; type Args = Actions[K]["args"]; const { bind, ...mutationOptions } = options; return useMutation<Result, unknown, Args>({ ...mutationOptions, mutationFn: async (data = {}) => { const res = await viperFetch({ method: "POST", bind, body: data instanceof FormData ? data : JSON.stringify(data), headers: { ...(data instanceof FormData ? {} : { "Content-Type": "application/json" }), Accept: "application/json", "X-Viper-Action": key as string, }, }); if (res.status === 422) { const data = (await res.json()) as { message: string; errors: Record<string, string[]>; }; throw data.errors; } if (!res.ok) { throw await res.json(); } return (await res.json()) as Result; }, }); }, useForm<K extends keyof Actions>( key: K, ...args: UseFormParams<Actions[K]> ) { const options = args[0] || ({} as MutationOptions<Actions[K]["result"], Actions[K]["args"]> & { state: Actions[K]["args"]; } & BindOptions<Actions[K]>); type Result = Actions[K]["result"]; type Args = Actions[K]["args"]; const { bind, state: initialState, ...mutationOptions } = options; const _initialState = { ...toValue(initialState ?? {}) }; const state = ref(initialState); const errors = ref<Record<string, string>>({}); const mutation = useMutation<Result, unknown, Args>({ ...mutationOptions, mutationFn: async (data = {}) => { errors.value = {}; const res = await viperFetch({ method: "POST", bind, body: JSON.stringify({ ...toValue(state), ...toValue(data ?? {}), }), headers: { "X-Viper-Action": key as string, }, }); if (res.status === 422) { const data = (await res.json()) as { message: string; errors: Record<string, string[]>; }; errors.value = Object.entries(data.errors).reduce( (acc, [key, value]) => ({ ...acc, [key]: value[0] }), {}, ); throw data.errors; } if (!res.ok) { throw await res.json(); } return (await res.json()) as Result; }, }); return { ...mutation, mutate(override: Partial<Args> = {}) { return mutation.mutate(override); }, mutateAsync(override: Partial<Args> = {}) { return mutation.mutateAsync(override); }, reset: () => { state.value = { ..._initialState }; }, errors, state, }; }, useFormData<K extends keyof Actions>( key: K, ...args: UseFormDataParams<Actions[K]> ) { const options = args[0] || ({} as MutationOptions<Actions[K]["result"], Actions[K]["args"]> & { state: Actions[K]["args"]; files: string[]; } & BindOptions<Actions[K]>); type Result = Actions[K]["result"]; type Args = Actions[K]["args"]; const { bind, state: initialState, files, ...mutationOptions } = options; const _initialState = { ...toValue(initialState ?? {}) }; const state = ref(initialState); const errors = ref<Record<string, string>>({}); const mutation = useMutation<Result, unknown, Args>({ ...mutationOptions, mutationFn: async (data = {}) => { errors.value = {}; const json = { ...toValue(state), ...toValue(data ?? {}), }; const formData = new FormData(); for (const key of files) { if (Array.isArray(json[key])) { for (const file of json[key]) { formData.append(`${key}[]`, file); } } else if (json[key]) { formData.set(key, json[key]); } delete json[key]; } formData.set("state", JSON.stringify(json)); const res = await viperFetch({ method: "POST", body: formData, bind, headers: { "X-Viper-Action": key as string, }, }); if (res.status === 422) { const data = (await res.json()) as { message: string; errors: Record<string, string[]>; }; errors.value = Object.entries(data.errors).reduce( (acc, [key, value]) => ({ ...acc, [key]: value[0] }), {}, ); throw data.errors; } if (!res.ok) { throw await res.json(); } return (await res.json()) as Result; }, }); return { ...mutation, mutate(override: Partial<Args> = {}) { return mutation.mutate(override); }, mutateAsync(override: Partial<Args> = {}) { return mutation.mutateAsync(override); }, reset: () => { state.value = { ..._initialState }; }, errors, state, }; }, }; } function getXsrfToken() { const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.startsWith("XSRF-TOKEN=")) { return cookie.substring("XSRF-TOKEN=".length, cookie.length); } } return null; }