From 8c91dff94d6081f79c2993110fb8abd1ef572ec6 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 13 Dec 2024 16:24:55 +0100 Subject: [PATCH] updated admin to use swr hooks instead of react-query --- app/src/auth/api/AuthApi.ts | 4 +- app/src/data/helper.ts | 8 +- app/src/modules/ModuleApi.ts | 70 ++++++++++++--- app/src/modules/SystemApi.ts | 25 +++--- app/src/modules/server/SystemController.ts | 20 ++++- app/src/ui/Admin.tsx | 42 ++++++++- app/src/ui/client/BkndProvider.tsx | 19 ++-- app/src/ui/client/api/use-api.ts | 11 ++- app/src/ui/client/api/use-entity.ts | 72 ++++++++++++--- app/src/ui/client/schema/actions.ts | 49 +++++------ app/src/ui/client/utils/AppQueryClient.ts | 10 +-- app/src/ui/components/display/Logo.tsx | 14 +-- app/src/ui/components/table/DataTable.tsx | 17 ++-- app/src/ui/layouts/AppShell/AppShell.tsx | 6 +- app/src/ui/layouts/AppShell/Header.tsx | 2 +- .../fields/EntityRelationalFormField.tsx | 88 +++++-------------- .../ui/modules/data/hooks/useEntityForm.tsx | 10 +-- app/src/ui/routes/data/data.$entity.$id.tsx | 58 ++++++------ .../ui/routes/data/data.$entity.create.tsx | 44 +++++----- app/src/ui/routes/data/data.$entity.index.tsx | 86 +++++++++--------- 20 files changed, 380 insertions(+), 275 deletions(-) diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index 8c2fe8b..7b43d6d 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi { async loginWithPassword(input: any) { const res = await this.post(["password", "login"], input); - if (res.res.ok && res.body.token) { + if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; @@ -23,7 +23,7 @@ export class AuthApi extends ModuleApi { async registerWithPassword(input: any) { const res = await this.post(["password", "register"], input); - if (res.res.ok && res.body.token) { + if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 481ab0f..74497b0 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -18,6 +18,7 @@ export function getChangeSet( data: EntityData, fields: Field[] ): EntityData { + //console.log("getChangeSet", formData, data); return transform( formData, (acc, _value, key) => { @@ -26,11 +27,12 @@ export function getChangeSet( if (!field || field.isVirtual()) return; const value = _value === "" ? null : _value; - const newValue = field.getValue(value, "submit"); + // normalize to null if undefined + const newValue = field.getValue(value, "submit") || null; // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; - console.log("changed", { + /*console.log("changed", { key, value, valueType: typeof value, @@ -38,7 +40,7 @@ export function getChangeSet( newValue, new: value, sent: acc[key] - }); + });*/ } else { //console.log("no change", key, value, data[key]); } diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 2473f0d..cfeac86 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -1,4 +1,4 @@ -import type { PrimaryFieldType } from "core"; +import { type PrimaryFieldType, isDebug } from "core"; import { encodeSearch } from "core/utils"; export type { PrimaryFieldType }; @@ -10,6 +10,7 @@ export type BaseModuleApiOptions = { token_transport?: "header" | "cookie" | "none"; }; +/** @deprecated */ export type ApiResponse = { success: boolean; status: number; @@ -47,7 +48,7 @@ export abstract class ModuleApi | URLSearchParams, _init?: RequestInit - ): FetchPromise> { + ): FetchPromise> { const method = _init?.method ?? "GET"; const input = Array.isArray(_input) ? _input.join("/") : _input; let url = this.getUrl(input); @@ -138,6 +139,58 @@ export abstract class ModuleApi = Data & { + raw: Response; + res: Response; + data: Data; + body: Body; + ok: boolean; + status: number; + toJSON(): Data; +}; + +export function createResponseProxy( + raw: Response, + body: Body, + data?: Data +): ResponseObject { + const actualData = data ?? (body as unknown as Data); + const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"]; + + return new Proxy(actualData as any, { + get(target, prop, receiver) { + if (prop === "raw" || prop === "res") return raw; + if (prop === "body") return body; + if (prop === "data") return data; + if (prop === "ok") return raw.ok; + if (prop === "status") return raw.status; + if (prop === "toJSON") { + return () => target; + } + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + if (_props.includes(prop as string)) { + return true; + } + return Reflect.has(target, prop); + }, + ownKeys(target) { + return Array.from(new Set([...Reflect.ownKeys(target), ..._props])); + }, + getOwnPropertyDescriptor(target, prop) { + if (_props.includes(prop as string)) { + return { + configurable: true, + enumerable: true, + value: Reflect.get({ raw, body, ok: raw.ok, status: raw.status }, prop) + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + } + }) as ResponseObject; +} + export class FetchPromise> implements Promise { // @ts-ignore [Symbol.toStringTag]: "FetchPromise"; @@ -149,7 +202,10 @@ export class FetchPromise> implements Promise { } ) {} - async execute(): Promise { + async execute(): Promise> { + // delay in dev environment + isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200))); + const fetcher = this.options?.fetcher ?? fetch; const res = await fetcher(this.request); let resBody: any; @@ -165,13 +221,7 @@ export class FetchPromise> implements Promise { resBody = await res.text(); } - return { - success: res.ok, - status: res.status, - body: resBody, - data: resData, - res - } as T; + return createResponseProxy(res, resBody, resData); } // biome-ignore lint/suspicious/noThenProperty: it's a promise :) diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts index 1d226c6..2451b78 100644 --- a/app/src/modules/SystemApi.ts +++ b/app/src/modules/SystemApi.ts @@ -1,3 +1,4 @@ +import type { ConfigUpdateResponse } from "modules/server/SystemController"; import { ModuleApi } from "./ModuleApi"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; @@ -15,37 +16,37 @@ export class SystemApi extends ModuleApi { }; } - async readSchema(options?: { config?: boolean; secrets?: boolean }) { - return await this.get("schema", { + readSchema(options?: { config?: boolean; secrets?: boolean }) { + return this.get("schema", { config: options?.config ? 1 : 0, secrets: options?.secrets ? 1 : 0 }); } - async setConfig( + setConfig( module: Module, value: ModuleConfigs[Module], force?: boolean ) { - return await this.post( + return this.post( ["config", "set", module].join("/") + `?force=${force ? 1 : 0}`, value ); } - async addConfig(module: Module, path: string, value: any) { - return await this.post(["config", "add", module, path], value); + addConfig(module: Module, path: string, value: any) { + return this.post(["config", "add", module, path], value); } - async patchConfig(module: Module, path: string, value: any) { - return await this.patch(["config", "patch", module, path], value); + patchConfig(module: Module, path: string, value: any) { + return this.patch(["config", "patch", module, path], value); } - async overwriteConfig(module: Module, path: string, value: any) { - return await this.put(["config", "overwrite", module, path], value); + overwriteConfig(module: Module, path: string, value: any) { + return this.put(["config", "overwrite", module, path], value); } - async removeConfig(module: Module, path: string) { - return await this.delete(["config", "remove", module, path]); + removeConfig(module: Module, path: string) { + return this.delete(["config", "remove", module, path]); } } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 93d7cfb..a9fb8d3 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -1,18 +1,32 @@ /// +import type { App } from "App"; import type { ClassController } from "core"; import { tbValidator as tb } from "core"; import { StringEnum, Type, TypeInvalidError } from "core/utils"; import { type Context, Hono } from "hono"; -import { MODULE_NAMES, type ModuleKey, getDefaultConfig } from "modules/ModuleManager"; +import { + MODULE_NAMES, + type ModuleConfigs, + type ModuleKey, + getDefaultConfig +} from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; import { generateOpenAPI } from "modules/server/openapi"; -import type { App } from "../../App"; const booleanLike = Type.Transform(Type.String()) .Decode((v) => v === "1") .Encode((v) => (v ? "1" : "0")); +export type ConfigUpdate = { + success: true; + module: Key; + config: ModuleConfigs[Key]; +}; +export type ConfigUpdateResponse = + | ConfigUpdate + | { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any }; + export class SystemController implements ClassController { constructor(private readonly app: App) {} @@ -60,7 +74,7 @@ export class SystemController implements ClassController { } ); - async function handleConfigUpdateResponse(c: Context, cb: () => Promise) { + async function handleConfigUpdateResponse(c: Context, cb: () => Promise) { try { return c.json(await cb(), { status: 202 }); } catch (e) { diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index df69242..bdfe5bc 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -3,6 +3,8 @@ import { Notifications } from "@mantine/notifications"; import type { ModuleConfigs } from "modules"; import React from "react"; import { BkndProvider, useBknd } from "ui/client/bknd"; +import { Logo } from "ui/components/display/Logo"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; import { FlashMessage } from "ui/modules/server/FlashMessage"; import { ClientProvider, type ClientProviderProps } from "./client"; import { createMantineTheme } from "./lib/mantine/theme"; @@ -21,7 +23,7 @@ export default function Admin({ config }: BkndAdminProps) { const Component = ( - + }> ); @@ -51,3 +53,41 @@ function AdminInternal() { ); } + +const Skeleton = ({ theme = "light" }: { theme?: string }) => { + return ( +
+ +
+
+ +
+ + +
+
+
+
+
+ +
+ Loading +
+
+
+
+ ); +}; diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index cbb6a39..3b26d26 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,5 +1,10 @@ import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; +import { Logo } from "ui/components/display/Logo"; +import { Link } from "ui/components/wouter/Link"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { HeaderNavigation } from "ui/layouts/AppShell/Header"; +import { Root } from "ui/routes/root"; import type { ModuleConfigs, ModuleSchemas } from "../../modules"; import { useClient } from "./ClientProvider"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; @@ -22,8 +27,12 @@ export type { TSchemaActions }; export function BkndProvider({ includeSecrets = false, adminOverride, - children -}: { includeSecrets?: boolean; children: any } & Pick) { + children, + fallback = null +}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick< + BkndContext, + "adminOverride" +>) { const [withSecrets, setWithSecrets] = useState(includeSecrets); const [schema, setSchema] = useState>(); @@ -37,7 +46,7 @@ export function BkndProvider({ async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { if (withSecrets && !force) return; - const { body, res } = await client.api.system.readSchema({ + const res = await client.api.system.readSchema({ config: true, secrets: _includeSecrets }); @@ -57,7 +66,7 @@ export function BkndProvider({ } const schema = res.ok - ? body + ? res.body : ({ version: 0, schema: getDefaultSchema(), @@ -89,7 +98,7 @@ export function BkndProvider({ fetchSchema(includeSecrets); }, []); - if (!fetched || !schema) return null; + if (!fetched || !schema) return fallback; const app = new AppReduced(schema?.config as any); const actions = getSchemaActions({ client, setSchema, reloadSchema }); diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index ae8e345..754ef7d 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -1,6 +1,6 @@ import type { Api } from "Api"; -import type { FetchPromise } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; +import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration } from "swr"; import { useClient } from "ui/client/ClientProvider"; export const useApi = () => { @@ -8,13 +8,16 @@ export const useApi = () => { return client.api; }; -export const useApiQuery = any = (data: Data) => Data>( +export const useApiQuery = < + Data, + RefineFn extends (data: ResponseObject) => any = (data: ResponseObject) => Data +>( fn: (api: Api) => FetchPromise, options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn } ) => { const api = useApi(); const promise = fn(api); - const refine = options?.refine ?? ((data: Data) => data); + const refine = options?.refine ?? ((data: ResponseObject) => data); const fetcher = () => promise.execute().then(refine); const key = promise.key(); diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 3169bc2..4482c12 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,9 +1,20 @@ import type { PrimaryFieldType } from "core"; import { objectTransform } from "core/utils"; import type { EntityData, RepoQuery } from "data"; +import type { ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration } from "swr"; import { useApi } from "ui/client"; +export class UseEntityApiError extends Error { + constructor( + public payload: Payload, + public response: Response, + message?: string + ) { + super(message ?? "UseEntityApiError"); + } +} + export const useEntity = < Entity extends string, Id extends PrimaryFieldType | undefined = undefined @@ -16,18 +27,27 @@ export const useEntity = < return { create: async (input: EntityData) => { const res = await api.createOne(entity, input); - return res.data; + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to create entity"); + } + return res; }, read: async (query: Partial = {}) => { const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); - return res.data; + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to read entity"); + } + return res; }, update: async (input: Partial, _id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); } const res = await api.updateOne(entity, _id, input); - return res.data; + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to update entity"); + } + return res; }, _delete: async (_id: PrimaryFieldType | undefined = id) => { if (!_id) { @@ -35,7 +55,10 @@ export const useEntity = < } const res = await api.deleteOne(entity, _id); - return res.data; + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to delete entity"); + } + return res; } }; }; @@ -47,24 +70,30 @@ export const useEntityQuery = < entity: Entity, id?: Id, query?: Partial, - options?: SWRConfiguration + options?: SWRConfiguration & { enabled?: boolean } ) => { const api = useApi().data; - const key = [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter( - Boolean - ); + const key = + options?.enabled !== false + ? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter( + Boolean + ) + : null; const { read, ...actions } = useEntity(entity, id) as any; - const fetcher = id ? () => read(query) : () => null; - const swr = useSWR(id ? key : null, fetcher, options); + const fetcher = () => read(query); + + type T = Awaited>; + const swr = useSWR(key, fetcher, { + revalidateOnFocus: false, + keepPreviousData: false, + ...options + }); const mapped = objectTransform(actions, (action) => { if (action === "read") return; return async (...args) => { - return swr.mutate(async () => { - const res = await action(...args); - return res; - }); + return swr.mutate(action(...args)) as any; }; }) as Omit>, "read">; @@ -74,3 +103,18 @@ export const useEntityQuery = < key }; }; + +export const useEntityMutate = < + Entity extends string, + Id extends PrimaryFieldType | undefined = undefined +>( + entity: Entity, + id?: Id, + options?: SWRConfiguration +) => { + const { data, ...$q } = useEntityQuery(entity, id, undefined, { + ...options, + enabled: false + }); + return $q; +}; diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index c03d4b0..9bed2c4 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -1,6 +1,8 @@ import { type NotificationData, notifications } from "@mantine/notifications"; import { ucFirst } from "core/utils"; -import type { ApiResponse, ModuleConfigs } from "../../../modules"; +import type { ModuleConfigs } from "modules"; +import type { ResponseObject } from "modules/ModuleApi"; +import type { ConfigUpdateResponse } from "modules/server/SystemController"; import type { AppQueryClient } from "../utils/AppQueryClient"; export type SchemaActionsProps = { @@ -14,10 +16,10 @@ export type TSchemaActions = ReturnType; export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) { const api = client.api; - async function handleConfigUpdate( + async function handleConfigUpdate( action: string, - module: string, - res: ApiResponse, + module: Module, + res: ResponseObject>, path?: string ): Promise { const base: Partial = { @@ -26,7 +28,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi autoClose: 3000 }; - if (res.res.ok && res.body.success) { + if (res.success === true) { console.log("update config", action, module, path, res.body); if (res.body.success) { setSchema((prev) => { @@ -35,7 +37,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi ...prev, config: { ...prev.config, - [module]: res.body.config + [module]: res.config } }; }); @@ -47,18 +49,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi color: "blue", message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}` }); - return true; + } else { + notifications.show({ + ...base, + title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`, + color: "red", + withCloseButton: true, + autoClose: false, + message: res.error ?? "Failed to complete config update" + }); } - notifications.show({ - ...base, - title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`, - color: "red", - withCloseButton: true, - autoClose: false, - message: res.body.error ?? "Failed to complete config update" - }); - return false; + return res.success; } return { @@ -72,7 +74,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi return await handleConfigUpdate("set", module, res); }, patch: async ( - module: keyof ModuleConfigs, + module: Module, path: string, value: any ): Promise => { @@ -80,25 +82,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi return await handleConfigUpdate("patch", module, res, path); }, overwrite: async ( - module: keyof ModuleConfigs, + module: Module, path: string, value: any ) => { const res = await api.system.overwriteConfig(module, path, value); return await handleConfigUpdate("overwrite", module, res, path); }, - add: async ( - module: keyof ModuleConfigs, - path: string, - value: any - ) => { + add: async (module: Module, path: string, value: any) => { const res = await api.system.addConfig(module, path, value); return await handleConfigUpdate("add", module, res, path); }, - remove: async ( - module: keyof ModuleConfigs, - path: string - ) => { + remove: async (module: Module, path: string) => { const res = await api.system.removeConfig(module, path); return await handleConfigUpdate("remove", module, res, path); } diff --git a/app/src/ui/client/utils/AppQueryClient.ts b/app/src/ui/client/utils/AppQueryClient.ts index 8eecf9e..239bd93 100644 --- a/app/src/ui/client/utils/AppQueryClient.ts +++ b/app/src/ui/client/utils/AppQueryClient.ts @@ -36,12 +36,10 @@ export class AppQueryClient { state: (): (AuthResponse & { verified: boolean }) | undefined => { return this.api.getAuthState() as any; }, - login: async (data: { email: string; password: string }): Promise< - ApiResponse - > => { + login: async (data: { email: string; password: string }) => { return await this.api.auth.loginWithPassword(data); }, - register: async (data: any): Promise> => { + register: async (data: any) => { return await this.api.auth.registerWithPassword(data); }, logout: async () => { @@ -57,7 +55,7 @@ export class AppQueryClient { //console.log("verifiying"); const res = await this.api.auth.me(); //console.log("verifying result", res); - if (!res.res.ok || !res.body.user) { + if (!res.ok || !res.body.user) { throw new Error(); } @@ -90,7 +88,7 @@ export class AppQueryClient { typeof filename === "string" ? filename : filename.path ); - if (res.res.ok) { + if (res.ok) { queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] }); return true; } diff --git a/app/src/ui/components/display/Logo.tsx b/app/src/ui/components/display/Logo.tsx index 89ad5bd..806ee6d 100644 --- a/app/src/ui/components/display/Logo.tsx +++ b/app/src/ui/components/display/Logo.tsx @@ -1,9 +1,13 @@ -import { useBknd } from "../../client/BkndProvider"; +import { useBknd } from "ui/client/bknd"; -export function Logo({ scale = 0.2, fill }: { scale?: number; fill?: string }) { - const { app } = useBknd(); - const theme = app.getAdminConfig().color_scheme; - const svgFill = fill ? fill : theme === "light" ? "black" : "white"; +export function Logo({ + scale = 0.2, + fill, + theme = "light" +}: { scale?: number; fill?: string; theme?: string }) { + const $bknd = useBknd(); + const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light"; + const svgFill = fill ? fill : _theme === "light" ? "black" : "white"; const dim = { width: Math.round(578 * scale), diff --git a/app/src/ui/components/table/DataTable.tsx b/app/src/ui/components/table/DataTable.tsx index 3ee0b6e..8aa664f 100644 --- a/app/src/ui/components/table/DataTable.tsx +++ b/app/src/ui/components/table/DataTable.tsx @@ -29,7 +29,7 @@ export const Check = () => { }; export type DataTableProps = { - data: Data[]; + data: Data[] | null; // "null" implies loading columns?: string[]; checkable?: boolean; onClickRow?: (row: Data) => void; @@ -71,10 +71,10 @@ export function DataTable = Record renderValue, onClickNew }: DataTableProps) { - total = total || data.length; + total = total || data?.length || 0; page = page || 1; - const select = columns && columns.length > 0 ? columns : Object.keys(data[0] || {}); + const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {}); const pages = Math.max(Math.ceil(total / perPage), 1); const CellRender = renderValue || CellValue; @@ -129,7 +129,9 @@ export function DataTable = Record
- No data to show + + {Array.isArray(data) ? "No data to show" : "Loading..."} +
@@ -188,7 +190,12 @@ export function DataTable = Record
- +
{perPageOptions && ( diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 003aba4..e61e1d5 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -1,14 +1,12 @@ -import { useClickOutside, useDisclosure, useHotkeys, useViewportSize } from "@mantine/hooks"; +import { useClickOutside, useHotkeys } from "@mantine/hooks"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; -import { ucFirst } from "core/utils"; import { throttle } from "lodash-es"; -import { type ComponentProps, createContext, useContext, useEffect, useRef, useState } from "react"; +import { type ComponentProps, useEffect, useRef, useState } from "react"; import type { IconType } from "react-icons"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; -import { Link } from "wouter"; import { useEvent } from "../../hooks/use-event"; export function Root({ children }) { diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index e7154e6..754eed6 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -24,7 +24,7 @@ import { useNavigate } from "ui/lib/routes"; import { useLocation } from "wouter"; import { NavLink } from "./AppShell"; -function HeaderNavigation() { +export function HeaderNavigation() { const [location, navigate] = useLocation(); const items: { diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index 8a42699..cb8c16e 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -4,7 +4,7 @@ import { ucFirst } from "core/utils"; import type { EntityData, RelationField } from "data"; import { useEffect, useRef, useState } from "react"; import { TbEye } from "react-icons/tb"; -import { useClient } from "ui/client"; +import { useClient, useEntityQuery } from "ui/client"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; import * as Formy from "ui/components/form/Formy"; @@ -31,25 +31,21 @@ export function EntityRelationalFormField({ const { app } = useBknd(); const entity = app.entity(field.target())!; const [query, setQuery] = useState({ limit: 10, page: 1, perPage: 10 }); - const [location, navigate] = useLocation(); + const [, navigate] = useLocation(); const ref = useRef(null); const client = useClient(); - const container = useEntities( - field.target(), - { - limit: query.limit, - offset: (query.page - 1) * query.limit - //select: entity.getSelect(undefined, "form") - }, - { enabled: true } - ); + const $q = useEntityQuery(field.target(), undefined, { + limit: query.limit, + offset: (query.page - 1) * query.limit + }); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); const referenceField = data?.[field.reference()]; const relationalField = data?.[field.name]; useEffect(() => { - _setValue(data?.[field.reference()]); + const value = data?.[field.reference()]; + _setValue(value); }, [referenceField]); useEffect(() => { @@ -57,62 +53,40 @@ export function EntityRelationalFormField({ const rel_value = field.target(); if (!rel_value || !relationalField) return; - console.log("-- need to fetch", field.target(), relationalField); const fetched = await client.api.data.readOne(field.target(), relationalField); - if (fetched.res.ok && fetched.data) { + if (fetched.ok && fetched.data) { _setValue(fetched.data as any); } - console.log("-- fetched", fetched); - - console.log("relation", { - referenceField, - relationalField, - data, - field, - entity - }); })(); }, [relationalField]); - /*const initialValue: { id: number | undefined; [key: string]: any } = data?.[ - field.reference() - ] ?? { - id: data?.[field.name], - };*/ - function handleViewItem(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); - console.log("yo"); if (_value) { navigate(routes.data.entity.edit(entity.name, _value.id as any)); } } - /*console.log( - "relationfield:data", - { _value, initialValue }, - data, - field.reference(), - entity, - //container.entity, - //data[field.reference()], - data?.[field.name], - field, - );*/ - // fix missing value on fields that are required useEffect(() => { if (field.isRequired() && !fieldApi.state.value) { - fieldApi.setValue(container.data?.[0]?.id); + const firstValue = $q.data?.[0]; + if (!firstValue) return; + + console.warn("setting first value because field is required", field.name, firstValue.id); + fieldApi.setValue(firstValue.id); + _setValue(firstValue as any); } - }, [container.data]); + }, [$q.data]); + + const fetching = $q.isLoading || $q.isValidating; return ( {field.getLabel()}
( - {/* - {container.data ? ( - <> - {emptyOption} - {!field.isRequired() && emptyOption} - {container.data?.map(renderRow)} - - ) : ( - - )} - */} ); } diff --git a/app/src/ui/modules/data/hooks/useEntityForm.tsx b/app/src/ui/modules/data/hooks/useEntityForm.tsx index 7b991ea..45432d7 100644 --- a/app/src/ui/modules/data/hooks/useEntityForm.tsx +++ b/app/src/ui/modules/data/hooks/useEntityForm.tsx @@ -19,18 +19,16 @@ export function useEntityForm({ // @todo: check if virtual must be filtered const fields = entity.getFillableFields(action, true); - console.log("useEntityForm:data", data); - // filter defaultValues to only contain fillable fields const defaultValues = getDefaultValues(fields, data); - console.log("useEntityForm:defaultValues", data); + //console.log("useEntityForm", { data, defaultValues }); const Form = useForm({ defaultValues, validators: { onSubmitAsync: async ({ value }): Promise => { try { - console.log("validating", value, entity.isValidData(value, action)); + //console.log("validating", value, entity.isValidData(value, action)); entity.isValidData(value, action, true); return undefined; } catch (e) { @@ -40,7 +38,7 @@ export function useEntityForm({ } }, onSubmit: async ({ value, formApi }) => { - console.log("onSubmit", value); + //console.log("onSubmit", value); if (!entity.isValidData(value, action)) { console.error("invalid data", value); return; @@ -49,7 +47,7 @@ export function useEntityForm({ if (!data) return; const changeSet = getChangeSet(action, value, data, fields); - console.log("changesSet", action, changeSet); + //console.log("changesSet", action, changeSet, { data }); // only submit change set if there were changes await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet); diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index 20349e6..7b6fcf0 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -2,12 +2,11 @@ import { ucFirst } from "core/utils"; import type { Entity, EntityData, EntityRelation } from "data"; import { Fragment, useState } from "react"; import { TbDots } from "react-icons/tb"; -import { useClient } from "ui/client"; +import { useClient, useEntityQuery } from "ui/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Dropdown } from "ui/components/overlay/Dropdown"; -import { useEntity } from "ui/container"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; @@ -25,22 +24,23 @@ export function DataEntityUpdate({ params }) { const [navigate] = useNavigate(); useBrowserTitle(["Data", entity.label, `#${entityId}`]); const targetRelations = relations.listableRelationsOf(entity); - //console.log("targetRelations", targetRelations, relations.relationsOf(entity)); - // filter out polymorphic for now - //.filter((r) => r.type() !== "poly"); + const local_relation_refs = relations .sourceRelationsOf(entity) ?.map((r) => r.other(entity).reference); - const container = useEntity(entity.name, entityId, { - fetch: { - query: { - with: local_relation_refs - } + const $q = useEntityQuery( + entity.name, + entityId, + { + with: local_relation_refs + }, + { + revalidateOnFocus: false } - }); + ); - function goBack(state?: Record) { + function goBack() { window.history.go(-1); } @@ -52,43 +52,39 @@ export function DataEntityUpdate({ params }) { return; } - const res = await container.actions.update(changeSet); - console.log("update:res", res); - if (res.data?.error) { - setError(res.data.error); - } else { - error && setError(null); + try { + await $q.update(changeSet); + if (error) setError(null); goBack(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update"); } } async function handleDelete() { if (confirm("Are you sure to delete?")) { - const res = await container.actions.remove(); - if (res.error) { - setError(res.error); - } else { - error && setError(null); + try { + await $q._delete(); + if (error) setError(null); goBack(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete"); } } } + const data = $q.data; const { Form, handleSubmit } = useEntityForm({ action: "update", entity, - initialData: container.data, + initialData: $q.data?.toJSON(), onSubmitted }); - //console.log("form.data", Form.state.values, container.data); const makeKey = (key: string | number = "") => `${params.entity.name}_${entityId}_${String(key)}`; - const fieldsDisabled = - container.raw.fetch?.isLoading || - container.status.fetch.isUpdating || - Form.state.isSubmitting; + const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting; return ( @@ -103,7 +99,7 @@ export function DataEntityUpdate({ params }) { onClick: () => { bkndModals.open("debug", { data: { - data: container.data as any, + data: data as any, entity: entity.toJSON(), schema: entity.toSchema(true), form: Form.state.values, @@ -165,7 +161,7 @@ export function DataEntityUpdate({ params }) { entityId={entityId} handleSubmit={handleSubmit} fieldsDisabled={fieldsDisabled} - data={container.data ?? undefined} + data={data ?? undefined} Form={Form} action="update" className="flex flex-grow flex-col gap-3 p-3" diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 3d3a09a..3df30db 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -1,15 +1,16 @@ import { Type } from "core/utils"; import { useState } from "react"; +import { useEntityMutate, useEntityQuery } from "ui/client"; +import { useBknd } from "ui/client/BkndProvider"; +import { Button } from "ui/components/buttons/Button"; +import { type EntityData, useEntity } from "ui/container"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import { useSearch } from "ui/hooks/use-search"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; +import { routes } from "ui/lib/routes"; import { EntityForm } from "ui/modules/data/components/EntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; -import { useBknd } from "../../client/BkndProvider"; -import { Button } from "../../components/buttons/Button"; -import { type EntityData, useEntity } from "../../container"; -import { useBrowserTitle } from "../../hooks/use-browser-title"; -import { useSearch } from "../../hooks/use-search"; -import * as AppShell from "../../layouts/AppShell/AppShell"; -import { Breadcrumbs2 } from "../../layouts/AppShell/Breadcrumbs2"; -import { routes } from "../../lib/routes"; export function DataEntityCreate({ params }) { const { app } = useBknd(); @@ -17,40 +18,37 @@ export function DataEntityCreate({ params }) { const [error, setError] = useState(null); useBrowserTitle(["Data", entity.label, "Create"]); - const container = useEntity(entity.name); + const $q = useEntityMutate(entity.name); + // @todo: use entity schema for prefilling const search = useSearch(Type.Object({}), {}); - console.log("search", search.value); - function goBack(state?: Record) { + function goBack() { window.history.go(-1); } async function onSubmitted(changeSet?: EntityData) { console.log("create:changeSet", changeSet); - //return; - const res = await container.actions.create(changeSet); - console.log("create:res", res); - if (res.data?.error) { - setError(res.data.error); - } else { - error && setError(null); + if (!changeSet) return; + + try { + await $q.create(changeSet); + if (error) setError(null); // @todo: navigate to created? goBack(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create"); } } - const { Form, handleSubmit, values } = useEntityForm({ + const { Form, handleSubmit } = useEntityForm({ action: "create", entity, initialData: search.value, onSubmitted }); - const fieldsDisabled = - container.raw.fetch?.isLoading || - container.status.fetch.isUpdating || - Form.state.isSubmitting; + const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting; return ( <> diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 13b81b3..831e5ff 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -1,12 +1,12 @@ import { Type } from "core/utils"; import { querySchema } from "data"; import { TbDots } from "react-icons/tb"; +import { useApiQuery } from "ui/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Message } from "ui/components/display/Message"; import { Dropdown } from "ui/components/overlay/Dropdown"; -import { EntitiesContainer } from "ui/container"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useSearch } from "ui/hooks/use-search"; import * as AppShell from "ui/layouts/AppShell/AppShell"; @@ -25,19 +25,33 @@ const searchSchema = Type.Composite( { additionalProperties: false } ); +const PER_PAGE_OPTIONS = [5, 10, 25]; + export function DataEntityList({ params }) { - const { $data, relations } = useBkndData(); - const entity = $data.entity(params.entity as string); + const { $data } = useBkndData(); + const entity = $data.entity(params.entity as string)!; + useBrowserTitle(["Data", entity?.label ?? params.entity]); const [navigate] = useNavigate(); const search = useSearch(searchSchema, { select: entity?.getSelect(undefined, "table") ?? [], sort: entity?.getDefaultSort() }); - console.log("search", search.value); - useBrowserTitle(["Data", entity?.label ?? params.entity]); - const PER_PAGE_OPTIONS = [5, 10, 25]; - //console.log("search", search.value); + const $q = useApiQuery( + (api) => + api.data.readMany(entity.name, { + select: search.value.select, + limit: search.value.perPage, + offset: (search.value.page - 1) * search.value.perPage, + sort: search.value.sort + }), + { + revalidateOnFocus: true, + keepPreviousData: true + } + ); + const data = $q.data?.data; + const meta = $q.data?.body.meta; function handleClickRow(row: Record) { if (entity) navigate(routes.data.entity.edit(entity.name, row.id)); @@ -65,6 +79,8 @@ export function DataEntityList({ params }) { return ; } + const isUpdating = $q.isLoading && $q.isValidating; + return ( <>
*/} - - {(params) => { - if (params.status.fetch.isLoading) { - return null; - } - - const isUpdating = params.status.fetch.isUpdating; - - return ( -
- -
- ); - }} -
+ +