updated admin to use swr hooks instead of react-query

This commit is contained in:
dswbx
2024-12-13 16:24:55 +01:00
parent 50c5adce0c
commit 8c91dff94d
20 changed files with 380 additions and 275 deletions

View File

@@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async loginWithPassword(input: any) { async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input); const res = await this.post<AuthResponse>(["password", "login"], input);
if (res.res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
@@ -23,7 +23,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async registerWithPassword(input: any) { async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input); const res = await this.post<AuthResponse>(["password", "register"], input);
if (res.res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;

View File

@@ -18,6 +18,7 @@ export function getChangeSet(
data: EntityData, data: EntityData,
fields: Field[] fields: Field[]
): EntityData { ): EntityData {
//console.log("getChangeSet", formData, data);
return transform( return transform(
formData, formData,
(acc, _value, key) => { (acc, _value, key) => {
@@ -26,11 +27,12 @@ export function getChangeSet(
if (!field || field.isVirtual()) return; if (!field || field.isVirtual()) return;
const value = _value === "" ? null : _value; 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" // @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) { if (action === "create" || newValue !== data[key]) {
acc[key] = newValue; acc[key] = newValue;
console.log("changed", { /*console.log("changed", {
key, key,
value, value,
valueType: typeof value, valueType: typeof value,
@@ -38,7 +40,7 @@ export function getChangeSet(
newValue, newValue,
new: value, new: value,
sent: acc[key] sent: acc[key]
}); });*/
} else { } else {
//console.log("no change", key, value, data[key]); //console.log("no change", key, value, data[key]);
} }

View File

@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core"; import { type PrimaryFieldType, isDebug } from "core";
import { encodeSearch } from "core/utils"; import { encodeSearch } from "core/utils";
export type { PrimaryFieldType }; export type { PrimaryFieldType };
@@ -10,6 +10,7 @@ export type BaseModuleApiOptions = {
token_transport?: "header" | "cookie" | "none"; token_transport?: "header" | "cookie" | "none";
}; };
/** @deprecated */
export type ApiResponse<Data = any> = { export type ApiResponse<Data = any> = {
success: boolean; success: boolean;
status: number; status: number;
@@ -47,7 +48,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
_input: TInput, _input: TInput,
_query?: Record<string, any> | URLSearchParams, _query?: Record<string, any> | URLSearchParams,
_init?: RequestInit _init?: RequestInit
): FetchPromise<ApiResponse<Data>> { ): FetchPromise<ResponseObject<Data>> {
const method = _init?.method ?? "GET"; const method = _init?.method ?? "GET";
const input = Array.isArray(_input) ? _input.join("/") : _input; const input = Array.isArray(_input) ? _input.join("/") : _input;
let url = this.getUrl(input); let url = this.getUrl(input);
@@ -138,6 +139,58 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
} }
} }
export type ResponseObject<Body = any, Data = Body extends { data: infer R } ? R : Body> = Data & {
raw: Response;
res: Response;
data: Data;
body: Body;
ok: boolean;
status: number;
toJSON(): Data;
};
export function createResponseProxy<Body = any, Data = any>(
raw: Response,
body: Body,
data?: Data
): ResponseObject<Body, Data> {
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<Body, Data>;
}
export class FetchPromise<T = ApiResponse<any>> implements Promise<T> { export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
// @ts-ignore // @ts-ignore
[Symbol.toStringTag]: "FetchPromise"; [Symbol.toStringTag]: "FetchPromise";
@@ -149,7 +202,10 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
} }
) {} ) {}
async execute(): Promise<T> { async execute(): Promise<ResponseObject<T>> {
// delay in dev environment
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
const fetcher = this.options?.fetcher ?? fetch; const fetcher = this.options?.fetcher ?? fetch;
const res = await fetcher(this.request); const res = await fetcher(this.request);
let resBody: any; let resBody: any;
@@ -165,13 +221,7 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
resBody = await res.text(); resBody = await res.text();
} }
return { return createResponseProxy<T>(res, resBody, resData);
success: res.ok,
status: res.status,
body: resBody,
data: resData,
res
} as T;
} }
// biome-ignore lint/suspicious/noThenProperty: it's a promise :) // biome-ignore lint/suspicious/noThenProperty: it's a promise :)

