@ozmos/viper-react
Version:
React plugin for Viper
352 lines • 13.3 kB
JSX
import { useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
import { useLayoutEffect, useMemo, 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;
headerFunctions = [];
mergeHeaders(func) {
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) {
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) {
// Get the response content
const responseContent = await res.text();
// Create a native dialog element
const dialog = document.createElement("dialog");
dialog.style.cssText = `
width: 80vw;
height: 80vh;
max-width: 90vw;
max-height: 90vh;
padding: 0;
border: none;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
`;
dialog.innerHTML = `
<div style="padding: 20px; height: 100%; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 20px 0;">Request Failed (${res.status} ${res.statusText})</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow: auto; flex: 1; white-space: pre-wrap; margin: 0;">${responseContent}</pre>
<div style="margin-top: 20px; text-align: right; flex-shrink: 0;">
<button onclick="this.closest('dialog').close();" style="padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">Close</button>
</div>
</div>
`;
// Ensure dialog is removed from DOM when closed (regardless of how it's dismissed)
dialog.addEventListener("close", () => {
dialog.remove();
});
// Append to document body and show
document.body.appendChild(dialog);
dialog.showModal();
throw new Error("Failed to fetch page");
}
page.updateFromPageJson(await res.json());
return {};
}
export function usePage() {
const params = usePageStore((state) => state.params);
async function viperFetch({ bind, body, headers, method = "GET", qs = {}, }) {
const boundHeaders = {};
const bindKeys = [];
const bindValues = [];
for (const [key, value] of Object.entries(bind ?? {})) {
if (value != null) {
bindKeys.push(key);
bindValues.push(String(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)) {
if (Array.isArray(value)) {
for (const item of value) {
url.searchParams.append(`${key}[]`, String(item));
}
}
else {
url.searchParams.set(key, String(value));
}
}
}
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: params,
useQuery(key, ...args) {
const options = args[0];
const { bind, qs, ...opts } = options ?? {};
const [enabled, setEnabled] = useState(false);
const queryKey = useMemo(() => {
return [
page.hashes[key],
...Object.entries(bind ?? {})
.map(([key, value]) => `bind:${key}:${value}`)
.filter(Boolean),
...(qs
? Object.entries(qs).map(([key, value]) => `qs:${key}:${value}`)
: []),
];
}, [key, bind, qs]);
const query = useQuery({
...opts,
enabled,
initialData: () => page.props[key],
queryKey,
queryFn: async () => {
const res = await viperFetch({
bind,
headers: {
"X-Viper-Only": key,
},
qs,
});
setEnabled(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,
};
},
useMutation(key, ...args) {
const options = args[0] || { bind: {} };
const { bind, ...mutationOptions } = options;
return useMutation({
...mutationOptions,
mutationFn: async (data = {}) => {
const res = await viperFetch({
method: "POST",
body: data instanceof FormData ? data : JSON.stringify(data),
headers: {
...(data instanceof FormData
? {}
: { "Content-Type": "application/json" }),
Accept: "application/json",
"X-Viper-Action": key,
},
bind,
});
if (res.status === 422) {
const data = (await res.json());
throw data.errors;
}
if (!res.ok) {
throw await res.json();
}
return (await res.json());
},
});
},
useForm(key, ...args) {
const options = args[0] ||
{};
const { bind, state: initialState, ...mutationOptions } = options;
const _initialState = { ...(initialState ?? {}) };
const [state, setState] = useState(initialState ?? {});
const [errors, setErrors] = useState({});
const mutation = useMutation({
...mutationOptions,
mutationFn: async (data = {}) => {
setErrors({});
const res = await viperFetch({
method: "POST",
body: JSON.stringify({
...(state ?? {}),
...(data ?? {}),
}),
headers: {
"X-Viper-Action": key,
},
bind,
});
if (res.status === 422) {
const data = (await res.json());
setErrors(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());
},
});
return {
...mutation,
mutate(override = {}) {
return mutation.mutate(override);
},
mutateAsync(override = {}) {
return mutation.mutateAsync(override);
},
reset: () => {
setState({ ...(_initialState ?? {}) });
},
errors,
state,
setState,
};
},
useFormData(key, ...args) {
const options = args[0] ||
{};
const { bind, state: initialState, files, ...mutationOptions } = options;
const _initialState = { ...(initialState ?? {}) };
const [state, setState] = useState(initialState ?? {});
const [errors, setErrors] = useState({});
const mutation = useMutation({
...mutationOptions,
mutationFn: async (data = {}) => {
setErrors({});
const json = {
...(state ?? {}),
...(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,
headers: {
"X-Viper-Action": key,
},
bind,
});
if (res.status === 422) {
const data = (await res.json());
setErrors(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());
},
});
return {
...mutation,
mutate(override = {}) {
return mutation.mutate(override);
},
mutateAsync(override = {}) {
return mutation.mutateAsync(override);
},
reset: () => {
setState({ ..._initialState });
},
errors,
state,
setState,
};
},
};
}
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;
}
//# sourceMappingURL=page.jsx.map