diff --git a/.gitignore b/.gitignore index 9727332..2232350 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ packages/media/.env **/*/.env **/*/.dev.vars **/*/.wrangler +**/*/*.tgz **/*/vite.config.ts.timestamp* .history **/*/.db/* diff --git a/app/__test__/api/ModuleApi.spec.ts b/app/__test__/api/ModuleApi.spec.ts new file mode 100644 index 0000000..caa42d0 --- /dev/null +++ b/app/__test__/api/ModuleApi.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { secureRandomString } from "../../src/core/utils"; +import { ModuleApi } from "../../src/modules"; + +class Api extends ModuleApi { + _getUrl(path: string) { + return this.getUrl(path); + } +} + +const host = "http://localhost"; + +describe("ModuleApi", () => { + it("resolves options correctly", () => { + const api = new Api({ host }); + expect(api.options).toEqual({ host }); + }); + + it("returns correct url from path", () => { + const api = new Api({ host }); + expect(api._getUrl("/test")).toEqual("http://localhost/test"); + expect(api._getUrl("test")).toEqual("http://localhost/test"); + expect(api._getUrl("test/")).toEqual("http://localhost/test"); + expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1"); + }); + + it("fetches endpoint", async () => { + const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); + const api = new Api({ host }); + api.fetcher = app.request as typeof fetch; + + const res = await api.get("/endpoint"); + expect(res.res.ok).toEqual(true); + expect(res.res.status).toEqual(200); + expect(res.data).toEqual({ foo: "bar" }); + expect(res.body).toEqual({ foo: "bar" }); + }); + + it("has accessible request", async () => { + const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); + const api = new Api({ host }); + api.fetcher = app.request as typeof fetch; + + const promise = api.get("/endpoint"); + expect(promise.request).toBeDefined(); + expect(promise.request.url).toEqual("http://localhost/endpoint"); + + expect((await promise).body).toEqual({ foo: "bar" }); + }); + + it("adds token to headers when given in options", () => { + const token = secureRandomString(20); + const api = new Api({ host, token, token_transport: "header" }); + + expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`); + }); + + it("sets header to accept json", () => { + const api = new Api({ host }); + expect(api.get("/").request.headers.get("Accept")).toEqual("application/json"); + }); + + it("adds additional headers from options", () => { + const headers = new Headers({ + "X-Test": "123" + }); + const api = new Api({ host, headers }); + expect(api.get("/").request.headers.get("X-Test")).toEqual("123"); + }); + + it("uses basepath & removes trailing slash", () => { + const api = new Api({ host, basepath: "/api" }); + expect(api.get("/").request.url).toEqual("http://localhost/api"); + }); + + it("uses search params", () => { + const api = new Api({ host }); + const search = new URLSearchParams({ + foo: "bar" + }); + expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString()); + }); + + it("resolves method shortcut fns correctly", () => { + const api = new Api({ host }); + expect(api.get("/").request.method).toEqual("GET"); + expect(api.post("/").request.method).toEqual("POST"); + expect(api.put("/").request.method).toEqual("PUT"); + expect(api.patch("/").request.method).toEqual("PATCH"); + expect(api.delete("/").request.method).toEqual("DELETE"); + }); + + // @todo: test error response + // @todo: test method shortcut functions +}); diff --git a/app/__test__/api/api.spec.ts b/app/__test__/api/api.spec.ts deleted file mode 100644 index 9c2a61b..0000000 --- a/app/__test__/api/api.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, test } from "bun:test"; -import { DataApi } from "../../src/modules/data/api/DataApi"; - -describe("Api", async () => { - test("...", async () => { - /*const dataApi = new DataApi({ - host: "https://dev-config-soma.bknd.run" - }); - - const one = await dataApi.readOne("users", 1); - const many = await dataApi.readMany("users", { limit: 2 }); - console.log("one", one); - console.log("many", many);*/ - }); -}); diff --git a/app/package.json b/app/package.json index ada105a..fb02b8e 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.3.3", + "version": "0.3.4-alpha1", "scripts": { "build:all": "bun run build && bun run build:cli", "dev": "vite", @@ -22,18 +22,19 @@ }, "license": "FSL-1.1-MIT", "dependencies": { + "@cfworker/json-schema": "^2.0.1", "@libsql/client": "^0.14.0", - "@tanstack/react-form": "0.19.2", "@sinclair/typebox": "^0.32.34", + "@tanstack/react-form": "0.19.2", + "aws4fetch": "^1.0.18", + "dayjs": "^1.11.13", + "fast-xml-parser": "^4.4.0", + "hono": "^4.6.12", "kysely": "^0.27.4", "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", - "hono": "^4.6.12", - "fast-xml-parser": "^4.4.0", - "@cfworker/json-schema": "^2.0.1", - "dayjs": "^1.11.13", "oauth4webapi": "^2.11.1", - "aws4fetch": "^1.0.18" + "swr": "^2.2.5" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", @@ -54,8 +55,6 @@ "@radix-ui/react-scroll-area": "^1.2.0", "@rjsf/core": "^5.22.2", "@tabler/icons-react": "3.18.0", - "@tanstack/react-query": "^5.59.16", - "@tanstack/react-query-devtools": "^5.59.16", "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/app/src/Api.ts b/app/src/Api.ts index d94aff9..5196622 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -1,3 +1,4 @@ +import type { SafeUser } from "auth"; import { AuthApi } from "auth/api/AuthApi"; import { DataApi } from "data/api/DataApi"; import { decode } from "hono/jwt"; @@ -5,7 +6,7 @@ import { omit } from "lodash-es"; import { MediaApi } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; -export type TApiUser = object; +export type TApiUser = SafeUser; declare global { interface Window { @@ -24,6 +25,12 @@ export type ApiOptions = { localStorage?: boolean; }; +export type AuthState = { + token?: string; + user?: TApiUser; + verified: boolean; +}; + export class Api { private token?: string; private user?: TApiUser; @@ -50,6 +57,10 @@ export class Api { this.buildApis(); } + get baseUrl() { + return this.options.host; + } + get tokenKey() { return this.options.key ?? "auth"; } @@ -85,7 +96,11 @@ export class Api { updateToken(token?: string, rebuild?: boolean) { this.token = token; - this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined; + if (token) { + this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; + } else { + this.user = undefined; + } if (this.options.localStorage) { const key = this.tokenKey; @@ -105,7 +120,7 @@ export class Api { return this; } - getAuthState() { + getAuthState(): AuthState { return { token: this.token, user: this.user, @@ -113,6 +128,20 @@ export class Api { }; } + async verifyAuth() { + try { + const res = await this.auth.me(); + if (!res.ok || !res.body.user) { + throw new Error(); + } + + this.markAuthVerified(true); + } catch (e) { + this.markAuthVerified(false); + this.updateToken(undefined); + } + } + getUser(): TApiUser | null { return this.user || null; } diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index df7ffb0..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,17 +23,17 @@ 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; } - async me() { + me() { return this.get<{ user: SafeUser | null }>(["me"]); } - async strategies() { + strategies() { return this.get>(["strategies"]); } diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 0fa6d3e..967a5f1 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -15,7 +15,7 @@ export class DataApi extends ModuleApi { }; } - async readOne( + readOne( entity: string, id: PrimaryFieldType, query: Partial> = {} @@ -23,14 +23,14 @@ export class DataApi extends ModuleApi { return this.get>([entity, id], query); } - async readMany(entity: string, query: Partial = {}) { + readMany(entity: string, query: Partial = {}) { return this.get>( [entity], query ?? this.options.defaultQuery ); } - async readManyByReference( + readManyByReference( entity: string, id: PrimaryFieldType, reference: string, @@ -42,19 +42,19 @@ export class DataApi extends ModuleApi { ); } - async createOne(entity: string, input: EntityData) { + createOne(entity: string, input: EntityData) { return this.post>([entity], input); } - async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) { + updateOne(entity: string, id: PrimaryFieldType, input: EntityData) { return this.patch>([entity, id], input); } - async deleteOne(entity: string, id: PrimaryFieldType) { + deleteOne(entity: string, id: PrimaryFieldType) { return this.delete>([entity, id]); } - async count(entity: string, where: RepoQuery["where"] = {}) { + count(entity: string, where: RepoQuery["where"] = {}) { return this.post>( [entity, "fn", "count"], where 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/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 329a3cc..a85ac77 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -72,6 +72,6 @@ export const querySchema = Type.Object( } ); -export type RepoQueryIn = Simplify>; +export type RepoQueryIn = Static; export type RepoQuery = Required>; export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index c69c952..121c2fc 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -10,11 +10,11 @@ export class MediaApi extends ModuleApi { }; } - async getFiles() { + getFiles() { return this.get(["files"]); } - async getFile(filename: string) { + getFile(filename: string) { return this.get(["file", filename]); } @@ -32,13 +32,13 @@ export class MediaApi extends ModuleApi { }); } - async uploadFile(file: File) { + uploadFile(file: File) { const formData = new FormData(); formData.append("file", file); return this.post(["upload"], formData); } - async deleteFile(filename: string) { + deleteFile(filename: string) { return this.delete(["file", filename]); } } diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 541505b..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; @@ -18,7 +19,11 @@ export type ApiResponse = { res: Response; }; -export abstract class ModuleApi { +export type TInput = string | (string | number | PrimaryFieldType)[]; + +export abstract class ModuleApi { + protected fetcher?: typeof fetch; + constructor(protected readonly _options: Partial = {}) {} protected getDefaultOptions(): Partial { @@ -35,14 +40,15 @@ export abstract class ModuleApi { } protected getUrl(path: string) { - return this.options.host + (this.options.basepath + "/" + path).replace(/\/\//g, "/"); + const basepath = this.options.basepath ?? ""; + return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, ""); } - protected async request( - _input: string | (string | number | PrimaryFieldType)[], + protected request( + _input: TInput, _query?: Record | URLSearchParams, _init?: RequestInit - ): Promise> { + ): FetchPromise> { const method = _init?.method ?? "GET"; const input = Array.isArray(_input) ? _input.join("/") : _input; let url = this.getUrl(input); @@ -78,14 +84,130 @@ export abstract class ModuleApi { } } - //console.log("url", url); - const res = await fetch(url, { + const request = new Request(url, { ..._init, method, body, headers }); + return new FetchPromise(request, { + fetcher: this.fetcher + }); + } + + get( + _input: TInput, + _query?: Record | URLSearchParams, + _init?: RequestInit + ) { + return this.request(_input, _query, { + ..._init, + method: "GET" + }); + } + + post(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "POST" + }); + } + + patch(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "PATCH" + }); + } + + put(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "PUT" + }); + } + + delete(_input: TInput, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + method: "DELETE" + }); + } +} + +export type ResponseObject = 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"; + + constructor( + public request: Request, + protected options?: { + fetcher?: typeof fetch; + } + ) {} + + 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; let resData: any; @@ -99,69 +221,51 @@ export abstract class ModuleApi { resBody = await res.text(); } - return { - success: res.ok, - status: res.status, - body: resBody, - data: resData, - res - }; + return createResponseProxy(res, resBody, resData); } - protected async get( - _input: string | (string | number | PrimaryFieldType)[], - _query?: Record | URLSearchParams, - _init?: RequestInit - ) { - return this.request(_input, _query, { - ..._init, - method: "GET" - }); + // biome-ignore lint/suspicious/noThenProperty: it's a promise :) + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined + ): Promise { + return this.execute().then(onfulfilled as any, onrejected); } - protected async post( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "POST" - }); + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | null | undefined + ): Promise { + return this.then(undefined, onrejected); } - protected async patch( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "PATCH" - }); + finally(onfinally?: (() => void) | null | undefined): Promise { + return this.then( + (value) => { + onfinally?.(); + return value; + }, + (reason) => { + onfinally?.(); + throw reason; + } + ); } - protected async put( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "PUT" - }); + path(): string { + const url = new URL(this.request.url); + return url.pathname; } - protected async delete( - _input: string | (string | number | PrimaryFieldType)[], - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - method: "DELETE" - }); + key(options?: { search: boolean }): string { + const url = new URL(this.request.url); + return options?.search !== false ? this.path() + url.search : this.path(); + } + + keyArray(options?: { search: boolean }): string[] { + const url = new URL(this.request.url); + const path = this.path().split("/"); + return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter( + Boolean + ); } } diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts index 1d226c6..3241141 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,41 @@ export class SystemApi extends ModuleApi { }; } - async readSchema(options?: { config?: boolean; secrets?: boolean }) { - return await this.get("schema", { + readConfig() { + return this.get<{ version: number } & ModuleConfigs>("config"); + } + + 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..4f5293f 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,7 +1,7 @@ +import type { ModuleConfigs, ModuleSchemas } from "modules"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; -import type { ModuleConfigs, ModuleSchemas } from "../../modules"; -import { useClient } from "./ClientProvider"; +import { useApi } from "ui/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; @@ -22,14 +22,18 @@ 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>(); const [fetched, setFetched] = useState(false); const errorShown = useRef(); - const client = useClient(); + const api = useApi(); async function reloadSchema() { await fetchSchema(includeSecrets, true); @@ -37,7 +41,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 api.system.readSchema({ config: true, secrets: _includeSecrets }); @@ -57,7 +61,7 @@ export function BkndProvider({ } const schema = res.ok - ? body + ? res.body : ({ version: 0, schema: getDefaultSchema(), @@ -89,9 +93,9 @@ 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 }); + const actions = getSchemaActions({ api, setSchema, reloadSchema }); return ( diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 21544ca..4fd6719 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,22 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { TApiUser } from "Api"; +import { Api, type ApiOptions, type TApiUser } from "Api"; import { createContext, useContext, useEffect, useState } from "react"; -//import { useBkndWindowContext } from "ui/client/BkndProvider"; -import { AppQueryClient } from "./utils/AppQueryClient"; -const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({ +const ClientContext = createContext<{ baseUrl: string; api: Api }>({ baseUrl: undefined } as any); -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - refetchOnWindowFocus: false - } - } -}); - export type ClientProviderProps = { children?: any; baseUrl?: string; @@ -24,74 +12,53 @@ export type ClientProviderProps = { }; export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => { - const [actualBaseUrl, setActualBaseUrl] = useState(null); + //const [actualBaseUrl, setActualBaseUrl] = useState(null); const winCtx = useBkndWindowContext(); + const _ctx_baseUrl = useBaseUrl(); + let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? ""; try { - const _ctx_baseUrl = useBaseUrl(); - if (_ctx_baseUrl) { - console.warn("wrapped many times"); - setActualBaseUrl(_ctx_baseUrl); + if (!baseUrl) { + if (_ctx_baseUrl) { + actualBaseUrl = _ctx_baseUrl; + console.warn("wrapped many times, take from context", actualBaseUrl); + } else if (typeof window !== "undefined") { + actualBaseUrl = window.location.origin; + console.log("setting from window", actualBaseUrl); + } } } catch (e) { - console.error("error", e); + console.error("error .....", e); } - useEffect(() => { - // Only set base URL if running on the client side - if (typeof window !== "undefined") { - setActualBaseUrl(baseUrl || window.location.origin); - } - }, [baseUrl]); - - if (!actualBaseUrl) { - // Optionally, return a fallback during SSR rendering - return null; // or a loader/spinner if desired - } - - //console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user }); - const client = createClient(actualBaseUrl, user ?? winCtx.user); + const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); return ( - - - {children} - - + + {children} + ); }; -export function createClient(baseUrl: string, user?: object) { - return new AppQueryClient(baseUrl, user); -} - -export function createOrUseClient(baseUrl: string) { +export const useApi = (host?: ApiOptions["host"]): Api => { const context = useContext(ClientContext); - if (!context) { - console.warn("createOrUseClient returned a new client"); - return createClient(baseUrl); + if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { + return new Api({ host: host ?? "" }); } - return context.client; -} - -export const useClient = () => { - const context = useContext(ClientContext); - if (!context) { - throw new Error("useClient must be used within a ClientProvider"); - } - - console.log("useClient", context.baseUrl); - return context.client; + return context.api; }; +/** + * @deprecated use useApi().baseUrl instead + */ export const useBaseUrl = () => { const context = useContext(ClientContext); return context.baseUrl; }; type BkndWindowContext = { - user?: object; + user?: TApiUser; logout_route: string; }; export function useBkndWindowContext(): BkndWindowContext { diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts new file mode 100644 index 0000000..6d75f82 --- /dev/null +++ b/app/src/ui/client/api/use-api.ts @@ -0,0 +1,38 @@ +import type { Api } from "Api"; +import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; +import { useApi } from "ui/client"; + +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: ResponseObject) => data); + const fetcher = () => promise.execute().then(refine); + const key = promise.key(); + + type RefinedData = RefineFn extends (data: ResponseObject) => infer R ? R : Data; + + const swr = useSWR(options?.enabled === false ? null : key, fetcher, options); + return { + ...swr, + promise, + key, + api + }; +}; + +export const useInvalidate = () => { + const mutate = useSWRConfig().mutate; + const api = useApi(); + + return async (arg?: string | ((api: Api) => FetchPromise)) => { + if (!arg) return async () => mutate(""); + return mutate(typeof arg === "string" ? arg : arg(api).key()); + }; +}; diff --git a/app/src/ui/client/api/use-data.ts b/app/src/ui/client/api/use-data.ts new file mode 100644 index 0000000..46cc81a --- /dev/null +++ b/app/src/ui/client/api/use-data.ts @@ -0,0 +1,37 @@ +import type { DataApi } from "data/api/DataApi"; +import { useApi } from "ui/client"; + +type OmitFirstArg = F extends (x: any, ...args: infer P) => any + ? (...args: P) => ReturnType + : never; + +/** + * Maps all DataApi functions and omits + * the first argument "entity" for convenience + * @param entity + */ +export const useData = (entity: string) => { + const api = useApi().data; + const methods = [ + "readOne", + "readMany", + "readManyByReference", + "createOne", + "updateOne", + "deleteOne" + ] as const; + + return methods.reduce( + (acc, method) => { + // @ts-ignore + acc[method] = (...params) => { + // @ts-ignore + return api[method](entity, ...params); + }; + return acc; + }, + {} as { + [K in (typeof methods)[number]]: OmitFirstArg<(typeof api)[K]>; + } + ); +}; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts new file mode 100644 index 0000000..23be395 --- /dev/null +++ b/app/src/ui/client/api/use-entity.ts @@ -0,0 +1,121 @@ +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 +>( + entity: Entity, + id?: Id +) => { + const api = useApi().data; + + return { + create: async (input: EntityData) => { + const res = await api.createOne(entity, input); + 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); + 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); + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to update entity"); + } + return res; + }, + _delete: async (_id: PrimaryFieldType | undefined = id) => { + if (!_id) { + throw new Error("id is required"); + } + + const res = await api.deleteOne(entity, _id); + if (!res.ok) { + throw new UseEntityApiError(res.data, res.res, "Failed to delete entity"); + } + return res; + } + }; +}; + +export const useEntityQuery = < + Entity extends string, + Id extends PrimaryFieldType | undefined = undefined +>( + entity: Entity, + id?: Id, + query?: Partial, + options?: SWRConfiguration & { enabled?: boolean } +) => { + const api = useApi().data; + 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 = () => 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(action(...args)) as any; + }; + }) as Omit>, "read">; + + return { + ...swr, + ...mapped, + api, + 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/index.ts b/app/src/ui/client/index.ts index 30bb77f..792f884 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -2,9 +2,12 @@ export { ClientProvider, useBkndWindowContext, type ClientProviderProps, - useClient, + useApi, useBaseUrl } from "./ClientProvider"; +export * from "./api/use-api"; +export * from "./api/use-data"; +export * from "./api/use-entity"; export { useAuth } from "./schema/auth/use-auth"; export { Api } from "../../Api"; diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index c03d4b0..cebc137 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -1,23 +1,23 @@ import { type NotificationData, notifications } from "@mantine/notifications"; +import type { Api } from "Api"; import { ucFirst } from "core/utils"; -import type { ApiResponse, ModuleConfigs } from "../../../modules"; -import type { AppQueryClient } from "../utils/AppQueryClient"; +import type { ModuleConfigs } from "modules"; +import type { ResponseObject } from "modules/ModuleApi"; +import type { ConfigUpdateResponse } from "modules/server/SystemController"; export type SchemaActionsProps = { - client: AppQueryClient; + api: Api; setSchema: React.Dispatch>; reloadSchema: () => Promise; }; export type TSchemaActions = ReturnType; -export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) { - const api = client.api; - - async function handleConfigUpdate( +export function getSchemaActions({ api, setSchema, reloadSchema }: SchemaActionsProps) { + async function handleConfigUpdate( action: string, - module: string, - res: ApiResponse, + module: Module, + res: ResponseObject>, path?: string ): Promise { const base: Partial = { @@ -26,7 +26,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 +35,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi ...prev, config: { ...prev.config, - [module]: res.body.config + [module]: res.config } }; }); @@ -47,18 +47,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 +72,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 +80,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/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 405e140..fd2ec84 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,15 +1,8 @@ -import { Api } from "Api"; +import { Api, type AuthState } from "Api"; import type { AuthResponse } from "auth"; import type { AppAuthSchema } from "auth/auth-schema"; -import type { ApiResponse } from "modules/ModuleApi"; import { useEffect, useState } from "react"; -import { - createClient, - createOrUseClient, - queryClient, - useBaseUrl, - useClient -} from "../../ClientProvider"; +import { useApi, useInvalidate } from "ui/client"; type LoginData = { email: string; @@ -18,55 +11,54 @@ type LoginData = { }; type UseAuth = { - data: (AuthResponse & { verified: boolean }) | undefined; - user: AuthResponse["user"] | undefined; - token: AuthResponse["token"] | undefined; + data: AuthState | undefined; + user: AuthState["user"] | undefined; + token: AuthState["token"] | undefined; verified: boolean; - login: (data: LoginData) => Promise>; - register: (data: LoginData) => Promise>; + login: (data: LoginData) => Promise; + register: (data: LoginData) => Promise; logout: () => void; verify: () => void; setToken: (token: string) => void; }; -// @todo: needs to use a specific auth endpoint to get strategy information export const useAuth = (options?: { baseUrl?: string }): UseAuth => { - const ctxBaseUrl = useBaseUrl(); - //const client = useClient(); - const client = createOrUseClient(options?.baseUrl ? options?.baseUrl : ctxBaseUrl); - const authState = client.auth().state(); + const api = useApi(options?.baseUrl); + const invalidate = useInvalidate(); + const authState = api.getAuthState(); const [authData, setAuthData] = useState(authState); const verified = authState?.verified ?? false; + function updateAuthState() { + setAuthData(api.getAuthState()); + } + async function login(input: LoginData) { - const res = await client.auth().login(input); - if (res.res.ok && res.data && "user" in res.data) { - setAuthData(res.data); - } - return res; + const res = await api.auth.loginWithPassword(input); + updateAuthState(); + return res.data; } async function register(input: LoginData) { - const res = await client.auth().register(input); - if (res.res.ok && res.data && "user" in res.data) { - setAuthData(res.data); - } - return res; + const res = await api.auth.registerWithPassword(input); + updateAuthState(); + return res.data; } function setToken(token: string) { - setAuthData(client.auth().setToken(token) as any); + api.updateToken(token); + updateAuthState(); } async function logout() { - await client.auth().logout(); + await api.updateToken(undefined); setAuthData(undefined); - queryClient.clear(); + invalidate(); } async function verify() { - await client.auth().verify(); - setAuthData(client.auth().state()); + await api.verifyAuth(); + updateAuthState(); } return { @@ -87,10 +79,7 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial { const [data, setData] = useState(); - const ctxBaseUrl = useBaseUrl(); - const api = new Api({ - host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl - }); + const api = useApi(options?.baseUrl); useEffect(() => { (async () => { diff --git a/app/src/ui/client/schema/auth/use-bknd-auth.ts b/app/src/ui/client/schema/auth/use-bknd-auth.ts index 26b8720..a3bb003 100644 --- a/app/src/ui/client/schema/auth/use-bknd-auth.ts +++ b/app/src/ui/client/schema/auth/use-bknd-auth.ts @@ -1,8 +1,7 @@ import { useBknd } from "ui/client/bknd"; export function useBkndAuth() { - //const client = useClient(); - const { config, app, schema, actions: bkndActions } = useBknd(); + const { config, schema, actions: bkndActions } = useBknd(); const actions = { roles: { diff --git a/app/src/ui/client/schema/flows/use-flows.ts b/app/src/ui/client/schema/flows/use-flows.ts index cdaa220..554a028 100644 --- a/app/src/ui/client/schema/flows/use-flows.ts +++ b/app/src/ui/client/schema/flows/use-flows.ts @@ -1,10 +1,8 @@ import { type Static, parse } from "core/utils"; import { type TAppFlowSchema, flowSchema } from "flows/flows-schema"; import { useBknd } from "../../BkndProvider"; -import { useClient } from "../../ClientProvider"; export function useFlows() { - const client = useClient(); const { config, app, actions: bkndActions } = useBknd(); const actions = { diff --git a/app/src/ui/client/utils/AppQueryClient.ts b/app/src/ui/client/utils/AppQueryClient.ts deleted file mode 100644 index 8eecf9e..0000000 --- a/app/src/ui/client/utils/AppQueryClient.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { - type QueryObserverOptions, - type UseQueryResult, - keepPreviousData, - useMutation, - useQuery -} from "@tanstack/react-query"; -import type { AuthResponse } from "auth"; -import type { EntityData, RepoQuery, RepositoryResponse } from "data"; -import { Api } from "../../../Api"; -import type { ApiResponse } from "../../../modules/ModuleApi"; -import { queryClient } from "../ClientProvider"; - -export class AppQueryClient { - api: Api; - constructor( - public baseUrl: string, - user?: object - ) { - this.api = new Api({ - host: baseUrl, - user - }); - } - - queryOptions(options?: Partial): Partial { - return { - staleTime: 1000 * 60 * 5, - placeholderData: keepPreviousData, - ...options - }; - } - - auth = () => { - return { - state: (): (AuthResponse & { verified: boolean }) | undefined => { - return this.api.getAuthState() as any; - }, - login: async (data: { email: string; password: string }): Promise< - ApiResponse - > => { - return await this.api.auth.loginWithPassword(data); - }, - register: async (data: any): Promise> => { - return await this.api.auth.registerWithPassword(data); - }, - logout: async () => { - this.api.updateToken(undefined); - return true; - }, - setToken: (token) => { - this.api.updateToken(token); - return this.api.getAuthState(); - }, - verify: async () => { - try { - //console.log("verifiying"); - const res = await this.api.auth.me(); - //console.log("verifying result", res); - if (!res.res.ok || !res.body.user) { - throw new Error(); - } - - this.api.markAuthVerified(true); - } catch (e) { - this.api.markAuthVerified(false); - this.api.updateToken(undefined); - } - } - }; - }; - - media = (options?: Partial) => { - const queryOptions = this.queryOptions(options); - return { - api: () => { - return this.api.media; - }, - list: (query: Partial = { limit: 10 }): UseQueryResult => { - return useQuery({ - ...(queryOptions as any), // @todo: fix typing - queryKey: ["data", "entity", "media", { query }], - queryFn: async () => { - return await this.api.data.readMany("media", query); - } - }); - }, - deleteFile: async (filename: string | { path: string }) => { - const res = await this.api.media.deleteFile( - typeof filename === "string" ? filename : filename.path - ); - - if (res.res.ok) { - queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] }); - return true; - } - - return false; - } - }; - }; - - query = (options?: Partial) => { - const queryOptions = this.queryOptions(options); - return { - data: { - entity: (name: string) => { - return { - readOne: ( - id: number, - query: Partial> = {} - ): any => { - return useQuery({ - ...queryOptions, - queryKey: ["data", "entity", name, id, { query }], - queryFn: async () => { - return await this.api.data.readOne(name, id, query); - } - }); - }, - readMany: ( - query: Partial = { limit: 10, offset: 0 } - ): UseQueryResult => { - return useQuery({ - ...(queryOptions as any), // @todo: fix typing - queryKey: ["data", "entity", name, { query }], - queryFn: async () => { - return await this.api.data.readMany(name, query); - } - }); - }, - readManyByReference: ( - id: number, - reference: string, - referenced_entity?: string, // required for query invalidation - query: Partial = { limit: 10, offset: 0 } - ): UseQueryResult> => { - return useQuery({ - ...(queryOptions as any), // @todo: fix typing - queryKey: [ - "data", - "entity", - referenced_entity ?? reference, - { name, id, reference, query } - ], - queryFn: async () => { - return await this.api.data.readManyByReference( - name, - id, - reference, - query - ); - } - }); - }, - count: ( - where: RepoQuery["where"] = {} - ): UseQueryResult> => { - return useQuery({ - ...(queryOptions as any), // @todo: fix typing - queryKey: ["data", "entity", name, "fn", "count", { where }], - queryFn: async () => { - return await this.api.data.count(name, where); - } - }); - } - }; - } - } - }; - }; - - // @todo: centralize, improve - __invalidate = (...args: any[]) => { - console.log("___invalidate", ["data", "entity", ...args]); - queryClient.invalidateQueries({ queryKey: ["data", "entity", ...args] }); - }; - - // @todo: must return response... why? - mutation = { - data: { - entity: (name: string) => { - return { - update: (id: number): any => { - return useMutation({ - mutationFn: async (input: EntityData) => { - return await this.api.data.updateOne(name, id, input); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["data", "entity", name] }); - } - }); - }, - create: (): any => { - return useMutation({ - mutationFn: async (input: EntityData) => { - return await this.api.data.createOne(name, input); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["data", "entity", name] }); - } - }); - }, - delete: (id: number): any => { - return useMutation({ - mutationFn: async () => { - return await this.api.data.deleteOne(name, id); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["data", "entity", name] }); - } - }); - } - }; - } - } - }; -} 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/container/EntitiesContainer.tsx b/app/src/ui/container/EntitiesContainer.tsx deleted file mode 100644 index 3814c10..0000000 --- a/app/src/ui/container/EntitiesContainer.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { UseQueryOptions, UseQueryResult } from "@tanstack/react-query"; -import type { RepositoryResponse } from "data"; -import type { RepoQuery } from "data"; -import { useClient } from "../client"; -import { type EntityData, type QueryStatus, getStatus } from "./EntityContainer"; - -export type RenderParams = { - data: Data[] | undefined; - meta: RepositoryResponse["meta"] | undefined; - status: { - fetch: QueryStatus; - }; - raw: { - fetch: UseQueryResult; - }; - actions: { - create(obj: any): any; - update(id: number, obj: any): any; - }; -}; - -export type EntitiesContainerProps = { - entity: string; - query?: Partial; - queryOptions?: Partial; -}; - -export function useEntities( - entity: string, - query?: Partial, - queryOptions?: Partial -): RenderParams { - const client = useClient(); - let data: any = null; - let meta: any = null; - - const fetchQuery = client.query(queryOptions).data.entity(entity).readMany(query); - const createMutation = client.mutation.data.entity(entity).create(); - const updateMutation = (id: number) => client.mutation.data.entity(entity).update(id); - - if (fetchQuery?.isSuccess) { - meta = fetchQuery.data?.body.meta; - data = fetchQuery.data?.body.data; - } - - function create(obj: any) { - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: - return new Promise(async (resolve, reject) => { - await createMutation?.mutate(obj, { - onSuccess: resolve, - onError: reject - }); - }); - } - - function update(id: number, obj: any) { - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: - return new Promise(async (resolve, reject) => { - await updateMutation(id).mutate(obj, { - onSuccess: resolve, - onError: reject - }); - }); - } - - return { - data, - meta, - actions: { - create, - update - // remove - }, - status: { - fetch: getStatus(fetchQuery) - }, - raw: { - fetch: fetchQuery - } - }; -} - -export function EntitiesContainer({ - entity, - query, - queryOptions, - children -}: EntitiesContainerProps & { - children(params: RenderParams): any; -}) { - const params = useEntities(entity, query, queryOptions); - return children(params as any); -} - -export const Entities = EntitiesContainer; diff --git a/app/src/ui/container/EntityContainer.tsx b/app/src/ui/container/EntityContainer.tsx deleted file mode 100644 index da27a71..0000000 --- a/app/src/ui/container/EntityContainer.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import type { UseQueryResult } from "@tanstack/react-query"; -import type { RepoQuery } from "data"; -import { useClient } from "../client"; - -export type EntityData = Record; - -export type EntityContainerRenderParams = { - data: Data | null; - client: ReturnType; - initialValues: object; - raw: { - fetch?: UseQueryResult; - }; - status: { - fetch: QueryStatus; - }; - actions: { - create(obj: any): any; - update(obj: any): any; - remove(): any; - }; -}; - -export type MutationStatus = { - isLoading: boolean; - isSuccess: boolean; - isError: boolean; -}; - -export type QueryStatus = MutationStatus & { - isUpdating: boolean; -}; - -export function getStatus(query?: UseQueryResult): QueryStatus { - return { - isLoading: query ? query.isPending : false, - isUpdating: query ? !query.isInitialLoading && query.isFetching : false, - isSuccess: query ? query.isSuccess : false, - isError: query ? query.isError : false - }; -} - -export type EntityContainerProps = { - entity: string; - id?: number; -}; - -type FetchOptions = { - disabled?: boolean; - query?: Partial>; -}; - -// @todo: add option to disable fetches (for form updates) -// @todo: must return a way to indicate error! -export function useEntity( - entity: string, - id?: number, - options?: { fetch?: FetchOptions } -): EntityContainerRenderParams { - const client = useClient(); - let data: any = null; - - const fetchQuery = id - ? client.query().data.entity(entity).readOne(id, options?.fetch?.query) - : undefined; - const createMutation = id ? null : client.mutation.data.entity(entity).create(); - const updateMutation = id ? client.mutation.data.entity(entity).update(id) : undefined; - const deleteMutation = id ? client.mutation.data.entity(entity).delete(id) : undefined; - - if (fetchQuery?.isSuccess) { - data = fetchQuery.data?.body.data; - } - - const initialValues = { one: 1 }; - - function create(obj: any) { - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: - return new Promise(async (resolve, reject) => { - await createMutation?.mutate(obj, { - onSuccess: resolve, - onError: reject - }); - }); - } - - function update(obj: any) { - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: - return new Promise(async (resolve, reject) => { - //await new Promise((r) => setTimeout(r, 4000)); - await updateMutation?.mutate(obj, { - onSuccess: resolve, - onError: reject - }); - }); - } - - function remove() { - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: - return new Promise(async (resolve, reject) => { - //await new Promise((r) => setTimeout(r, 4000)); - await deleteMutation?.mutate(undefined, { - onSuccess: resolve, - onError: reject - }); - }); - } - - return { - data, - client, - initialValues, - actions: { - create, - update, - remove - }, - status: { - fetch: getStatus(fetchQuery) - //update: getMutationStatus(updateMutation), - }, - raw: { - fetch: fetchQuery - } - }; -} - -export function EntityContainer({ - entity, - id, - children -}: EntityContainerProps & { children(params: EntityContainerRenderParams): any }) { - const params = useEntity(entity, id); - return children(params); -} - -export const Entity = EntityContainer; diff --git a/app/src/ui/container/index.ts b/app/src/ui/container/index.ts deleted file mode 100644 index a427a24..0000000 --- a/app/src/ui/container/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./EntitiesContainer"; -export * from "./EntityContainer"; diff --git a/app/src/ui/index.ts b/app/src/ui/index.ts index 7b3c676..92a9c97 100644 --- a/app/src/ui/index.ts +++ b/app/src/ui/index.ts @@ -1,11 +1 @@ export { default as Admin, type BkndAdminProps } from "./Admin"; -export { - EntitiesContainer, - useEntities, - type EntitiesContainerProps -} from "./container/EntitiesContainer"; -export { - EntityContainer, - useEntity, - type EntityContainerProps -} from "./container/EntityContainer"; 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/lib/mantine/theme.ts b/app/src/ui/lib/mantine/theme.ts index 0dfe287..e880b9d 100644 --- a/app/src/ui/lib/mantine/theme.ts +++ b/app/src/ui/lib/mantine/theme.ts @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { twMerge } from "tailwind-merge"; -// default: https://github.com/mantinedev/mantine/blob/master/src/mantine-core/src/core/MantineProvider/default-theme.ts +// default: https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/core/src/core/MantineProvider/default-theme.ts export function createMantineTheme(scheme: "light" | "dark"): { theme: ReturnType; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 40147b0..131bd61 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -10,7 +10,7 @@ import { } from "data"; import { MediaField } from "media/MediaField"; import { type ComponentProps, Suspense } from "react"; -import { useClient } from "ui/client"; +import { useApi, useBaseUrl, useInvalidate } from "ui/client"; import { JsonEditor } from "ui/components/code/JsonEditor"; import * as Formy from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy"; @@ -215,7 +215,9 @@ function EntityMediaFormField({ }) { if (!entityId) return; - const client = useClient(); + const api = useApi(); + const baseUrl = useBaseUrl(); + const invalidate = useInvalidate(); const value = formApi.useStore((state) => { const val = state.values[field.name]; if (!val || typeof val === "undefined") return []; @@ -227,22 +229,21 @@ function EntityMediaFormField({ value.length === 0 ? [] : mediaItemsToFileStates(value, { - baseUrl: client.baseUrl, + baseUrl: api.baseUrl, overrides: { state: "uploaded" } }); const getUploadInfo = useEvent(() => { - const api = client.media().api(); return { - url: api.getEntityUploadUrl(entity.name, entityId, field.name), - headers: api.getUploadHeaders(), + url: api.media.getEntityUploadUrl(entity.name, entityId, field.name), + headers: api.media.getUploadHeaders(), method: "POST" }; }); - const handleDelete = useEvent(async (file) => { - client.__invalidate(entity.name, entityId); - return await client.media().deleteFile(file); + const handleDelete = useEvent(async (file: FileState) => { + invalidate((api) => api.data.readOne(entity.name, entityId)); + return api.media.deleteFile(file.path); }); return ( diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index 8a42699..861bc24 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -4,12 +4,11 @@ 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 { 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"; import { Popover } from "ui/components/overlay/Popover"; -import { useEntities } from "ui/container"; import { routes } from "ui/lib/routes"; import { useLocation } from "wouter"; import { EntityTable } from "../EntityTable"; @@ -31,25 +30,20 @@ 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 +51,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) { + const fetched = await $q.api.readOne(field.target(), relationalField); + 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/auth/auth.index.tsx b/app/src/ui/routes/auth/auth.index.tsx index ae7b9f0..21eace5 100644 --- a/app/src/ui/routes/auth/auth.index.tsx +++ b/app/src/ui/routes/auth/auth.index.tsx @@ -1,25 +1,19 @@ -import { useClient } from "ui/client"; +import { useApiQuery } from "ui/client"; import { useBknd } from "ui/client/bknd"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; +import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button"; import { Alert } from "ui/components/display/Alert"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; import { routes } from "ui/lib/routes"; -import { - Button, - ButtonLink, - type ButtonLinkProps, - type ButtonProps -} from "../../components/buttons/Button"; -import * as AppShell from "../../layouts/AppShell/AppShell"; export function AuthIndex() { - const client = useClient(); const { app } = useBknd(); const { config: { roles, strategies, entity_name, enabled } } = useBkndAuth(); const users_entity = entity_name; - const query = client.query().data.entity("users").count(); - const usersTotal = query.data?.body.count ?? 0; + const $q = useApiQuery((api) => api.data.count(users_entity)); + const usersTotal = $q.data?.count ?? 0; const rolesTotal = Object.keys(roles ?? {}).length ?? 0; const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0; diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index 20349e6..09cd38a 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -1,13 +1,12 @@ import { ucFirst } from "core/utils"; -import type { Entity, EntityData, EntityRelation } from "data"; +import type { Entity, EntityData, EntityRelation, RepoQuery } from "data"; import { Fragment, useState } from "react"; import { TbDots } from "react-icons/tb"; -import { useClient } from "ui/client"; +import { useApiQuery, 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" @@ -236,18 +232,17 @@ function EntityDetailInner({ relation: EntityRelation; }) { const other = relation.other(entity); - const client = useClient(); const [navigate] = useNavigate(); - const search = { + const search: Partial = { select: other.entity.getSelect(undefined, "table"), limit: 10, offset: 0 }; - const query = client - .query() - .data.entity(entity.name) - .readManyByReference(id, other.reference, other.entity.name, search); + // @todo: add custom key for invalidation + const $q = useApiQuery((api) => + api.data.readManyByReference(entity.name, id, other.reference, search) + ); function handleClickRow(row: Record) { navigate(routes.data.entity.edit(other.entity.name, row.id)); @@ -266,12 +261,11 @@ function EntityDetailInner({ } } - if (query.isPending) { + if (!$q.data) { return null; } - const isUpdating = query.isInitialLoading || query.isFetching; - //console.log("query", query, search.select); + const isUpdating = $q.isValidating || $q.isLoading; return (
diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 3d3a09a..5b16b64 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 type { EntityData } from "data"; import { useState } from "react"; +import { useEntityMutate } from "ui/client"; +import { useBknd } from "ui/client/BkndProvider"; +import { Button } from "ui/components/buttons/Button"; +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 ( -
- -
- ); - }} -
+ +
diff --git a/app/src/ui/routes/media/_media.root.tsx b/app/src/ui/routes/media/_media.root.tsx index 2b23bb6..dcb70d8 100644 --- a/app/src/ui/routes/media/_media.root.tsx +++ b/app/src/ui/routes/media/_media.root.tsx @@ -1,16 +1,17 @@ import { IconPhoto } from "@tabler/icons-react"; +import type { MediaFieldSchema } from "modules"; import { TbSettings } from "react-icons/tb"; -import { Dropzone } from "ui/modules/media/components/dropzone/Dropzone"; +import { useApi, useBaseUrl, useEntityQuery } from "ui/client"; +import { useBknd } from "ui/client/BkndProvider"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { Empty } from "ui/components/display/Empty"; +import { Link } from "ui/components/wouter/Link"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; +import { useEvent } from "ui/hooks/use-event"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { Dropzone, type FileState } from "ui/modules/media/components/dropzone/Dropzone"; import { mediaItemsToFileStates } from "ui/modules/media/helper"; import { useLocation } from "wouter"; -import { useClient } from "../../client"; -import { useBknd } from "../../client/BkndProvider"; -import { IconButton } from "../../components/buttons/IconButton"; -import { Empty } from "../../components/display/Empty"; -import { Link } from "../../components/wouter/Link"; -import { useBrowserTitle } from "../../hooks/use-browser-title"; -import { useEvent } from "../../hooks/use-event"; -import * as AppShell from "../../layouts/AppShell/AppShell"; export function MediaRoot({ children }) { const { app, config } = useBknd(); @@ -62,32 +63,30 @@ export function MediaRoot({ children }) { // @todo: add infinite load export function MediaEmpty() { useBrowserTitle(["Media"]); - const client = useClient(); - const query = client.media().list({ limit: 50 }); + const baseUrl = useBaseUrl(); + const api = useApi(); + const $q = useEntityQuery("media", undefined, { limit: 50 }); const getUploadInfo = useEvent((file) => { - const api = client.media().api(); return { - url: api.getFileUploadUrl(file), - headers: api.getUploadHeaders(), + url: api.media.getFileUploadUrl(file), + headers: api.media.getUploadHeaders(), method: "POST" }; }); - const handleDelete = useEvent(async (file) => { - return await client.media().deleteFile(file); + const handleDelete = useEvent(async (file: FileState) => { + return api.media.deleteFile(file.path); }); - const media = query.data?.data || []; - const initialItems = mediaItemsToFileStates(media, { baseUrl: client.baseUrl }); - - console.log("initialItems", initialItems); + const media = ($q.data || []) as MediaFieldSchema[]; + const initialItems = mediaItemsToFileStates(media, { baseUrl }); return (
null; /*!isDebug() - ? () => null // Render nothing in production - : lazy(() => - import("@tanstack/react-query-devtools").then((res) => ({ - default: res.ReactQueryDevtools, - })), - );*/ - export const Root = ({ children }) => { const { verify } = useAuth(); @@ -26,10 +17,6 @@ export const Root = ({ children }) => { {children} - - - - ); }; diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index 9cc89d4..5c758b1 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -1,5 +1,7 @@ import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; import SwaggerTest from "ui/routes/test/tests/swagger-test"; +import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; +import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; import { Route, useParams } from "wouter"; import { Empty } from "../../components/display/Empty"; import { Link } from "../../components/wouter/Link"; @@ -37,7 +39,9 @@ const tests = { EntityFieldsForm, FlowsTest, AppShellAccordionsTest, - SwaggerTest + SwaggerTest, + SWRAndAPI, + SwrAndDataApi } as const; export default function TestRoutes() { diff --git a/app/src/ui/routes/test/tests/swr-and-api.tsx b/app/src/ui/routes/test/tests/swr-and-api.tsx new file mode 100644 index 0000000..53c632e --- /dev/null +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { useApiQuery } from "ui/client"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; + +export default function SWRAndAPI() { + const [text, setText] = useState(""); + const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), { + refine: (data) => data.data, + revalidateOnFocus: true + }); + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + return ( + +
{JSON.stringify(r.promise.keyArray({ search: false }))}
+ {r.error &&
failed to load
} + {r.isLoading &&
loading...
} + {data &&
{JSON.stringify(data, null, 2)}
} + {data && ( +
{ + e.preventDefault(); + if (!comment) return; + + await r.mutate(async () => { + const res = await r.api.data.updateOne("comments", comment.id, { + content: text + }); + return res.data; + }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ )} +
+ ); +} diff --git a/app/src/ui/routes/test/tests/swr-and-data-api.tsx b/app/src/ui/routes/test/tests/swr-and-data-api.tsx new file mode 100644 index 0000000..7c2e2a6 --- /dev/null +++ b/app/src/ui/routes/test/tests/swr-and-data-api.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { useEntity, useEntityQuery } from "ui/client/api/use-entity"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; + +export default function SwrAndDataApi() { + return ( +
+ + +
+ ); +} + +function QueryDataApi() { + const [text, setText] = useState(""); + const { data, update, ...r } = useEntityQuery("comments", 1, {}); + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + return ( + +
{JSON.stringify(r.key)}
+ {r.error &&
failed to load
} + {r.isLoading &&
loading...
} + {data &&
{JSON.stringify(data, null, 2)}
} + {data && ( +
{ + e.preventDefault(); + if (!comment) return; + await update({ content: text }); + return false; + }} + > + setText(e.target.value)} /> + +
+ )} +
+ ); +} + +function DirectDataApi() { + const [data, setData] = useState(); + const { create, read, update, _delete } = useEntity("comments", 1); + + useEffect(() => { + read().then(setData); + }, []); + + return
{JSON.stringify(data, null, 2)}
; +} diff --git a/bun.lockb b/bun.lockb index 4edbd27..26c6843 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/mint.json b/docs/mint.json index 5be4fbe..69cde17 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -61,7 +61,7 @@ "navigation": [ { "group": "Getting Started", - "pages": ["introduction", "setup", "sdk", "cli"] + "pages": ["introduction", "setup", "sdk", "react", "cli"] }, { "group": "Modules", diff --git a/docs/react.mdx b/docs/react.mdx new file mode 100644 index 0000000..1c30c2c --- /dev/null +++ b/docs/react.mdx @@ -0,0 +1,194 @@ +--- +title: 'SDK (React)' +description: 'Use the bknd SDK for React' +--- + +For all SDK options targeting React, you always have 2 options: +1. use simple hooks which are solely based on the [API](/sdk) +2. use the query hook that makes wraps the API in [SWR](https://swr.vercel.app/) + +To use the simple hook that returns the Api, you can use: +```tsx +import { useApi } from "bknd/client"; + +export default function App() { + const api = useApi(); + // ... +} +``` + +## `useApiQuery([selector], [options])` +This hook wraps the API class in an SWR hook for convenience. You can use any API endpoint +supported, like so: +```tsx +import { useApiQuery } from "bknd/client"; + +export default function App() { + const { data, ...swr } = useApiQuery((api) => api.data.readMany("comments")); + + if (swr.error) return
Error
+ if (swr.isLoading) return
Loading...
+ + return
{JSON.stringify(data, null, 2)}
+} +``` + +### Props +* `selector: (api: Api) => FetchPromise` + + The first parameter is a selector function that provides an Api instance and expects an + endpoint function to be returned. + +* `options`: optional object that inherits from `SWRConfiguration` + + ```ts + type Options = SWRConfiguration & { + enabled?: boolean; + refine?: (data: Data) => Data | any; + } + ``` + + * `enabled`: Determines whether this hook should trigger a fetch of the data or not. + * `refine`: Optional refinement that is called after a response from the API has been + received. Useful to omit irrelevant data from the response (see example below). + +### Using mutations +To query and mutate data using this hook, you can leverage the parameters returned. In the +following example we'll also use a `refine` function as well as `revalidateOnFocus` (option from +`SWRConfiguration`) so that our data keeps updating on window focus change. + +```tsx +import { useState } from "react"; +import { useApiQuery } from "bknd/client"; + +export default function App() { + const [text, setText] = useState(""); + const { data, api, mutate, ...q } = useApiQuery( + (api) => api.data.readOne("comments", 1), + { + // filter to a subset of the response + refine: (data) => data.data, + revalidateOnFocus: true + } + ); + + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + if (q.error) return
Error
+ if (q.isLoading) return
Loading...
+ + return ( +
{ + e.preventDefault(); + if (!comment) return; + + // this will automatically revalidate the query + await mutate(async () => { + const res = await api.data.updateOne("comments", comment.id, { + content: text + }); + return res.data; + }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ ); +} +``` + +## `useEntity()` +This hook wraps the endpoints of `DataApi` and returns CRUD options as parameters: +```tsx +import { useState } from "react", +import { useEntity } from "bknd/client"; + +export default function App() { + const [data, setData] = useState(); + const { create, read, update, _delete } = useEntity("comments", 1); + + useEffect(() => { + read().then(setData); + }, []); + + return
{JSON.stringify(data, null, 2)}
+} +``` +If you only supply the entity name as string without an ID, the `read` method will fetch a list +of entities instead of a single entry. + +### Props +Following props are available when using `useEntityQuery([entity], [id?])`: +- `entity: string`: Specify the table name of the entity +- `id?: number | string`: If an id given, it will fetch a single entry, otherwise a list + +### Returned actions +The following actions are returned from this hook: +- `create: (input: object)`: Create a new entry +- `read: (query: Partial = {})`: If an id was given, +it returns a single item, otherwise a list +- `update: (input: object, id?: number | string)`: If an id was given, the id parameter is +optional. Updates the given entry partially. +- `_delete: (id?: number | string)`: If an id was given, the id parameter is +optional. Deletes the given entry. + +## `useEntityQuery()` +This hook wraps the actions from `useEntity` around `SWR`. The previous example would look like +this: +```tsx +import { useState } from "react", +import { useEntityQuery } from "bknd/client"; + +export default function App() { + const { data } = useEntityQuery("comments", 1); + + return
{JSON.stringify(data, null, 2)}
+} +``` + +### Using mutations +All actions returned from `useEntityQuery` are conveniently wrapped around the `mutate` function, +so you don't have think about this: +```tsx +import { useState } from "react"; +import { useEntityQuery } from "bknd/client"; + +export default function App() { + const [text, setText] = useState(""); + const { data, update, ...q } = useEntityQuery("comments", 1); + + const comment = data ? data : null; + + useEffect(() => { + setText(comment?.content ?? ""); + }, [comment]); + + if (q.error) return
Error
+ if (q.isLoading) return
Loading...
+ + return ( +
{ + e.preventDefault(); + if (!comment) return; + + // this will automatically revalidate the query + await update({ content: text }); + + return false; + }} + > + setText(e.target.value)} /> + +
+ ); +} +``` diff --git a/examples/plasmic/.gitignore b/examples/plasmic/.gitignore new file mode 100644 index 0000000..d0d878e --- /dev/null +++ b/examples/plasmic/.gitignore @@ -0,0 +1 @@ +.next \ No newline at end of file diff --git a/examples/plasmic/next-env.d.ts b/examples/plasmic/next-env.d.ts new file mode 100644 index 0000000..a4a7b3f --- /dev/null +++ b/examples/plasmic/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/examples/plasmic/next.config.ts b/examples/plasmic/next.config.ts new file mode 100644 index 0000000..3915163 --- /dev/null +++ b/examples/plasmic/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/examples/plasmic/package.json b/examples/plasmic/package.json new file mode 100644 index 0000000..d530a6c --- /dev/null +++ b/examples/plasmic/package.json @@ -0,0 +1,26 @@ +{ + "name": "plasmic-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@plasmicapp/loader-nextjs": "^1.0.409", + "bknd": "workspace:*", + "@bknd/plasmic": "workspace:*", + "next": "15.0.4", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "postcss": "^8" + } +} diff --git a/examples/plasmic/postcss.config.mjs b/examples/plasmic/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/examples/plasmic/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/examples/plasmic/src/pages/[[...catchall]].tsx b/examples/plasmic/src/pages/[[...catchall]].tsx new file mode 100644 index 0000000..4de4a49 --- /dev/null +++ b/examples/plasmic/src/pages/[[...catchall]].tsx @@ -0,0 +1,78 @@ +import { PLASMIC } from "@/plasmic-init"; +import { + type ComponentRenderData, + PlasmicComponent, + PlasmicRootProvider, + extractPlasmicQueryData +} from "@plasmicapp/loader-nextjs"; +import type { GetServerSideProps } from "next"; +// biome-ignore lint/suspicious/noShadowRestrictedNames: +import Error from "next/error"; +import { useRouter } from "next/router"; +import * as React from "react"; + +export const getServerSideProps: GetServerSideProps = async (context) => { + const { catchall } = context.params ?? {}; + + // Convert the catchall param into a path string + const plasmicPath = + typeof catchall === "string" + ? catchall + : Array.isArray(catchall) + ? `/${catchall.join("/")}` + : "/"; + const plasmicData = await PLASMIC.maybeFetchComponentData(plasmicPath); + if (!plasmicData) { + // This is some non-Plasmic catch-all page + return { + props: {} + }; + } + + // This is a path that Plasmic knows about. + const pageMeta = plasmicData.entryCompMetas[0]; + + // Cache the necessary data fetched for the page. + const queryCache = await extractPlasmicQueryData( + + {/* @ts-ignore */} + + + ); + + // Pass the data in as props. + return { + props: { plasmicData, queryCache } + }; +}; + +export default function CatchallPage(props: { + plasmicData?: ComponentRenderData; + queryCache?: Record; +}) { + const { plasmicData, queryCache } = props; + const router = useRouter(); + if (!plasmicData || plasmicData.entryCompMetas.length === 0) { + return ; + } + const pageMeta = plasmicData.entryCompMetas[0]; + return ( + // Pass in the data fetched in getStaticProps as prefetchedData + + {/* @ts-ignore */} + + + ); +} diff --git a/examples/plasmic/src/pages/_app.tsx b/examples/plasmic/src/pages/_app.tsx new file mode 100644 index 0000000..80eb165 --- /dev/null +++ b/examples/plasmic/src/pages/_app.tsx @@ -0,0 +1,11 @@ +import "@/styles/globals.css"; +import { ClientProvider } from "bknd/client"; +import type { AppProps } from "next/app"; + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ); +} diff --git a/examples/plasmic/src/pages/_document.tsx b/examples/plasmic/src/pages/_document.tsx new file mode 100644 index 0000000..628a733 --- /dev/null +++ b/examples/plasmic/src/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + + + +
+ + + + ); +} diff --git a/examples/plasmic/src/pages/admin/[[...admin]].tsx b/examples/plasmic/src/pages/admin/[[...admin]].tsx new file mode 100644 index 0000000..fb28734 --- /dev/null +++ b/examples/plasmic/src/pages/admin/[[...admin]].tsx @@ -0,0 +1,25 @@ +// pages/admin/[[...admin]].tsx +import { withApi } from "bknd/adapter/nextjs"; +import dynamic from "next/dynamic"; +import "bknd/dist/styles.css"; + +/*export const config = { + runtime: "experimental-edge" +}*/ + +const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { + ssr: false, +}); + +export const getServerSideProps = withApi(async (context) => { + return { + props: { + user: context.api.getUser(), + }, + }; +}); + +export default function AdminPage() { + if (typeof document === "undefined") return null; + return ; +} diff --git a/examples/plasmic/src/pages/api/[...route].ts b/examples/plasmic/src/pages/api/[...route].ts new file mode 100644 index 0000000..f296f98 --- /dev/null +++ b/examples/plasmic/src/pages/api/[...route].ts @@ -0,0 +1,16 @@ +import { serve } from "bknd/adapter/nextjs"; + +export const config = { + runtime: "edge", + unstable_allowDynamic: ["**/*.js"] +}; + +export default serve({ + connection: { + type: "libsql", + config: { + url: process.env.DB_URL!, + authToken: process.env.DB_TOKEN! + } + } +}); diff --git a/examples/plasmic/src/pages/plasmic-host.tsx b/examples/plasmic/src/pages/plasmic-host.tsx new file mode 100644 index 0000000..7dac28d --- /dev/null +++ b/examples/plasmic/src/pages/plasmic-host.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { PlasmicCanvasHost } from '@plasmicapp/loader-nextjs'; +import { PLASMIC } from '@/plasmic-init'; + +export default function PlasmicHost() { + return PLASMIC && ; +} \ No newline at end of file diff --git a/examples/plasmic/src/pages/test.tsx b/examples/plasmic/src/pages/test.tsx new file mode 100644 index 0000000..5b66ecc --- /dev/null +++ b/examples/plasmic/src/pages/test.tsx @@ -0,0 +1,6 @@ +import { useApi } from "bknd/client"; + +export default function Test() { + const api = useApi(undefined); + return
{api.baseUrl}
; +} diff --git a/examples/plasmic/src/plasmic-init.ts b/examples/plasmic/src/plasmic-init.ts new file mode 100644 index 0000000..9c77d9d --- /dev/null +++ b/examples/plasmic/src/plasmic-init.ts @@ -0,0 +1,17 @@ +import { initPlasmicLoader } from "@plasmicapp/loader-nextjs"; +import { loader } from "@bknd/plasmic"; + +export const PLASMIC = initPlasmicLoader({ + projects: [ + { + id: process.env.PLASMIC_ID!, + token: process.env.PLASMIC_TOKEN!, + } + ], + preview: true, //process.env.NODE_ENV === "development", +}) + +loader(PLASMIC); +/* +PLASMIC.registerComponent(BkndData, BkndDataMeta); +PLASMIC.registerGlobalContext(BkndContext, BkndContextMeta as any);*/ diff --git a/examples/plasmic/src/styles/globals.css b/examples/plasmic/src/styles/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/examples/plasmic/src/styles/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/plasmic/tailwind.config.ts b/examples/plasmic/tailwind.config.ts new file mode 100644 index 0000000..109807b --- /dev/null +++ b/examples/plasmic/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from "tailwindcss"; + +export default { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: "var(--background)", + foreground: "var(--foreground)", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/examples/plasmic/tsconfig.json b/examples/plasmic/tsconfig.json new file mode 100644 index 0000000..ecf7ecf --- /dev/null +++ b/examples/plasmic/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "noImplicitAny": false, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/plasmic/components/WouterLink.tsx b/packages/plasmic/components/WouterLink.tsx deleted file mode 100644 index a7472dc..0000000 --- a/packages/plasmic/components/WouterLink.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { CodeComponentMeta } from "@plasmicapp/host"; -import { Link } from "wouter"; - -export function WouterLink({ href, className, children, ...props }) { - return ( - - {children} - - ); -} - -export const WouterLinkMeta: CodeComponentMeta = { - name: "WouterLink", - importPath: import.meta.dir, - props: { - href: { - type: "href", - }, - children: { - type: "slot", - }, - }, -}; diff --git a/packages/plasmic/index.ts b/packages/plasmic/index.ts deleted file mode 100644 index 05e7de8..0000000 --- a/packages/plasmic/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { loader as loadBkndComponents, CatchAllPage, createWouterPlasmicApp } from "./loader"; - -export * from "./components"; -export * from "./contexts"; diff --git a/packages/plasmic/loader.tsx b/packages/plasmic/loader.tsx deleted file mode 100644 index f81547f..0000000 --- a/packages/plasmic/loader.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { PlasmicCanvasHost, type registerComponent } from "@plasmicapp/host"; -import { - type ComponentRenderData, - PlasmicComponent, - type PlasmicComponentLoader, - PlasmicRootProvider -} from "@plasmicapp/loader-react"; -import { forwardRef, useEffect, useState } from "react"; -import { Link, Route, Router, Switch } from "wouter"; -import { - BkndData, - BkndDataMeta, - Image, - ImageMeta, - LazyRender, - LazyRenderMeta, - WouterLink, - WouterLinkMeta -} from "./components"; -import { BkndContext, BkndContextMeta } from "./contexts"; - -export function loader(PLASMIC: PlasmicComponentLoader) { - PLASMIC.registerComponent(BkndData, BkndDataMeta); - PLASMIC.registerComponent(WouterLink, WouterLinkMeta); - PLASMIC.registerComponent(Image, ImageMeta); - PLASMIC.registerComponent(LazyRender, LazyRenderMeta); - PLASMIC.registerGlobalContext(BkndContext, BkndContextMeta as any); -} - -const CustomLink = forwardRef((props, ref) => { - //console.log("rendering custom link", props); - //return null; - if ("data-replace" in props) { - return ; - } - //return ; - // @ts-ignore it's because of the link - return ; -}); - -const Wrapper = ({ children }) => { - return ( -
-
{children}
-
- ); -}; - -export function CatchAllPage({ - PLASMIC, - prefix = "" -}: { PLASMIC: PlasmicComponentLoader; prefix?: string }) { - const [loading, setLoading] = useState(true); - const [pageData, setPageData] = useState(null); - - //const params = useParams(); - const pathname = location.pathname.replace(prefix, ""); - const path = pathname.length === 0 ? "/" : pathname; - //console.log("path", path, params); - useEffect(() => { - async function load() { - const pageData = await PLASMIC.maybeFetchComponentData(path); - //console.log("pageData", pageData); - setPageData(pageData); - setLoading(false); - } - load(); - }, []); - - if (loading) { - return Loading ...; - } - if (!pageData) { - return Not found; - } - - const pageMeta = pageData.entryCompMetas[0]; - - // The page will already be cached from the `load` call above. - return ( - - - - ); -} - -export function createWouterPlasmicApp(PLASMIC: PlasmicComponentLoader, prefix = "") { - return function App() { - return ( - - - - } - /> - - - ); - }; -} diff --git a/packages/plasmic/package.json b/packages/plasmic/package.json index 2ad935c..8d128c5 100644 --- a/packages/plasmic/package.json +++ b/packages/plasmic/package.json @@ -1,5 +1,6 @@ { "name": "@bknd/plasmic", + "version": "0.3.4-alpha1", "type": "module", "sideEffects": false, "scripts": { @@ -8,41 +9,61 @@ "build:only": "rm -rf dist && bun tsup", "types": "bun tsc -p tsconfig.json --noEmit --skipLibCheck", "build:types": "bun tsc --emitDeclarationOnly", - "updater": "bun x npm-check-updates -ui" + "updater": "bun x npm-check-updates -ui", + "pack": "rm -rf *.tgz && npm pack && mv *.tgz latest.tgz", + "prepublishOnly": "bun run build" }, - "dependencies": { - "wouter": "^3.3.5" + "publishConfig": { + "access": "public" }, + "dependencies": {}, "devDependencies": { "@types/bun": "latest", + "bknd": "workspace:*", + "tsdx": "^0.14.1", "typescript": "^5.0.0" }, "peerDependencies": { - "@plasmicapp/host": ">=1.0.0", - "bknd": "workspace:*", + "bknd": "*", "react": ">=18", - "react-dom": ">=18" + "react-dom": ">=18", + "@plasmicapp/host": ">=1.0.0", + "@plasmicapp/query": ">=0.1.0" }, "tsup": { - "entry": ["index.ts"], - "minify": true, + "entry": [ + "src/index.ts" + ], + "minify": false, "clean": true, - "external": ["react", "react-dom", "@plasmicapp/host", "@plasmicapp/loader-react", "@plasmicapp/loader-core"], - "format": ["esm"], + "external": [ + "react", + "react-dom", + "@plasmicapp/host", + "@plasmicapp/query", + "swr" + ], + "format": [ + "esm", + "cjs" + ], "platform": "browser", - "shims": true, "bundle": true, "metafile": true, - "splitting": false, + "splitting": true, "sourceMap": true, "outDir": "dist" }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" - } - }, - "files": ["dist"] + "types": "dist/index.d.ts", + "module": "dist/index.js", + "main": "dist/index.cjs", + "files": [ + "dist", + "README.md", + "!dist/*.tsbuildinfo", + "!dist/*.map", + "!dist/**/*.map", + "!dist/metafile*", + "!dist/**/metafile*" + ] } diff --git a/packages/plasmic/components/Image.tsx b/packages/plasmic/src/components/Image.tsx similarity index 86% rename from packages/plasmic/components/Image.tsx rename to packages/plasmic/src/components/Image.tsx index 5c02e68..9c50439 100644 --- a/packages/plasmic/components/Image.tsx +++ b/packages/plasmic/src/components/Image.tsx @@ -1,6 +1,7 @@ -"use client"; - import type { CodeComponentMeta } from "@plasmicapp/host"; +import registerComponent, { type ComponentMeta } from "@plasmicapp/host/registerComponent"; +// biome-ignore lint/style/useImportType: +import React from "react"; //import { PlasmicCanvasContext } from "@plasmicapp/loader-react"; import { useContext, useEffect, useRef, useState } from "react"; @@ -39,7 +40,7 @@ function numeric(value: number | string): number { function getDimensionDefaults( width: number | string | undefined, height: number | string | undefined, - ratio: number | undefined, + ratio: number | undefined ) { let _width = width; let _height = height; @@ -61,7 +62,7 @@ function getDimensionDefaults( function getPlaceholderStyle( width: number | string | undefined, height: number | string | undefined, - ratio: number | undefined, + ratio: number | undefined ) { let paddingBottom = 0; if (width && height) { @@ -73,7 +74,7 @@ function getPlaceholderStyle( } return { - paddingBottom: paddingBottom + "%", + paddingBottom: paddingBottom + "%" }; } @@ -126,7 +127,7 @@ export const Image: React.FC = ({ } }); }, - { threshold: loadTreshold }, + { threshold: loadTreshold } ); if (imgRef.current) { observer.observe(imgRef.current); @@ -150,7 +151,7 @@ export const Image: React.FC = ({ const { width: _width, height: _height, - ratio: _ratio, + ratio: _ratio } = getDimensionDefaults(width, height, ratio); const imgStyle: any = { @@ -163,7 +164,7 @@ export const Image: React.FC = ({ height: "auto", //height: _height || "auto", //height: !transitioned ? _height || "auto" : "auto", - opacity: forceLoad || loaded ? 1 : 0, + opacity: forceLoad || loaded ? 1 : 0 }; const placeholderStyle: any = { @@ -174,7 +175,7 @@ export const Image: React.FC = ({ width: _width || "100%", height: 0, //height: transitioned ? "auto" : 0, - ...getPlaceholderStyle(_width, _height, _ratio), + ...getPlaceholderStyle(_width, _height, _ratio) }; const wrapperStyle: any = { @@ -186,7 +187,7 @@ export const Image: React.FC = ({ lineHeight: 0, //height: _height, maxWidth: "100%", - maxHeight: "100%", + maxHeight: "100%" }; if (loaded) { wrapperStyle.height = "auto"; @@ -213,13 +214,24 @@ export const Image: React.FC = ({ ); }; -export const ImageMeta: CodeComponentMeta> = { +export function registerImage( + loader?: { registerComponent: typeof registerComponent }, + customMeta?: ComponentMeta +) { + if (loader) { + loader.registerComponent(Image, customMeta ?? ImageMeta); + } else { + registerComponent(Image, customMeta ?? ImageMeta); + } +} + +export const ImageMeta: CodeComponentMeta = { name: "ImageLazy", - importPath: import.meta.dir, + importPath: "@bknd/plasmic", props: { src: { type: "imageUrl", - displayName: "Image", + displayName: "Image" }, alt: "string", width: "number", @@ -230,14 +242,14 @@ export const ImageMeta: CodeComponentMeta> = { //backgroundColor: "color", transitionSpeed: { type: "number", - helpText: "How fast image should fade in. Default is 200 (ms).", + helpText: "How fast image should fade in. Default is 200 (ms)." }, loadTreshold: { type: "number", displayName: "Treshold", //defaultValue: 0.1, helpText: - "Number between 0 and 1. Default is 0.1. Determines how much of the image must be in viewport before it gets loaded", - }, - }, + "Number between 0 and 1. Default is 0.1. Determines how much of the image must be in viewport before it gets loaded" + } + } }; diff --git a/packages/plasmic/components/LazyRender.tsx b/packages/plasmic/src/components/LazyRender.tsx similarity index 73% rename from packages/plasmic/components/LazyRender.tsx rename to packages/plasmic/src/components/LazyRender.tsx index 2de5e06..a49e3a3 100644 --- a/packages/plasmic/components/LazyRender.tsx +++ b/packages/plasmic/src/components/LazyRender.tsx @@ -1,4 +1,7 @@ import type { CodeComponentMeta } from "@plasmicapp/host"; +import registerComponent, { type ComponentMeta } from "@plasmicapp/host/registerComponent"; +// biome-ignore lint/style/useImportType: +import React from "react"; import { useEffect, useRef, useState } from "react"; interface LazyRenderProps { @@ -22,7 +25,7 @@ export const LazyRender: React.FC = ({ threshold = 0.1, delay = 0, fallback = , - onBecomesVisible, + onBecomesVisible }) => { const [isVisible, setIsVisible] = useState(forceLoad); const ref = useRef(null); @@ -43,7 +46,7 @@ export const LazyRender: React.FC = ({ } const observerOptions: IntersectionObserverInit = { - threshold: threshold < 1 ? threshold : 0.1, + threshold: threshold < 1 ? threshold : 0.1 }; const observerCallback: IntersectionObserverCallback = (entries) => { @@ -74,38 +77,49 @@ export const LazyRender: React.FC = ({ ); }; -export const LazyRenderMeta: CodeComponentMeta> = { +export function registerLazyRender( + loader?: { registerComponent: typeof registerComponent }, + customMeta?: ComponentMeta +) { + if (loader) { + loader.registerComponent(LazyRender, customMeta ?? LazyRenderMeta); + } else { + registerComponent(LazyRender, customMeta ?? LazyRenderMeta); + } +} + +export const LazyRenderMeta: CodeComponentMeta = { name: "LazyRender", - importPath: import.meta.dir, + importPath: "@bknd/plasmic", props: { forceLoad: { type: "boolean", - defaultValue: false, + defaultValue: false }, forceFallback: { type: "boolean", - defaultValue: false, + defaultValue: false }, threshold: { type: "number", - defaultValue: 0.1, + defaultValue: 0.1 }, fallback: { - type: "slot", + type: "slot" //allowedComponents: ["*"], }, delay: { type: "number", - defaultValue: 0, + defaultValue: 0 }, onBecomesVisible: { type: "code", - lang: "javascript", + lang: "javascript" }, children: { - type: "slot", + type: "slot" //allowedComponents: ["*"], - }, - }, + } + } }; diff --git a/packages/plasmic/components/data/BkndData.tsx b/packages/plasmic/src/components/data/BkndData.tsx similarity index 67% rename from packages/plasmic/components/data/BkndData.tsx rename to packages/plasmic/src/components/data/BkndData.tsx index f5fef9d..6f2068c 100644 --- a/packages/plasmic/components/data/BkndData.tsx +++ b/packages/plasmic/src/components/data/BkndData.tsx @@ -1,11 +1,13 @@ -import { type CodeComponentMeta, DataProvider, usePlasmicCanvasContext } from "@plasmicapp/host"; +import { DataProvider, usePlasmicCanvasContext } from "@plasmicapp/host"; +import registerComponent, { type ComponentMeta } from "@plasmicapp/host/registerComponent"; +import { usePlasmicQueryData } from "@plasmicapp/query"; +import { useApi, useEntityQuery } from "bknd/client"; import type { RepoQuery } from "bknd/data"; -import { useEntities, useEntity } from "bknd/ui"; -import { encodeSearch } from "bknd/utils"; -import { useContext, useEffect, useState } from "react"; +// biome-ignore lint/style/useImportType: +import React from "react"; import { usePlasmicBkndContext } from "../../contexts/BkndContext"; -type BkndEntitiesProps = { +type BkndDataProps = { children?: React.ReactNode; loading?: React.ReactNode; error?: React.ReactNode; @@ -23,10 +25,11 @@ type BkndEntitiesProps = { dataName?: string; entityId?: number; entity?: string; + select?: string[]; sortBy: string; sortDir: "asc" | "desc"; where?: string; - mode?: "fetch" | "react-query"; + mode?: "fetch" | "swr"; noLayout?: boolean; preview?: boolean; previewSlot?: "loading" | "error" | "empty"; @@ -61,11 +64,13 @@ export function BkndData({ sortBy = "id", sortDir = "asc", mode = "fetch", + select = [], noLayout, preview, previewSlot, ...props -}: BkndEntitiesProps) { +}: BkndDataProps) { + //console.log("--bknd data"); const inEditor = !!usePlasmicCanvasContext(); const plasmicContext = usePlasmicBkndContext(); @@ -100,6 +105,7 @@ export function BkndData({ } const query = { + select: select.length > 0 ? select : undefined, limit: entityId ? undefined : limit, offset: entityId ? undefined : offset, where: _where, @@ -108,7 +114,7 @@ export function BkndData({ join: joinRefs }; - console.log("---context", plasmicContext); + //console.log("---context", plasmicContext); if (plasmicContext.appConfig?.data?.entities) { const { entities, relations } = plasmicContext.appConfig.data; console.log("entities", entities); @@ -149,8 +155,7 @@ export function BkndData({ children }; - const Component = - mode === "react-query" ? : ; + const Component = mode === "swr" ? : ; return noLayout ? Component :
{Component}
; } @@ -175,32 +180,19 @@ const ModeFetch = ({ entity, query }: ModeProps) => { - const [data, setData] = useState([]); - const [isLoading, setLoading] = useState(true); - const [hasError, setError] = useState(); - const plasmicContext = usePlasmicBkndContext(); - const basepath = "/api/data"; - const path = entityId ? `${basepath}/${entity}/${entityId}` : `${basepath}/${entity}`; - console.log("query", path, query); - const url = `${plasmicContext.baseUrl}${path}?${encodeSearch(query)}`; - useEffect(() => { - (async () => { - try { - const res = await fetch(url); - const result = (await res.json()) as any; - //console.log("result", result); - setData(result.data); - setLoading(false); - setError(undefined); - } catch (e) { - console.error(e); - setError(String(e)); - setLoading(false); - } - })(); - }, [url]); + const api = useApi(); + const endpoint = entityId + ? api.data.readOne(entity, entityId, query) + : api.data.readMany(entity, query); - console.log("--data", { name: dataName ?? entity ?? "data", data, isLoading, hasError }); + const { + data, + error: hasError, + isLoading + } = usePlasmicQueryData(endpoint.key(), async () => { + const res = await endpoint.execute(); + return res.data; + }); if (isLoading) { return ; @@ -213,7 +205,6 @@ const ModeFetch = ({ if (data.length === 0) { return ; } - console.log("--here1"); return ( @@ -222,85 +213,48 @@ const ModeFetch = ({ ); }; -const ModeReactQuery = (props: ModeProps) => { - return props.entityId ? ( - - ) : ( - - ); -}; +const ModeSWR = ({ children, loading, error, dataName, entityId, empty, entity }: ModeProps) => { + const $q = useEntityQuery(entity, entityId); -const ModeReactQuerySingle = ({ - children, - loading, - error, - dataName, - entityId, - empty, - entity -}: ModeProps) => { - const container = useEntity(entity, entityId); - const { isLoading, isError } = container.status.fetch; - - if (isLoading) { + if ($q.isLoading) { return ; } - if (isError) { + if ($q.error) { return ; } - if (!container.data) { + if (!$q.data) { return ; } return ( - + {children} ); }; -const ModeReactQueryMultiple = ({ - children, - loading, - error, - empty, - dataName, - entity, - query -}: ModeProps) => { - const container = useEntities(entity, query); - const { isLoading, isError } = container.status.fetch; - - if (isLoading) { - return ; +export function registerBkndData( + loader?: { registerComponent: typeof registerComponent }, + customMeta?: ComponentMeta +) { + if (loader) { + loader.registerComponent(BkndData, customMeta ?? BkndDataMeta); + } else { + registerComponent(BkndData, customMeta ?? BkndDataMeta); } +} - if (isError) { - return ; - } - - if (!container.data || container.data.length === 0) { - return ; - } - - return ( - - {children} - - ); -}; - -export const BkndDataMeta: CodeComponentMeta> = { +export const BkndDataMeta: ComponentMeta = { name: "BKND Data", section: "BKND", - importPath: import.meta.dir, + importPath: "@bknd/plasmic", providesData: true, props: { entity: { type: "choice", - options: (props, ctx) => ctx.entities + options: (props, ctx) => ctx?.entities ?? [] }, dataName: { type: "string" @@ -308,6 +262,10 @@ export const BkndDataMeta: CodeComponentMeta ctx?.fields ?? [] + }, limit: { type: "number", defaultValue: 10, @@ -326,13 +284,13 @@ export const BkndDataMeta: CodeComponentMeta ctx.references + options: (props, ctx) => ctx?.references ?? [] }, joinRefs: { displayName: "Join", type: "choice", multiSelect: true, - options: (props, ctx) => ctx.references + options: (props, ctx) => ctx?.references ?? [] }, where: { type: "code", @@ -340,7 +298,7 @@ export const BkndDataMeta: CodeComponentMeta ctx.fields + options: (props, ctx) => ctx?.fields ?? [] }, sortDir: { type: "choice", @@ -361,7 +319,7 @@ export const BkndDataMeta: CodeComponentMeta +import React from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; // Users will be able to set these props in Studio. @@ -18,17 +24,6 @@ type BkndContextProps = { const BkndContextContext = createContext({} as any); -function getBaseUrlFromWindow() { - if (typeof window === "undefined") { - return ""; - } - - const protocol = window.location.protocol; - const host = window.location.host; - - return `${protocol}//${host}`; -} - // @todo: it's an issue that we need auth, so we cannot make baseurl adjustable (maybe add an option to useAuth with a specific base url?) export const BkndContext = ({ children, @@ -36,19 +31,15 @@ export const BkndContext = ({ initialAuth }: React.PropsWithChildren) => { const auth = useAuth(); - const baseurl = useBaseUrl(); + const baseurl = baseUrl ?? useBaseUrl(); + const api = useApi({ host: baseurl }); const [data, setData] = useState({ baseUrl: baseurl, - /*baseUrl: (baseUrl && baseUrl.length > 0 ? baseUrl : getBaseUrlFromWindow()).replace( - /\/+$/, - "" - ),*/ auth: auth ?? initialAuth, appConfig: undefined }); const inEditor = !!usePlasmicCanvasContext(); - console.log("context:user", data); useEffect(() => { setData((prev) => ({ ...prev, auth: auth })); @@ -57,8 +48,10 @@ export const BkndContext = ({ useEffect(() => { (async () => { if (inEditor) { - const res = await fetch(`${baseurl}/api/system/config`); - const result = (await res.json()) as BkndGlobalContextProps["appConfig"]; + const result = await api.system.readConfig(); + + /*const res = await fetch(`${baseurl}/api/system/config`); + const result = (await res.json()) as BkndGlobalContextProps["appConfig"];*/ console.log("appconfig", result); setData((prev) => ({ ...prev, appConfig: result })); } @@ -101,13 +94,12 @@ export const BkndContext = ({ [baseUrl] ); - console.log("plasmic.bknd.context", data); + console.log("plasmic.bknd.context", { baseUrl }); return ( - {/*{children}*/} - {children} + {children} @@ -119,8 +111,20 @@ export function usePlasmicBkndContext() { return context; } -export const BkndContextMeta = { +export function registerBkndContext( + loader?: { registerGlobalContext: typeof registerGlobalContext }, + customMeta?: GlobalContextMeta +) { + if (loader) { + loader.registerGlobalContext(BkndContext, customMeta ?? BkndContextMeta); + } else { + registerGlobalContext(BkndContext, customMeta ?? BkndContextMeta); + } +} + +export const BkndContextMeta: GlobalContextMeta = { name: "BkndContext", + importPath: "@bknd/plasmic", props: { baseUrl: { type: "string" }, initialAuth: { type: "object" } }, providesData: true, globalActions: { diff --git a/packages/plasmic/contexts/index.ts b/packages/plasmic/src/contexts/index.ts similarity index 100% rename from packages/plasmic/contexts/index.ts rename to packages/plasmic/src/contexts/index.ts diff --git a/packages/plasmic/src/index.ts b/packages/plasmic/src/index.ts new file mode 100644 index 0000000..2f57de8 --- /dev/null +++ b/packages/plasmic/src/index.ts @@ -0,0 +1,17 @@ +import type { registerComponent, registerGlobalContext } from "@plasmicapp/host"; +import { registerImage } from "./components/Image"; +import { registerLazyRender } from "./components/LazyRender"; +import { registerBkndData } from "./components/data/BkndData"; +import { registerBkndContext } from "./contexts/BkndContext"; + +export function registerAll(loader?: { + registerComponent: typeof registerComponent; + registerGlobalContext: typeof registerGlobalContext; +}) { + registerBkndData(loader); + registerBkndContext(loader); + registerImage(loader); + registerLazyRender(loader); +} + +export { registerBkndData, registerBkndContext }; diff --git a/packages/plasmic/tsconfig.json b/packages/plasmic/tsconfig.json index 7f2aec6..d8a2e77 100644 --- a/packages/plasmic/tsconfig.json +++ b/packages/plasmic/tsconfig.json @@ -4,19 +4,23 @@ "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", - "jsx": "react-jsx", + "jsx": "react", "allowJs": true, "moduleResolution": "bundler", "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "strict": true, "outDir": "dist", + "declarationDir": "dist", "declaration": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + "rootDir": "src", + "baseUrl": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, - "include": ["index.ts", "loader.tsx", "components", "contexts"], - "exclude": ["@bknd/app", "@bknd/core", "dist", "node_modules", "build.ts"] + "include": ["src/**/*"], + "exclude": ["bknd", "dist", "node_modules"] }