View File

@@ -1,3 +1,4 @@
import type { ConfigUpdateResponse } from "modules/server/SystemController";
import { ModuleApi } from "./ModuleApi"; import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
@@ -15,37 +16,37 @@ export class SystemApi extends ModuleApi<any> {
}; };
} }
async readSchema(options?: { config?: boolean; secrets?: boolean }) { readSchema(options?: { config?: boolean; secrets?: boolean }) {
return await this.get<ApiSchemaResponse>("schema", { return this.get<ApiSchemaResponse>("schema", {
config: options?.config ? 1 : 0, config: options?.config ? 1 : 0,
secrets: options?.secrets ? 1 : 0 secrets: options?.secrets ? 1 : 0
}); });
} }
async setConfig<Module extends ModuleKey>( setConfig<Module extends ModuleKey>(
module: Module, module: Module,
value: ModuleConfigs[Module], value: ModuleConfigs[Module],
force?: boolean force?: boolean
) { ) {
return await this.post<any>( return this.post<ConfigUpdateResponse>(
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`, ["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
value value
); );
} }
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.post<any>(["config", "add", module, path], value); return this.post<ConfigUpdateResponse>(["config", "add", module, path], value);
} }
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.patch<any>(["config", "patch", module, path], value); return this.patch<ConfigUpdateResponse>(["config", "patch", module, path], value);
} }
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.put<any>(["config", "overwrite", module, path], value); return this.put<ConfigUpdateResponse>(["config", "overwrite", module, path], value);
} }
async removeConfig<Module extends ModuleKey>(module: Module, path: string) { removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return await this.delete<any>(["config", "remove", module, path]); return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
} }
} }

View File

@@ -1,18 +1,32 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import type { App } from "App";
import type { ClassController } from "core"; import type { ClassController } from "core";
import { tbValidator as tb } from "core"; import { tbValidator as tb } from "core";
import { StringEnum, Type, TypeInvalidError } from "core/utils"; import { StringEnum, Type, TypeInvalidError } from "core/utils";
import { type Context, Hono } from "hono"; 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 * as SystemPermissions from "modules/permissions";
import { generateOpenAPI } from "modules/server/openapi"; import { generateOpenAPI } from "modules/server/openapi";
import type { App } from "../../App";
const booleanLike = Type.Transform(Type.String()) const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1") .Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0")); .Encode((v) => (v ? "1" : "0"));
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
config: ModuleConfigs[Key];
};
export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
| ConfigUpdate<Key>
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
export class SystemController implements ClassController { export class SystemController implements ClassController {
constructor(private readonly app: App) {} constructor(private readonly app: App) {}
@@ -60,7 +74,7 @@ export class SystemController implements ClassController {
} }
); );
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<object>) { async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
try { try {
return c.json(await cb(), { status: 202 }); return c.json(await cb(), { status: 202 });
} catch (e) { } catch (e) {

View File

@@ -3,6 +3,8 @@ import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules"; import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; 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 { FlashMessage } from "ui/modules/server/FlashMessage";
import { ClientProvider, type ClientProviderProps } from "./client"; import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
@@ -21,7 +23,7 @@ export default function Admin({
config config
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider adminOverride={config}> <BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}>
<AdminInternal /> <AdminInternal />
</BkndProvider> </BkndProvider>
); );
@@ -51,3 +53,41 @@ function AdminInternal() {
</MantineProvider> </MantineProvider>
); );
} }
const Skeleton = ({ theme = "light" }: { theme?: string }) => {
return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
<AppShell.Root>
<header
data-shell="header"
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
>
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo />
</div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => (
<AppShell.NavLink key={key} as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
</AppShell.NavLink>
))}
</nav>
<nav className="flex md:hidden flex-row items-center">
<AppShell.NavLink as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
</AppShell.NavLink>
</nav>
<div className="flex flex-grow" />
<div className="hidden lg:flex flex-row items-center px-4 gap-2 opacity-50">
<div className="size-11 rounded-full bg-primary/10" />
</div>
</header>
<AppShell.Content>
<div className="flex flex-col w-full h-full justify-center items-center">
<span className="font-mono opacity-30">Loading</span>
</div>
</AppShell.Content>
</AppShell.Root>
</div>
);
};

View File

@@ -1,5 +1,10 @@
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; 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 type { ModuleConfigs, ModuleSchemas } from "../../modules";
import { useClient } from "./ClientProvider"; import { useClient } from "./ClientProvider";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
@@ -22,8 +27,12 @@ export type { TSchemaActions };
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, adminOverride,
children children,
}: { includeSecrets?: boolean; children: any } & Pick<BkndContext, "adminOverride">) { fallback = null
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick<
BkndContext,
"adminOverride"
>) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
@@ -37,7 +46,7 @@ export function BkndProvider({
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
if (withSecrets && !force) return; if (withSecrets && !force) return;
const { body, res } = await client.api.system.readSchema({ const res = await client.api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
}); });
@@ -57,7 +66,7 @@ export function BkndProvider({
} }
const schema = res.ok const schema = res.ok
? body ? res.body
: ({ : ({
version: 0, version: 0,
schema: getDefaultSchema(), schema: getDefaultSchema(),
@@ -89,7 +98,7 @@ export function BkndProvider({
fetchSchema(includeSecrets); fetchSchema(includeSecrets);
}, []); }, []);
if (!fetched || !schema) return null; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ client, setSchema, reloadSchema }); const actions = getSchemaActions({ client, setSchema, reloadSchema });

View File

@@ -1,6 +1,6 @@
import type { Api } from "Api"; import type { Api } from "Api";
import type { FetchPromise } from "modules/ModuleApi"; import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; import useSWR, { type SWRConfiguration } from "swr";
import { useClient } from "ui/client/ClientProvider"; import { useClient } from "ui/client/ClientProvider";
export const useApi = () => { export const useApi = () => {
@@ -8,13 +8,16 @@ export const useApi = () => {
return client.api; return client.api;
}; };
export const useApiQuery = <Data, RefineFn extends (data: Data) => any = (data: Data) => Data>( export const useApiQuery = <
Data,
RefineFn extends (data: ResponseObject<Data>) => any = (data: ResponseObject<Data>) => Data
>(
fn: (api: Api) => FetchPromise<Data>, fn: (api: Api) => FetchPromise<Data>,
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn } options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
) => { ) => {
const api = useApi(); const api = useApi();
const promise = fn(api); const promise = fn(api);
const refine = options?.refine ?? ((data: Data) => data); const refine = options?.refine ?? ((data: ResponseObject<Data>) => data);
const fetcher = () => promise.execute().then(refine); const fetcher = () => promise.execute().then(refine);
const key = promise.key(); const key = promise.key();

View File

@@ -1,9 +1,20 @@
import type { PrimaryFieldType } from "core"; import type { PrimaryFieldType } from "core";
import { objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data"; import type { EntityData, RepoQuery } from "data";
import type { ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration } from "swr"; import useSWR, { type SWRConfiguration } from "swr";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(
public payload: Payload,
public response: Response,
message?: string
) {
super(message ?? "UseEntityApiError");
}
}
export const useEntity = < export const useEntity = <
Entity extends string, Entity extends string,
Id extends PrimaryFieldType | undefined = undefined Id extends PrimaryFieldType | undefined = undefined
@@ -16,18 +27,27 @@ export const useEntity = <
return { return {
create: async (input: EntityData) => { create: async (input: EntityData) => {
const res = await api.createOne(entity, input); 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<RepoQuery> = {}) => { read: async (query: Partial<RepoQuery> = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); 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<EntityData>, _id: PrimaryFieldType | undefined = id) => { update: async (input: Partial<EntityData>, _id: PrimaryFieldType | undefined = id) => {
if (!_id) { if (!_id) {
throw new Error("id is required"); throw new Error("id is required");
} }
const res = await api.updateOne(entity, _id, input); 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) => { _delete: async (_id: PrimaryFieldType | undefined = id) => {
if (!_id) { if (!_id) {
@@ -35,7 +55,10 @@ export const useEntity = <
} }
const res = await api.deleteOne(entity, _id); 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, entity: Entity,
id?: Id, id?: Id,
query?: Partial<RepoQuery>, query?: Partial<RepoQuery>,
options?: SWRConfiguration options?: SWRConfiguration & { enabled?: boolean }
) => { ) => {
const api = useApi().data; const api = useApi().data;
const key = [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter( const key =
Boolean options?.enabled !== false
); ? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter(
Boolean
)
: null;
const { read, ...actions } = useEntity(entity, id) as any; const { read, ...actions } = useEntity(entity, id) as any;
const fetcher = id ? () => read(query) : () => null; const fetcher = () => read(query);
const swr = useSWR<EntityData>(id ? key : null, fetcher, options);
type T = Awaited<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>;
const swr = useSWR<T>(key, fetcher, {
revalidateOnFocus: false,
keepPreviousData: false,
...options
});
const mapped = objectTransform(actions, (action) => { const mapped = objectTransform(actions, (action) => {
if (action === "read") return; if (action === "read") return;
return async (...args) => { return async (...args) => {
return swr.mutate(async () => { return swr.mutate(action(...args)) as any;
const res = await action(...args);
return res;
});
}; };
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">; }) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
@@ -74,3 +103,18 @@ export const useEntityQuery = <
key 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;
};

View File

@@ -1,6 +1,8 @@
import { type NotificationData, notifications } from "@mantine/notifications"; import { type NotificationData, notifications } from "@mantine/notifications";
import { ucFirst } from "core/utils"; 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"; import type { AppQueryClient } from "../utils/AppQueryClient";
export type SchemaActionsProps = { export type SchemaActionsProps = {
@@ -14,10 +16,10 @@ export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) { export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) {
const api = client.api; const api = client.api;
async function handleConfigUpdate( async function handleConfigUpdate<Module extends keyof ModuleConfigs>(
action: string, action: string,
module: string, module: Module,
res: ApiResponse, res: ResponseObject<ConfigUpdateResponse<Module>>,
path?: string path?: string
): Promise<boolean> { ): Promise<boolean> {
const base: Partial<NotificationData> = { const base: Partial<NotificationData> = {
@@ -26,7 +28,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
autoClose: 3000 autoClose: 3000
}; };
if (res.res.ok && res.body.success) { if (res.success === true) {
console.log("update config", action, module, path, res.body); console.log("update config", action, module, path, res.body);
if (res.body.success) { if (res.body.success) {
setSchema((prev) => { setSchema((prev) => {
@@ -35,7 +37,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
...prev, ...prev,
config: { config: {
...prev.config, ...prev.config,
[module]: res.body.config [module]: res.config
} }
}; };
}); });
@@ -47,18 +49,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
color: "blue", color: "blue",
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}` 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({ return res.success;
...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 { return {
@@ -72,7 +74,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
return await handleConfigUpdate("set", module, res); return await handleConfigUpdate("set", module, res);
}, },
patch: async <Module extends keyof ModuleConfigs>( patch: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: Module,
path: string, path: string,
value: any value: any
): Promise<boolean> => { ): Promise<boolean> => {
@@ -80,25 +82,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
return await handleConfigUpdate("patch", module, res, path); return await handleConfigUpdate("patch", module, res, path);
}, },
overwrite: async <Module extends keyof ModuleConfigs>( overwrite: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: Module,
path: string, path: string,
value: any value: any
) => { ) => {
const res = await api.system.overwriteConfig(module, path, value); const res = await api.system.overwriteConfig(module, path, value);
return await handleConfigUpdate("overwrite", module, res, path); return await handleConfigUpdate("overwrite", module, res, path);
}, },
add: async <Module extends keyof ModuleConfigs>( add: async <Module extends keyof ModuleConfigs>(module: Module, path: string, value: any) => {
module: keyof ModuleConfigs,
path: string,
value: any
) => {
const res = await api.system.addConfig(module, path, value); const res = await api.system.addConfig(module, path, value);
return await handleConfigUpdate("add", module, res, path); return await handleConfigUpdate("add", module, res, path);
}, },
remove: async <Module extends keyof ModuleConfigs>( remove: async <Module extends keyof ModuleConfigs>(module: Module, path: string) => {
module: keyof ModuleConfigs,
path: string
) => {
const res = await api.system.removeConfig(module, path); const res = await api.system.removeConfig(module, path);
return await handleConfigUpdate("remove", module, res, path); return await handleConfigUpdate("remove", module, res, path);
} }

View File

@@ -36,12 +36,10 @@ export class AppQueryClient {
state: (): (AuthResponse & { verified: boolean }) | undefined => { state: (): (AuthResponse & { verified: boolean }) | undefined => {
return this.api.getAuthState() as any; return this.api.getAuthState() as any;
}, },
login: async (data: { email: string; password: string }): Promise< login: async (data: { email: string; password: string }) => {
ApiResponse<AuthResponse>
> => {
return await this.api.auth.loginWithPassword(data); return await this.api.auth.loginWithPassword(data);
}, },
register: async (data: any): Promise<ApiResponse<AuthResponse>> => { register: async (data: any) => {
return await this.api.auth.registerWithPassword(data); return await this.api.auth.registerWithPassword(data);
}, },
logout: async () => { logout: async () => {
@@ -57,7 +55,7 @@ export class AppQueryClient {
//console.log("verifiying"); //console.log("verifiying");
const res = await this.api.auth.me(); const res = await this.api.auth.me();
//console.log("verifying result", res); //console.log("verifying result", res);
if (!res.res.ok || !res.body.user) { if (!res.ok || !res.body.user) {
throw new Error(); throw new Error();
} }
@@ -90,7 +88,7 @@ export class AppQueryClient {
typeof filename === "string" ? filename : filename.path typeof filename === "string" ? filename : filename.path
); );
if (res.res.ok) { if (res.ok) {
queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] }); queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] });
return true; return true;
} }

View File

@@ -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 }) { export function Logo({
const { app } = useBknd(); scale = 0.2,
const theme = app.getAdminConfig().color_scheme; fill,
const svgFill = fill ? fill : theme === "light" ? "black" : "white"; 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 = { const dim = {
width: Math.round(578 * scale), width: Math.round(578 * scale),

View File

@@ -29,7 +29,7 @@ export const Check = () => {
}; };
export type DataTableProps<Data> = { export type DataTableProps<Data> = {
data: Data[]; data: Data[] | null; // "null" implies loading
columns?: string[]; columns?: string[];
checkable?: boolean; checkable?: boolean;
onClickRow?: (row: Data) => void; onClickRow?: (row: Data) => void;
@@ -71,10 +71,10 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
renderValue, renderValue,
onClickNew onClickNew
}: DataTableProps<Data>) { }: DataTableProps<Data>) {
total = total || data.length; total = total || data?.length || 0;
page = page || 1; 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 pages = Math.max(Math.ceil(total / perPage), 1);
const CellRender = renderValue || CellValue; const CellRender = renderValue || CellValue;
@@ -129,7 +129,9 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
<tr> <tr>
<td colSpan={select.length + (checkable ? 1 : 0)}> <td colSpan={select.length + (checkable ? 1 : 0)}>
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted"> <div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
<i className="opacity-50">No data to show</i> <i className="opacity-50">
{Array.isArray(data) ? "No data to show" : "Loading..."}
</i>
</div> </div>
</td> </td>
</tr> </tr>
@@ -188,7 +190,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div> </div>
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="hidden md:flex text-primary/40"> <div className="hidden md:flex text-primary/40">
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} /> <TableDisplay
perPage={perPage}
page={page}
items={data?.length || 0}
total={total}
/>
</div> </div>
<div className="flex flex-row gap-2 md:gap-10 items-center"> <div className="flex flex-row gap-2 md:gap-10 items-center">
{perPageOptions && ( {perPageOptions && (

View File

@@ -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 * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { throttle } from "lodash-es"; 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 type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { Link } from "wouter";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
export function Root({ children }) { export function Root({ children }) {

View File

@@ -24,7 +24,7 @@ import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const items: { const items: {

View File

@@ -4,7 +4,7 @@ import { ucFirst } from "core/utils";
import type { EntityData, RelationField } from "data"; import type { EntityData, RelationField } from "data";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb"; import { TbEye } from "react-icons/tb";
import { useClient } from "ui/client"; import { useClient, useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
@@ -31,25 +31,21 @@ export function EntityRelationalFormField({
const { app } = useBknd(); const { app } = useBknd();
const entity = app.entity(field.target())!; const entity = app.entity(field.target())!;
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 }); const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
const [location, navigate] = useLocation(); const [, navigate] = useLocation();
const ref = useRef<any>(null); const ref = useRef<any>(null);
const client = useClient(); const client = useClient();
const container = useEntities( const $q = useEntityQuery(field.target(), undefined, {
field.target(), limit: query.limit,
{ offset: (query.page - 1) * query.limit
limit: query.limit, });
offset: (query.page - 1) * query.limit
//select: entity.getSelect(undefined, "form")
},
{ enabled: true }
);
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
const referenceField = data?.[field.reference()]; const referenceField = data?.[field.reference()];
const relationalField = data?.[field.name]; const relationalField = data?.[field.name];
useEffect(() => { useEffect(() => {
_setValue(data?.[field.reference()]); const value = data?.[field.reference()];
_setValue(value);
}, [referenceField]); }, [referenceField]);
useEffect(() => { useEffect(() => {
@@ -57,62 +53,40 @@ export function EntityRelationalFormField({
const rel_value = field.target(); const rel_value = field.target();
if (!rel_value || !relationalField) return; if (!rel_value || !relationalField) return;
console.log("-- need to fetch", field.target(), relationalField);
const fetched = await client.api.data.readOne(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); _setValue(fetched.data as any);
} }
console.log("-- fetched", fetched);
console.log("relation", {
referenceField,
relationalField,
data,
field,
entity
});
})(); })();
}, [relationalField]); }, [relationalField]);
/*const initialValue: { id: number | undefined; [key: string]: any } = data?.[
field.reference()
] ?? {
id: data?.[field.name],
};*/
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) { function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
console.log("yo");
if (_value) { if (_value) {
navigate(routes.data.entity.edit(entity.name, _value.id as any)); 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 // fix missing value on fields that are required
useEffect(() => { useEffect(() => {
if (field.isRequired() && !fieldApi.state.value) { 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 ( return (
<Formy.Group> <Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label> <Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<div <div
data-disabled={!Array.isArray(container.data) || disabled ? 1 : undefined} data-disabled={fetching || disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none" className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
> >
<Popover <Popover
@@ -120,7 +94,7 @@ export function EntityRelationalFormField({
className="" className=""
target={({ toggle }) => ( target={({ toggle }) => (
<PopoverTable <PopoverTable
container={container} container={$q.data}
entity={entity} entity={entity}
query={query} query={query}
toggle={toggle} toggle={toggle}
@@ -198,28 +172,6 @@ export function EntityRelationalFormField({
onChange={console.log} onChange={console.log}
tabIndex={-1} tabIndex={-1}
/> />
{/*<Formy.Select
ref={ref}
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
data-value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
disabled={!Array.isArray(container.data)}
>
{container.data ? (
<>
{emptyOption}
{!field.isRequired() && emptyOption}
{container.data?.map(renderRow)}
</>
) : (
<option value={undefined} disabled>
Loading...
</option>
)}
</Formy.Select>*/}
</Formy.Group> </Formy.Group>
); );
} }

View File

@@ -19,18 +19,16 @@ export function useEntityForm({
// @todo: check if virtual must be filtered // @todo: check if virtual must be filtered
const fields = entity.getFillableFields(action, true); const fields = entity.getFillableFields(action, true);
console.log("useEntityForm:data", data);
// filter defaultValues to only contain fillable fields // filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data); const defaultValues = getDefaultValues(fields, data);
console.log("useEntityForm:defaultValues", data); //console.log("useEntityForm", { data, defaultValues });
const Form = useForm({ const Form = useForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmitAsync: async ({ value }): Promise<any> => { onSubmitAsync: async ({ value }): Promise<any> => {
try { try {
console.log("validating", value, entity.isValidData(value, action)); //console.log("validating", value, entity.isValidData(value, action));
entity.isValidData(value, action, true); entity.isValidData(value, action, true);
return undefined; return undefined;
} catch (e) { } catch (e) {
@@ -40,7 +38,7 @@ export function useEntityForm({
} }
}, },
onSubmit: async ({ value, formApi }) => { onSubmit: async ({ value, formApi }) => {
console.log("onSubmit", value); //console.log("onSubmit", value);
if (!entity.isValidData(value, action)) { if (!entity.isValidData(value, action)) {
console.error("invalid data", value); console.error("invalid data", value);
return; return;
@@ -49,7 +47,7 @@ export function useEntityForm({
if (!data) return; if (!data) return;
const changeSet = getChangeSet(action, value, data, fields); 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 // only submit change set if there were changes
await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet); await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet);

View File

@@ -2,12 +2,11 @@ import { ucFirst } from "core/utils";
import type { Entity, EntityData, EntityRelation } from "data"; import type { Entity, EntityData, EntityRelation } from "data";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb"; 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 { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEntity } from "ui/container";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
@@ -25,22 +24,23 @@ export function DataEntityUpdate({ params }) {
const [navigate] = useNavigate(); const [navigate] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]); useBrowserTitle(["Data", entity.label, `#${entityId}`]);
const targetRelations = relations.listableRelationsOf(entity); 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 const local_relation_refs = relations
.sourceRelationsOf(entity) .sourceRelationsOf(entity)
?.map((r) => r.other(entity).reference); ?.map((r) => r.other(entity).reference);
const container = useEntity(entity.name, entityId, { const $q = useEntityQuery(
fetch: { entity.name,
query: { entityId,
with: local_relation_refs {
} with: local_relation_refs
},
{
revalidateOnFocus: false
} }
}); );
function goBack(state?: Record<string, any>) { function goBack() {
window.history.go(-1); window.history.go(-1);
} }
@@ -52,43 +52,39 @@ export function DataEntityUpdate({ params }) {
return; return;
} }
const res = await container.actions.update(changeSet); try {
console.log("update:res", res); await $q.update(changeSet);
if (res.data?.error) { if (error) setError(null);
setError(res.data.error);
} else {
error && setError(null);
goBack(); goBack();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update");
} }
} }
async function handleDelete() { async function handleDelete() {
if (confirm("Are you sure to delete?")) { if (confirm("Are you sure to delete?")) {
const res = await container.actions.remove(); try {
if (res.error) { await $q._delete();
setError(res.error); if (error) setError(null);
} else {
error && setError(null);
goBack(); goBack();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete");
} }
} }
} }
const data = $q.data;
const { Form, handleSubmit } = useEntityForm({ const { Form, handleSubmit } = useEntityForm({
action: "update", action: "update",
entity, entity,
initialData: container.data, initialData: $q.data?.toJSON(),
onSubmitted onSubmitted
}); });
//console.log("form.data", Form.state.values, container.data);
const makeKey = (key: string | number = "") => const makeKey = (key: string | number = "") =>
`${params.entity.name}_${entityId}_${String(key)}`; `${params.entity.name}_${entityId}_${String(key)}`;
const fieldsDisabled = const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
container.raw.fetch?.isLoading ||
container.status.fetch.isUpdating ||
Form.state.isSubmitting;
return ( return (
<Fragment key={makeKey()}> <Fragment key={makeKey()}>
@@ -103,7 +99,7 @@ export function DataEntityUpdate({ params }) {
onClick: () => { onClick: () => {
bkndModals.open("debug", { bkndModals.open("debug", {
data: { data: {
data: container.data as any, data: data as any,
entity: entity.toJSON(), entity: entity.toJSON(),
schema: entity.toSchema(true), schema: entity.toSchema(true),
form: Form.state.values, form: Form.state.values,
@@ -165,7 +161,7 @@ export function DataEntityUpdate({ params }) {
entityId={entityId} entityId={entityId}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled} fieldsDisabled={fieldsDisabled}
data={container.data ?? undefined} data={data ?? undefined}
Form={Form} Form={Form}
action="update" action="update"
className="flex flex-grow flex-col gap-3 p-3" className="flex flex-grow flex-col gap-3 p-3"

View File

@@ -1,15 +1,16 @@
import { Type } from "core/utils"; import { Type } from "core/utils";
import { useState } from "react"; 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 { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; 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 }) { export function DataEntityCreate({ params }) {
const { app } = useBknd(); const { app } = useBknd();
@@ -17,40 +18,37 @@ export function DataEntityCreate({ params }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useBrowserTitle(["Data", entity.label, "Create"]); useBrowserTitle(["Data", entity.label, "Create"]);
const container = useEntity(entity.name); const $q = useEntityMutate(entity.name);
// @todo: use entity schema for prefilling // @todo: use entity schema for prefilling
const search = useSearch(Type.Object({}), {}); const search = useSearch(Type.Object({}), {});
console.log("search", search.value);
function goBack(state?: Record<string, any>) { function goBack() {
window.history.go(-1); window.history.go(-1);
} }
async function onSubmitted(changeSet?: EntityData) { async function onSubmitted(changeSet?: EntityData) {
console.log("create:changeSet", changeSet); console.log("create:changeSet", changeSet);
//return; if (!changeSet) return;
const res = await container.actions.create(changeSet);
console.log("create:res", res); try {
if (res.data?.error) { await $q.create(changeSet);
setError(res.data.error); if (error) setError(null);
} else {
error && setError(null);
// @todo: navigate to created? // @todo: navigate to created?
goBack(); goBack();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create");
} }
} }
const { Form, handleSubmit, values } = useEntityForm({ const { Form, handleSubmit } = useEntityForm({
action: "create", action: "create",
entity, entity,
initialData: search.value, initialData: search.value,
onSubmitted onSubmitted
}); });
const fieldsDisabled = const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
container.raw.fetch?.isLoading ||
container.status.fetch.isUpdating ||
Form.state.isSubmitting;
return ( return (
<> <>

View File

@@ -1,12 +1,12 @@
import { Type } from "core/utils"; import { Type } from "core/utils";
import { querySchema } from "data"; import { querySchema } from "data";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { EntitiesContainer } from "ui/container";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search"; import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -25,19 +25,33 @@ const searchSchema = Type.Composite(
{ additionalProperties: false } { additionalProperties: false }
); );
const PER_PAGE_OPTIONS = [5, 10, 25];
export function DataEntityList({ params }) { export function DataEntityList({ params }) {
const { $data, relations } = useBkndData(); const { $data } = useBkndData();
const entity = $data.entity(params.entity as string); const entity = $data.entity(params.entity as string)!;
useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const search = useSearch(searchSchema, { const search = useSearch(searchSchema, {
select: entity?.getSelect(undefined, "table") ?? [], select: entity?.getSelect(undefined, "table") ?? [],
sort: entity?.getDefaultSort() 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<string, any>) { function handleClickRow(row: Record<string, any>) {
if (entity) navigate(routes.data.entity.edit(entity.name, row.id)); if (entity) navigate(routes.data.entity.edit(entity.name, row.id));
@@ -65,6 +79,8 @@ export function DataEntityList({ params }) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />; return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
} }
const isUpdating = $q.isLoading && $q.isValidating;
return ( return (
<> <>
<AppShell.SectionHeader <AppShell.SectionHeader
@@ -103,45 +119,25 @@ export function DataEntityList({ params }) {
<SearchInput placeholder={`Filter ${entity.label}`} /> <SearchInput placeholder={`Filter ${entity.label}`} />
</div>*/} </div>*/}
<EntitiesContainer <div
entity={entity.name} data-updating={isUpdating ? 1 : undefined}
query={{ className="data-[updating]:opacity-50 transition-opacity pb-10"
select: search.value.select,
limit: search.value.perPage,
offset: (search.value.page - 1) * search.value.perPage,
sort: search.value.sort
}}
> >
{(params) => { <EntityTable2
if (params.status.fetch.isLoading) { data={data ?? null}
return null; entity={entity}
} /*select={search.value.select}*/
onClickRow={handleClickRow}
const isUpdating = params.status.fetch.isUpdating; page={search.value.page}
sort={search.value.sort}
return ( onClickSort={handleSortClick}
<div perPage={search.value.perPage}
data-updating={isUpdating ? 1 : undefined} perPageOptions={PER_PAGE_OPTIONS}
className="data-[updating]:opacity-50 transition-opacity pb-10" total={meta?.count}
> onClickPage={handleClickPage}
<EntityTable2 onClickPerPage={handleClickPerPage}
data={params.data ?? []} />
entity={entity} </div>
select={search.value.select}
onClickRow={handleClickRow}
page={search.value.page}
sort={search.value.sort}
onClickSort={handleSortClick}
perPage={search.value.perPage}
perPageOptions={PER_PAGE_OPTIONS}
total={params.meta?.count}
onClickPage={handleClickPage}
onClickPerPage={handleClickPerPage}
/>
</div>
);
}}
</EntitiesContainer>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</> </>