UNPKG

@ozmos/viper-react

Version:

React plugin for Viper

267 lines 10.2 kB
import { useMutation, useQuery, useQueryClient, } from "@tanstack/react-query"; import { useLayoutEffect, useState } from "react"; import { redirect } from "react-router"; import { create } from "zustand"; const usePageStore = create((set) => ({ params: {}, })); export class Page { formatTitle = (title) => title; props = {}; actions = {}; hashes = {}; queryClient = null; updateFromPageJson(json) { this.props = json.props; this.actions = json.actions; this.hashes = json.hashes; this.setPageTitle(json.title || ""); usePageStore.setState({ params: json.params }); for (const key in this.props) { this.queryClient?.setQueryData([this.hashes[key]], () => this.props[key]); } } setPageTitle(title) { document.title = this.formatTitle(title || ""); } } const page = new Page(); export function ViperProvider({ children, formatTitle, }) { const queryClient = useQueryClient(); page.queryClient = queryClient; if (formatTitle) { page.formatTitle = formatTitle; } useLayoutEffect(() => { const pageJson = JSON.parse(document.getElementById("app")?.dataset.page ?? "{}"); page.updateFromPageJson(pageJson); }, []); return <>{children}</>; } export async function reactRouterLoader({ request }) { const res = await fetch(request.url, { headers: { "Content-Type": "application/json", Accept: "application/json", "X-Viper-Request": "true", }, }); const redirectUrl = res.headers.get("x-viper-location"); if (redirectUrl) { const url = new URL(redirectUrl); return redirect(url.pathname); } if (!res.ok) { throw new Error("Failed to fetch page"); } page.updateFromPageJson(await res.json()); return {}; } export function usePage() { const params = usePageStore((state) => state.params); return { setPageTitle: page.setPageTitle, params: params, useQuery(key) { const [enabled, setEnabled] = useState(false); const query = useQuery({ enabled, initialData: () => page.props[key], queryKey: [page.hashes[key]], queryFn: async () => { const res = await fetch(window.location.pathname, { headers: { Accept: "application/json", "Content-Type": "application/json", "X-Viper-Request": "true", "X-Viper-Only": key, }, }); setEnabled(true); if (!res.ok) { throw await res.json(); } const data = await res.json(); return data.props[key]; }, }); return { ...query, data: query.data, }; }, useMutation(key, options = {}) { return useMutation({ ...options, mutationFn: async (data = {}) => { const res = await fetch(window.location.pathname, { method: "POST", credentials: "include", body: JSON.stringify(data), headers: { Accept: "application/json", "Content-Type": "application/json", "X-Viper-Request": "true", "X-Viper-Action": key, "X-XSRF-TOKEN": decodeURIComponent(getXsrfToken() || ""), }, }); if (!res.ok) { if (res.status === 422) { const data = (await res.json()); throw data.errors; } throw await res.json(); } return (await res.json()); }, }); }, useForm(key, options) { // @ts-expect-error const _initialState = { ...options.state }; const [state, setState] = useState(_initialState); const [errors, setErrors] = useState({}); const mutation = useMutation({ ...options, mutationFn: async (data = {}) => { setErrors({}); const res = await fetch(window.location.pathname, { method: "POST", credentials: "include", body: JSON.stringify({ // @ts-expect-error ...state, // @ts-expect-error ...data, }), headers: { Accept: "application/json", "Content-Type": "application/json", "X-Viper-Request": "true", "X-Viper-Action": key, "X-XSRF-TOKEN": decodeURIComponent(getXsrfToken() || ""), }, }); if (!res.ok) { if (res.status === 422) { const data = (await res.json()); setErrors(Object.entries(data.errors).reduce( // biome-ignore lint/performance/noAccumulatingSpread: it's fine here (acc, [key, value]) => ({ ...acc, [key]: value[0] }), {})); throw data.errors; } throw await res.json(); } return (await res.json()); }, }); return { ...mutation, mutate(override = {}) { return mutation.mutate(override); }, mutateAsync(override = {}) { return mutation.mutateAsync(override); }, reset: () => { // @ts-expect-error setState({ ..._initialState }); }, errors, state, setState, }; }, useFormData(key, options) { // @ts-expect-error const _initialState = { ...options.state }; const [state, setState] = useState(_initialState); const [errors, setErrors] = useState({}); const mutation = useMutation({ ...options, mutationFn: async (data = {}) => { setErrors({}); const json = { ...state, // @ts-expect-error ...data, }; const formData = new FormData(); for (const key of options.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 fetch(window.location.pathname, { method: "POST", credentials: "include", body: formData, headers: { Accept: "application/json", "X-Viper-Request": "true", "X-Viper-Action": key, "X-XSRF-TOKEN": decodeURIComponent(getXsrfToken() || ""), }, }); if (!res.ok) { if (res.status === 422) { const data = (await res.json()); setErrors(Object.entries(data.errors).reduce( // biome-ignore lint/performance/noAccumulatingSpread: it's fine here (acc, [key, value]) => ({ ...acc, [key]: value[0] }), {})); throw data.errors; } throw await res.json(); } return (await res.json()); }, }); return { ...mutation, mutate(override = {}) { return mutation.mutate(override); }, mutateAsync(override = {}) { return mutation.mutateAsync(override); }, reset: () => { setState({ ..._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; } // type TestPage = { // props: Record<string, unknown>; // params: Record<string, string>; // actions: { // updateUser: { args: { email: string }; result: any }; // deleteUser: { args: { id: string }; result: any }; // }; // }; // usePage<TestPage>().useForm("updateUser", { // state: { // // its allowing me to pass either "id" or "email" here but it should only allow "email" // }, // }); //# sourceMappingURL=page.jsx.map