mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Merge pull request #31 from bknd-io/feat/optimize-api-and-replace-react-query
optimize client-side api & hooks
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ packages/media/.env
|
|||||||
**/*/.env
|
**/*/.env
|
||||||
**/*/.dev.vars
|
**/*/.dev.vars
|
||||||
**/*/.wrangler
|
**/*/.wrangler
|
||||||
|
**/*/*.tgz
|
||||||
**/*/vite.config.ts.timestamp*
|
**/*/vite.config.ts.timestamp*
|
||||||
.history
|
.history
|
||||||
**/*/.db/*
|
**/*/.db/*
|
||||||
|
|||||||
96
app/__test__/api/ModuleApi.spec.ts
Normal file
96
app/__test__/api/ModuleApi.spec.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
@@ -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);*/
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.3.3",
|
"version": "0.3.4-alpha1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:all": "bun run build && bun run build:cli",
|
"build:all": "bun run build && bun run build:cli",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -22,18 +22,19 @@
|
|||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cfworker/json-schema": "^2.0.1",
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@tanstack/react-form": "0.19.2",
|
|
||||||
"@sinclair/typebox": "^0.32.34",
|
"@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",
|
"kysely": "^0.27.4",
|
||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"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",
|
"oauth4webapi": "^2.11.1",
|
||||||
"aws4fetch": "^1.0.18"
|
"swr": "^2.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
@@ -54,8 +55,6 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"@rjsf/core": "^5.22.2",
|
"@rjsf/core": "^5.22.2",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@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/node": "^22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SafeUser } from "auth";
|
||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
@@ -5,7 +6,7 @@ import { omit } from "lodash-es";
|
|||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
|
|
||||||
export type TApiUser = object;
|
export type TApiUser = SafeUser;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -24,6 +25,12 @@ export type ApiOptions = {
|
|||||||
localStorage?: boolean;
|
localStorage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
token?: string;
|
||||||
|
user?: TApiUser;
|
||||||
|
verified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class Api {
|
export class Api {
|
||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: TApiUser;
|
private user?: TApiUser;
|
||||||
@@ -50,6 +57,10 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get baseUrl() {
|
||||||
|
return this.options.host;
|
||||||
|
}
|
||||||
|
|
||||||
get tokenKey() {
|
get tokenKey() {
|
||||||
return this.options.key ?? "auth";
|
return this.options.key ?? "auth";
|
||||||
}
|
}
|
||||||
@@ -85,7 +96,11 @@ export class Api {
|
|||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
this.token = token;
|
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) {
|
if (this.options.localStorage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
@@ -105,7 +120,7 @@ export class Api {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthState() {
|
getAuthState(): AuthState {
|
||||||
return {
|
return {
|
||||||
token: this.token,
|
token: this.token,
|
||||||
user: this.user,
|
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 {
|
getUser(): TApiUser | null {
|
||||||
return this.user || null;
|
return this.user || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
|
|
||||||
async loginWithPassword(input: any) {
|
async loginWithPassword(input: any) {
|
||||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
const res = await this.post<AuthResponse>(["password", "login"], input);
|
||||||
if (res.res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -23,17 +23,17 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
|
|
||||||
async registerWithPassword(input: any) {
|
async registerWithPassword(input: any) {
|
||||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
const res = await this.post<AuthResponse>(["password", "register"], input);
|
||||||
if (res.res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
async me() {
|
me() {
|
||||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async strategies() {
|
strategies() {
|
||||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async readOne(
|
readOne(
|
||||||
entity: string,
|
entity: string,
|
||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||||
@@ -23,14 +23,14 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
||||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||||
[entity],
|
[entity],
|
||||||
query ?? this.options.defaultQuery
|
query ?? this.options.defaultQuery
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readManyByReference(
|
readManyByReference(
|
||||||
entity: string,
|
entity: string,
|
||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
reference: string,
|
reference: string,
|
||||||
@@ -42,19 +42,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOne(entity: string, input: EntityData) {
|
createOne(entity: string, input: EntityData) {
|
||||||
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
||||||
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteOne(entity: string, id: PrimaryFieldType) {
|
deleteOne(entity: string, id: PrimaryFieldType) {
|
||||||
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async count(entity: string, where: RepoQuery["where"] = {}) {
|
count(entity: string, where: RepoQuery["where"] = {}) {
|
||||||
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
||||||
[entity, "fn", "count"],
|
[entity, "fn", "count"],
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function getChangeSet(
|
|||||||
data: EntityData,
|
data: EntityData,
|
||||||
fields: Field[]
|
fields: Field[]
|
||||||
): EntityData {
|
): EntityData {
|
||||||
|
//console.log("getChangeSet", formData, data);
|
||||||
return transform(
|
return transform(
|
||||||
formData,
|
formData,
|
||||||
(acc, _value, key) => {
|
(acc, _value, key) => {
|
||||||
@@ -26,11 +27,12 @@ export function getChangeSet(
|
|||||||
if (!field || field.isVirtual()) return;
|
if (!field || field.isVirtual()) return;
|
||||||
const value = _value === "" ? null : _value;
|
const value = _value === "" ? null : _value;
|
||||||
|
|
||||||
const newValue = field.getValue(value, "submit");
|
// normalize to null if undefined
|
||||||
|
const newValue = field.getValue(value, "submit") || null;
|
||||||
// @todo: add typing for "action"
|
// @todo: add typing for "action"
|
||||||
if (action === "create" || newValue !== data[key]) {
|
if (action === "create" || newValue !== data[key]) {
|
||||||
acc[key] = newValue;
|
acc[key] = newValue;
|
||||||
console.log("changed", {
|
/*console.log("changed", {
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
valueType: typeof value,
|
valueType: typeof value,
|
||||||
@@ -38,7 +40,7 @@ export function getChangeSet(
|
|||||||
newValue,
|
newValue,
|
||||||
new: value,
|
new: value,
|
||||||
sent: acc[key]
|
sent: acc[key]
|
||||||
});
|
});*/
|
||||||
} else {
|
} else {
|
||||||
//console.log("no change", key, value, data[key]);
|
//console.log("no change", key, value, data[key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,6 @@ export const querySchema = Type.Object(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type RepoQueryIn = Simplify<Static<typeof querySchema>>;
|
export type RepoQueryIn = Static<typeof querySchema>;
|
||||||
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
||||||
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFiles() {
|
getFiles() {
|
||||||
return this.get(["files"]);
|
return this.get(["files"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFile(filename: string) {
|
getFile(filename: string) {
|
||||||
return this.get(["file", filename]);
|
return this.get(["file", filename]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +32,13 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(file: File) {
|
uploadFile(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
return this.post(["upload"], formData);
|
return this.post(["upload"], formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile(filename: string) {
|
deleteFile(filename: string) {
|
||||||
return this.delete(["file", filename]);
|
return this.delete(["file", filename]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PrimaryFieldType } from "core";
|
import { type PrimaryFieldType, isDebug } from "core";
|
||||||
import { encodeSearch } from "core/utils";
|
import { encodeSearch } from "core/utils";
|
||||||
|
|
||||||
export type { PrimaryFieldType };
|
export type { PrimaryFieldType };
|
||||||
@@ -10,6 +10,7 @@ export type BaseModuleApiOptions = {
|
|||||||
token_transport?: "header" | "cookie" | "none";
|
token_transport?: "header" | "cookie" | "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
export type ApiResponse<Data = any> = {
|
export type ApiResponse<Data = any> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -18,7 +19,11 @@ export type ApiResponse<Data = any> = {
|
|||||||
res: Response;
|
res: Response;
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
export type TInput = string | (string | number | PrimaryFieldType)[];
|
||||||
|
|
||||||
|
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
|
||||||
|
protected fetcher?: typeof fetch;
|
||||||
|
|
||||||
constructor(protected readonly _options: Partial<Options> = {}) {}
|
constructor(protected readonly _options: Partial<Options> = {}) {}
|
||||||
|
|
||||||
protected getDefaultOptions(): Partial<Options> {
|
protected getDefaultOptions(): Partial<Options> {
|
||||||
@@ -35,14 +40,15 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getUrl(path: string) {
|
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<Data = any>(
|
protected request<Data = any>(
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
_input: TInput,
|
||||||
_query?: Record<string, any> | URLSearchParams,
|
_query?: Record<string, any> | URLSearchParams,
|
||||||
_init?: RequestInit
|
_init?: RequestInit
|
||||||
): Promise<ApiResponse<Data>> {
|
): FetchPromise<ResponseObject<Data>> {
|
||||||
const method = _init?.method ?? "GET";
|
const method = _init?.method ?? "GET";
|
||||||
const input = Array.isArray(_input) ? _input.join("/") : _input;
|
const input = Array.isArray(_input) ? _input.join("/") : _input;
|
||||||
let url = this.getUrl(input);
|
let url = this.getUrl(input);
|
||||||
@@ -78,14 +84,130 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("url", url);
|
const request = new Request(url, {
|
||||||
const res = await fetch(url, {
|
|
||||||
..._init,
|
..._init,
|
||||||
method,
|
method,
|
||||||
body,
|
body,
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new FetchPromise(request, {
|
||||||
|
fetcher: this.fetcher
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get<Data = any>(
|
||||||
|
_input: TInput,
|
||||||
|
_query?: Record<string, any> | URLSearchParams,
|
||||||
|
_init?: RequestInit
|
||||||
|
) {
|
||||||
|
return this.request<Data>(_input, _query, {
|
||||||
|
..._init,
|
||||||
|
method: "GET"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
post<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||||
|
return this.request<Data>(_input, undefined, {
|
||||||
|
..._init,
|
||||||
|
body,
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||||
|
return this.request<Data>(_input, undefined, {
|
||||||
|
..._init,
|
||||||
|
body,
|
||||||
|
method: "PATCH"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
put<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||||
|
return this.request<Data>(_input, undefined, {
|
||||||
|
..._init,
|
||||||
|
body,
|
||||||
|
method: "PUT"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<Data = any>(_input: TInput, _init?: RequestInit) {
|
||||||
|
return this.request<Data>(_input, undefined, {
|
||||||
|
..._init,
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResponseObject<Body = any, Data = Body extends { data: infer R } ? R : Body> = Data & {
|
||||||
|
raw: Response;
|
||||||
|
res: Response;
|
||||||
|
data: Data;
|
||||||
|
body: Body;
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
toJSON(): Data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createResponseProxy<Body = any, Data = any>(
|
||||||
|
raw: Response,
|
||||||
|
body: Body,
|
||||||
|
data?: Data
|
||||||
|
): ResponseObject<Body, Data> {
|
||||||
|
const actualData = data ?? (body as unknown as Data);
|
||||||
|
const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"];
|
||||||
|
|
||||||
|
return new Proxy(actualData as any, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "raw" || prop === "res") return raw;
|
||||||
|
if (prop === "body") return body;
|
||||||
|
if (prop === "data") return data;
|
||||||
|
if (prop === "ok") return raw.ok;
|
||||||
|
if (prop === "status") return raw.status;
|
||||||
|
if (prop === "toJSON") {
|
||||||
|
return () => target;
|
||||||
|
}
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
has(target, prop) {
|
||||||
|
if (_props.includes(prop as string)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Reflect.has(target, prop);
|
||||||
|
},
|
||||||
|
ownKeys(target) {
|
||||||
|
return Array.from(new Set([...Reflect.ownKeys(target), ..._props]));
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, prop) {
|
||||||
|
if (_props.includes(prop as string)) {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: Reflect.get({ raw, body, ok: raw.ok, status: raw.status }, prop)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||||
|
}
|
||||||
|
}) as ResponseObject<Body, Data>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
|
||||||
|
// @ts-ignore
|
||||||
|
[Symbol.toStringTag]: "FetchPromise";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public request: Request,
|
||||||
|
protected options?: {
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(): Promise<ResponseObject<T>> {
|
||||||
|
// 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 resBody: any;
|
||||||
let resData: any;
|
let resData: any;
|
||||||
|
|
||||||
@@ -99,69 +221,51 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
resBody = await res.text();
|
resBody = await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return createResponseProxy<T>(res, resBody, resData);
|
||||||
success: res.ok,
|
|
||||||
status: res.status,
|
|
||||||
body: resBody,
|
|
||||||
data: resData,
|
|
||||||
res
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async get<Data = any>(
|
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
then<TResult1 = T, TResult2 = never>(
|
||||||
_query?: Record<string, any> | URLSearchParams,
|
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
|
||||||
_init?: RequestInit
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
|
||||||
) {
|
): Promise<TResult1 | TResult2> {
|
||||||
return this.request<Data>(_input, _query, {
|
return this.execute().then(onfulfilled as any, onrejected);
|
||||||
..._init,
|
|
||||||
method: "GET"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async post<Data = any>(
|
catch<TResult = never>(
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined
|
||||||
body?: any,
|
): Promise<T | TResult> {
|
||||||
_init?: RequestInit
|
return this.then(undefined, onrejected);
|
||||||
) {
|
|
||||||
return this.request<Data>(_input, undefined, {
|
|
||||||
..._init,
|
|
||||||
body,
|
|
||||||
method: "POST"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async patch<Data = any>(
|
finally(onfinally?: (() => void) | null | undefined): Promise<T> {
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
return this.then(
|
||||||
body?: any,
|
(value) => {
|
||||||
_init?: RequestInit
|
onfinally?.();
|
||||||
) {
|
return value;
|
||||||
return this.request<Data>(_input, undefined, {
|
},
|
||||||
..._init,
|
(reason) => {
|
||||||
body,
|
onfinally?.();
|
||||||
method: "PATCH"
|
throw reason;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async put<Data = any>(
|
path(): string {
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
const url = new URL(this.request.url);
|
||||||
body?: any,
|
return url.pathname;
|
||||||
_init?: RequestInit
|
|
||||||
) {
|
|
||||||
return this.request<Data>(_input, undefined, {
|
|
||||||
..._init,
|
|
||||||
body,
|
|
||||||
method: "PUT"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async delete<Data = any>(
|
key(options?: { search: boolean }): string {
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
const url = new URL(this.request.url);
|
||||||
_init?: RequestInit
|
return options?.search !== false ? this.path() + url.search : this.path();
|
||||||
) {
|
}
|
||||||
return this.request<Data>(_input, undefined, {
|
|
||||||
..._init,
|
keyArray(options?: { search: boolean }): string[] {
|
||||||
method: "DELETE"
|
const url = new URL(this.request.url);
|
||||||
});
|
const path = this.path().split("/");
|
||||||
|
return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter(
|
||||||
|
Boolean
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ConfigUpdateResponse } from "modules/server/SystemController";
|
||||||
import { ModuleApi } from "./ModuleApi";
|
import { ModuleApi } from "./ModuleApi";
|
||||||
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
||||||
|
|
||||||
@@ -15,37 +16,41 @@ export class SystemApi extends ModuleApi<any> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async readSchema(options?: { config?: boolean; secrets?: boolean }) {
|
readConfig() {
|
||||||
return await this.get<ApiSchemaResponse>("schema", {
|
return this.get<{ version: number } & ModuleConfigs>("config");
|
||||||
|
}
|
||||||
|
|
||||||
|
readSchema(options?: { config?: boolean; secrets?: boolean }) {
|
||||||
|
return this.get<ApiSchemaResponse>("schema", {
|
||||||
config: options?.config ? 1 : 0,
|
config: options?.config ? 1 : 0,
|
||||||
secrets: options?.secrets ? 1 : 0
|
secrets: options?.secrets ? 1 : 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setConfig<Module extends ModuleKey>(
|
setConfig<Module extends ModuleKey>(
|
||||||
module: Module,
|
module: Module,
|
||||||
value: ModuleConfigs[Module],
|
value: ModuleConfigs[Module],
|
||||||
force?: boolean
|
force?: boolean
|
||||||
) {
|
) {
|
||||||
return await this.post<any>(
|
return this.post<ConfigUpdateResponse>(
|
||||||
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
|
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
|
||||||
value
|
value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
return await this.post<any>(["config", "add", module, path], value);
|
return this.post<ConfigUpdateResponse>(["config", "add", module, path], value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
return await this.patch<any>(["config", "patch", module, path], value);
|
return this.patch<ConfigUpdateResponse>(["config", "patch", module, path], value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
return await this.put<any>(["config", "overwrite", module, path], value);
|
return this.put<ConfigUpdateResponse>(["config", "overwrite", module, path], value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
||||||
return await this.delete<any>(["config", "remove", module, path]);
|
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import type { App } from "App";
|
||||||
import type { ClassController } from "core";
|
import type { ClassController } from "core";
|
||||||
import { tbValidator as tb } from "core";
|
import { tbValidator as tb } from "core";
|
||||||
import { StringEnum, Type, TypeInvalidError } from "core/utils";
|
import { StringEnum, Type, TypeInvalidError } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
import { MODULE_NAMES, type ModuleKey, getDefaultConfig } from "modules/ModuleManager";
|
import {
|
||||||
|
MODULE_NAMES,
|
||||||
|
type ModuleConfigs,
|
||||||
|
type ModuleKey,
|
||||||
|
getDefaultConfig
|
||||||
|
} from "modules/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { generateOpenAPI } from "modules/server/openapi";
|
import { generateOpenAPI } from "modules/server/openapi";
|
||||||
import type { App } from "../../App";
|
|
||||||
|
|
||||||
const booleanLike = Type.Transform(Type.String())
|
const booleanLike = Type.Transform(Type.String())
|
||||||
.Decode((v) => v === "1")
|
.Decode((v) => v === "1")
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
.Encode((v) => (v ? "1" : "0"));
|
||||||
|
|
||||||
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
|
success: true;
|
||||||
|
module: Key;
|
||||||
|
config: ModuleConfigs[Key];
|
||||||
|
};
|
||||||
|
export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
||||||
|
| ConfigUpdate<Key>
|
||||||
|
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
|
||||||
|
|
||||||
export class SystemController implements ClassController {
|
export class SystemController implements ClassController {
|
||||||
constructor(private readonly app: App) {}
|
constructor(private readonly app: App) {}
|
||||||
|
|
||||||
@@ -60,7 +74,7 @@ export class SystemController implements ClassController {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<object>) {
|
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
|
||||||
try {
|
try {
|
||||||
return c.json(await cb(), { status: 202 });
|
return c.json(await cb(), { status: 202 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Notifications } from "@mantine/notifications";
|
|||||||
import type { ModuleConfigs } from "modules";
|
import type { ModuleConfigs } from "modules";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { BkndProvider, useBknd } from "ui/client/bknd";
|
import { BkndProvider, useBknd } from "ui/client/bknd";
|
||||||
|
import { Logo } from "ui/components/display/Logo";
|
||||||
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||||
import { ClientProvider, type ClientProviderProps } from "./client";
|
import { ClientProvider, type ClientProviderProps } from "./client";
|
||||||
import { createMantineTheme } from "./lib/mantine/theme";
|
import { createMantineTheme } from "./lib/mantine/theme";
|
||||||
@@ -21,7 +23,7 @@ export default function Admin({
|
|||||||
config
|
config
|
||||||
}: BkndAdminProps) {
|
}: BkndAdminProps) {
|
||||||
const Component = (
|
const Component = (
|
||||||
<BkndProvider adminOverride={config}>
|
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}>
|
||||||
<AdminInternal />
|
<AdminInternal />
|
||||||
</BkndProvider>
|
</BkndProvider>
|
||||||
);
|
);
|
||||||
@@ -51,3 +53,41 @@ function AdminInternal() {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Skeleton = ({ theme = "light" }: { theme?: string }) => {
|
||||||
|
return (
|
||||||
|
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
|
||||||
|
<AppShell.Root>
|
||||||
|
<header
|
||||||
|
data-shell="header"
|
||||||
|
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||||
|
>
|
||||||
|
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||||
|
{[...new Array(5)].map((item, key) => (
|
||||||
|
<AppShell.NavLink key={key} as="span" className="active h-full opacity-50">
|
||||||
|
<div className="w-10 h-3" />
|
||||||
|
</AppShell.NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<nav className="flex md:hidden flex-row items-center">
|
||||||
|
<AppShell.NavLink as="span" className="active h-full opacity-50">
|
||||||
|
<div className="w-10 h-3" />
|
||||||
|
</AppShell.NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="flex flex-grow" />
|
||||||
|
<div className="hidden lg:flex flex-row items-center px-4 gap-2 opacity-50">
|
||||||
|
<div className="size-11 rounded-full bg-primary/10" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<AppShell.Content>
|
||||||
|
<div className="flex flex-col w-full h-full justify-center items-center">
|
||||||
|
<span className="font-mono opacity-30">Loading</span>
|
||||||
|
</div>
|
||||||
|
</AppShell.Content>
|
||||||
|
</AppShell.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
import { useApi } from "ui/client";
|
||||||
import { useClient } from "./ClientProvider";
|
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
|
|
||||||
@@ -22,14 +22,18 @@ export type { TSchemaActions };
|
|||||||
export function BkndProvider({
|
export function BkndProvider({
|
||||||
includeSecrets = false,
|
includeSecrets = false,
|
||||||
adminOverride,
|
adminOverride,
|
||||||
children
|
children,
|
||||||
}: { includeSecrets?: boolean; children: any } & Pick<BkndContext, "adminOverride">) {
|
fallback = null
|
||||||
|
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick<
|
||||||
|
BkndContext,
|
||||||
|
"adminOverride"
|
||||||
|
>) {
|
||||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||||
const [schema, setSchema] =
|
const [schema, setSchema] =
|
||||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||||
const [fetched, setFetched] = useState(false);
|
const [fetched, setFetched] = useState(false);
|
||||||
const errorShown = useRef<boolean>();
|
const errorShown = useRef<boolean>();
|
||||||
const client = useClient();
|
const api = useApi();
|
||||||
|
|
||||||
async function reloadSchema() {
|
async function reloadSchema() {
|
||||||
await fetchSchema(includeSecrets, true);
|
await fetchSchema(includeSecrets, true);
|
||||||
@@ -37,7 +41,7 @@ export function BkndProvider({
|
|||||||
|
|
||||||
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
|
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
|
||||||
if (withSecrets && !force) return;
|
if (withSecrets && !force) return;
|
||||||
const { body, res } = await client.api.system.readSchema({
|
const res = await api.system.readSchema({
|
||||||
config: true,
|
config: true,
|
||||||
secrets: _includeSecrets
|
secrets: _includeSecrets
|
||||||
});
|
});
|
||||||
@@ -57,7 +61,7 @@ export function BkndProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schema = res.ok
|
const schema = res.ok
|
||||||
? body
|
? res.body
|
||||||
: ({
|
: ({
|
||||||
version: 0,
|
version: 0,
|
||||||
schema: getDefaultSchema(),
|
schema: getDefaultSchema(),
|
||||||
@@ -89,9 +93,9 @@ export function BkndProvider({
|
|||||||
fetchSchema(includeSecrets);
|
fetchSchema(includeSecrets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!fetched || !schema) return null;
|
if (!fetched || !schema) return fallback;
|
||||||
const app = new AppReduced(schema?.config as any);
|
const app = new AppReduced(schema?.config as any);
|
||||||
const actions = getSchemaActions({ client, setSchema, reloadSchema });
|
const actions = getSchemaActions({ api, setSchema, reloadSchema });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
|
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||||
import type { TApiUser } from "Api";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
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
|
baseUrl: undefined
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ClientProviderProps = {
|
export type ClientProviderProps = {
|
||||||
children?: any;
|
children?: any;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -24,74 +12,53 @@ export type ClientProviderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
|
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
|
||||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||||
const winCtx = useBkndWindowContext();
|
const winCtx = useBkndWindowContext();
|
||||||
|
const _ctx_baseUrl = useBaseUrl();
|
||||||
|
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _ctx_baseUrl = useBaseUrl();
|
if (!baseUrl) {
|
||||||
if (_ctx_baseUrl) {
|
if (_ctx_baseUrl) {
|
||||||
console.warn("wrapped many times");
|
actualBaseUrl = _ctx_baseUrl;
|
||||||
setActualBaseUrl(_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) {
|
} catch (e) {
|
||||||
console.error("error", e);
|
console.error("error .....", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||||
<ClientContext.Provider value={{ baseUrl: actualBaseUrl, client }}>
|
{children}
|
||||||
{children}
|
</ClientContext.Provider>
|
||||||
</ClientContext.Provider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createClient(baseUrl: string, user?: object) {
|
export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||||
return new AppQueryClient(baseUrl, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOrUseClient(baseUrl: string) {
|
|
||||||
const context = useContext(ClientContext);
|
const context = useContext(ClientContext);
|
||||||
if (!context) {
|
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
||||||
console.warn("createOrUseClient returned a new client");
|
return new Api({ host: host ?? "" });
|
||||||
return createClient(baseUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.client;
|
return context.api;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use useApi().baseUrl instead
|
||||||
|
*/
|
||||||
export const useBaseUrl = () => {
|
export const useBaseUrl = () => {
|
||||||
const context = useContext(ClientContext);
|
const context = useContext(ClientContext);
|
||||||
return context.baseUrl;
|
return context.baseUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BkndWindowContext = {
|
type BkndWindowContext = {
|
||||||
user?: object;
|
user?: TApiUser;
|
||||||
logout_route: string;
|
logout_route: string;
|
||||||
};
|
};
|
||||||
export function useBkndWindowContext(): BkndWindowContext {
|
export function useBkndWindowContext(): BkndWindowContext {
|
||||||
|
|||||||
38
app/src/ui/client/api/use-api.ts
Normal file
38
app/src/ui/client/api/use-api.ts
Normal file
@@ -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<Data>) => any = (data: ResponseObject<Data>) => Data
|
||||||
|
>(
|
||||||
|
fn: (api: Api) => FetchPromise<Data>,
|
||||||
|
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
|
||||||
|
) => {
|
||||||
|
const api = useApi();
|
||||||
|
const promise = fn(api);
|
||||||
|
const refine = options?.refine ?? ((data: ResponseObject<Data>) => data);
|
||||||
|
const fetcher = () => promise.execute().then(refine);
|
||||||
|
const key = promise.key();
|
||||||
|
|
||||||
|
type RefinedData = RefineFn extends (data: ResponseObject<Data>) => infer R ? R : Data;
|
||||||
|
|
||||||
|
const swr = useSWR<RefinedData>(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<any>)) => {
|
||||||
|
if (!arg) return async () => mutate("");
|
||||||
|
return mutate(typeof arg === "string" ? arg : arg(api).key());
|
||||||
|
};
|
||||||
|
};
|
||||||
37
app/src/ui/client/api/use-data.ts
Normal file
37
app/src/ui/client/api/use-data.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { DataApi } from "data/api/DataApi";
|
||||||
|
import { useApi } from "ui/client";
|
||||||
|
|
||||||
|
type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => any
|
||||||
|
? (...args: P) => ReturnType<F>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps all DataApi functions and omits
|
||||||
|
* the first argument "entity" for convenience
|
||||||
|
* @param entity
|
||||||
|
*/
|
||||||
|
export const useData = <T extends keyof DataApi>(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]>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
121
app/src/ui/client/api/use-entity.ts
Normal file
121
app/src/ui/client/api/use-entity.ts
Normal file
@@ -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<Payload = any> 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<RepoQuery> = {}) => {
|
||||||
|
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<EntityData>, _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<RepoQuery>,
|
||||||
|
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<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>;
|
||||||
|
const swr = useSWR<T>(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<ReturnType<typeof useEntity<Entity, Id>>, "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;
|
||||||
|
};
|
||||||
@@ -2,9 +2,12 @@ export {
|
|||||||
ClientProvider,
|
ClientProvider,
|
||||||
useBkndWindowContext,
|
useBkndWindowContext,
|
||||||
type ClientProviderProps,
|
type ClientProviderProps,
|
||||||
useClient,
|
useApi,
|
||||||
useBaseUrl
|
useBaseUrl
|
||||||
} from "./ClientProvider";
|
} 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 { useAuth } from "./schema/auth/use-auth";
|
||||||
export { Api } from "../../Api";
|
export { Api } from "../../Api";
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { type NotificationData, notifications } from "@mantine/notifications";
|
import { type NotificationData, notifications } from "@mantine/notifications";
|
||||||
|
import type { Api } from "Api";
|
||||||
import { ucFirst } from "core/utils";
|
import { ucFirst } from "core/utils";
|
||||||
import type { ApiResponse, ModuleConfigs } from "../../../modules";
|
import type { ModuleConfigs } from "modules";
|
||||||
import type { AppQueryClient } from "../utils/AppQueryClient";
|
import type { ResponseObject } from "modules/ModuleApi";
|
||||||
|
import type { ConfigUpdateResponse } from "modules/server/SystemController";
|
||||||
|
|
||||||
export type SchemaActionsProps = {
|
export type SchemaActionsProps = {
|
||||||
client: AppQueryClient;
|
api: Api;
|
||||||
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
||||||
reloadSchema: () => Promise<void>;
|
reloadSchema: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
||||||
|
|
||||||
export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) {
|
export function getSchemaActions({ api, setSchema, reloadSchema }: SchemaActionsProps) {
|
||||||
const api = client.api;
|
async function handleConfigUpdate<Module extends keyof ModuleConfigs>(
|
||||||
|
|
||||||
async function handleConfigUpdate(
|
|
||||||
action: string,
|
action: string,
|
||||||
module: string,
|
module: Module,
|
||||||
res: ApiResponse,
|
res: ResponseObject<ConfigUpdateResponse<Module>>,
|
||||||
path?: string
|
path?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const base: Partial<NotificationData> = {
|
const base: Partial<NotificationData> = {
|
||||||
@@ -26,7 +26,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
|
|||||||
autoClose: 3000
|
autoClose: 3000
|
||||||
};
|
};
|
||||||
|
|
||||||
if (res.res.ok && res.body.success) {
|
if (res.success === true) {
|
||||||
console.log("update config", action, module, path, res.body);
|
console.log("update config", action, module, path, res.body);
|
||||||
if (res.body.success) {
|
if (res.body.success) {
|
||||||
setSchema((prev) => {
|
setSchema((prev) => {
|
||||||
@@ -35,7 +35,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
|
|||||||
...prev,
|
...prev,
|
||||||
config: {
|
config: {
|
||||||
...prev.config,
|
...prev.config,
|
||||||
[module]: res.body.config
|
[module]: res.config
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -47,18 +47,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
|
|||||||
color: "blue",
|
color: "blue",
|
||||||
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
|
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
|
||||||
});
|
});
|
||||||
return true;
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
...base,
|
||||||
|
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
|
||||||
|
color: "red",
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
message: res.error ?? "Failed to complete config update"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.show({
|
return res.success;
|
||||||
...base,
|
|
||||||
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
|
|
||||||
color: "red",
|
|
||||||
withCloseButton: true,
|
|
||||||
autoClose: false,
|
|
||||||
message: res.body.error ?? "Failed to complete config update"
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -72,7 +72,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
|
|||||||
return await handleConfigUpdate("set", module, res);
|
return await handleConfigUpdate("set", module, res);
|
||||||
},
|
},
|
||||||
patch: async <Module extends keyof ModuleConfigs>(
|
patch: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: Module,
|
||||||
path: string,
|
path: string,
|
||||||
value: any
|
value: any
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -80,25 +80,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
|
|||||||
return await handleConfigUpdate("patch", module, res, path);
|
return await handleConfigUpdate("patch", module, res, path);
|
||||||
},
|
},
|
||||||
overwrite: async <Module extends keyof ModuleConfigs>(
|
overwrite: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: Module,
|
||||||
path: string,
|
path: string,
|
||||||
value: any
|
value: any
|
||||||
) => {
|
) => {
|
||||||
const res = await api.system.overwriteConfig(module, path, value);
|
const res = await api.system.overwriteConfig(module, path, value);
|
||||||
return await handleConfigUpdate("overwrite", module, res, path);
|
return await handleConfigUpdate("overwrite", module, res, path);
|
||||||
},
|
},
|
||||||
add: async <Module extends keyof ModuleConfigs>(
|
add: async <Module extends keyof ModuleConfigs>(module: Module, path: string, value: any) => {
|
||||||
module: keyof ModuleConfigs,
|
|
||||||
path: string,
|
|
||||||
value: any
|
|
||||||
) => {
|
|
||||||
const res = await api.system.addConfig(module, path, value);
|
const res = await api.system.addConfig(module, path, value);
|
||||||
return await handleConfigUpdate("add", module, res, path);
|
return await handleConfigUpdate("add", module, res, path);
|
||||||
},
|
},
|
||||||
remove: async <Module extends keyof ModuleConfigs>(
|
remove: async <Module extends keyof ModuleConfigs>(module: Module, path: string) => {
|
||||||
module: keyof ModuleConfigs,
|
|
||||||
path: string
|
|
||||||
) => {
|
|
||||||
const res = await api.system.removeConfig(module, path);
|
const res = await api.system.removeConfig(module, path);
|
||||||
return await handleConfigUpdate("remove", module, res, path);
|
return await handleConfigUpdate("remove", module, res, path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Api } from "Api";
|
import { Api, type AuthState } from "Api";
|
||||||
import type { AuthResponse } from "auth";
|
import type { AuthResponse } from "auth";
|
||||||
import type { AppAuthSchema } from "auth/auth-schema";
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
import type { ApiResponse } from "modules/ModuleApi";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { useApi, useInvalidate } from "ui/client";
|
||||||
createClient,
|
|
||||||
createOrUseClient,
|
|
||||||
queryClient,
|
|
||||||
useBaseUrl,
|
|
||||||
useClient
|
|
||||||
} from "../../ClientProvider";
|
|
||||||
|
|
||||||
type LoginData = {
|
type LoginData = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -18,55 +11,54 @@ type LoginData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UseAuth = {
|
type UseAuth = {
|
||||||
data: (AuthResponse & { verified: boolean }) | undefined;
|
data: AuthState | undefined;
|
||||||
user: AuthResponse["user"] | undefined;
|
user: AuthState["user"] | undefined;
|
||||||
token: AuthResponse["token"] | undefined;
|
token: AuthState["token"] | undefined;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
login: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
login: (data: LoginData) => Promise<AuthResponse>;
|
||||||
register: (data: LoginData) => Promise<ApiResponse<AuthResponse>>;
|
register: (data: LoginData) => Promise<AuthResponse>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
verify: () => void;
|
verify: () => void;
|
||||||
setToken: (token: string) => void;
|
setToken: (token: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @todo: needs to use a specific auth endpoint to get strategy information
|
|
||||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||||
const ctxBaseUrl = useBaseUrl();
|
const api = useApi(options?.baseUrl);
|
||||||
//const client = useClient();
|
const invalidate = useInvalidate();
|
||||||
const client = createOrUseClient(options?.baseUrl ? options?.baseUrl : ctxBaseUrl);
|
const authState = api.getAuthState();
|
||||||
const authState = client.auth().state();
|
|
||||||
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||||
const verified = authState?.verified ?? false;
|
const verified = authState?.verified ?? false;
|
||||||
|
|
||||||
|
function updateAuthState() {
|
||||||
|
setAuthData(api.getAuthState());
|
||||||
|
}
|
||||||
|
|
||||||
async function login(input: LoginData) {
|
async function login(input: LoginData) {
|
||||||
const res = await client.auth().login(input);
|
const res = await api.auth.loginWithPassword(input);
|
||||||
if (res.res.ok && res.data && "user" in res.data) {
|
updateAuthState();
|
||||||
setAuthData(res.data);
|
return res.data;
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(input: LoginData) {
|
async function register(input: LoginData) {
|
||||||
const res = await client.auth().register(input);
|
const res = await api.auth.registerWithPassword(input);
|
||||||
if (res.res.ok && res.data && "user" in res.data) {
|
updateAuthState();
|
||||||
setAuthData(res.data);
|
return res.data;
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token: string) {
|
function setToken(token: string) {
|
||||||
setAuthData(client.auth().setToken(token) as any);
|
api.updateToken(token);
|
||||||
|
updateAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await client.auth().logout();
|
await api.updateToken(undefined);
|
||||||
setAuthData(undefined);
|
setAuthData(undefined);
|
||||||
queryClient.clear();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verify() {
|
async function verify() {
|
||||||
await client.auth().verify();
|
await api.verifyAuth();
|
||||||
setAuthData(client.auth().state());
|
updateAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,10 +79,7 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthS
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
} => {
|
} => {
|
||||||
const [data, setData] = useState<AuthStrategyData>();
|
const [data, setData] = useState<AuthStrategyData>();
|
||||||
const ctxBaseUrl = useBaseUrl();
|
const api = useApi(options?.baseUrl);
|
||||||
const api = new Api({
|
|
||||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function useBkndAuth() {
|
export function useBkndAuth() {
|
||||||
//const client = useClient();
|
const { config, schema, actions: bkndActions } = useBknd();
|
||||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
roles: {
|
roles: {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { type Static, parse } from "core/utils";
|
import { type Static, parse } from "core/utils";
|
||||||
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
|
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
|
||||||
import { useBknd } from "../../BkndProvider";
|
import { useBknd } from "../../BkndProvider";
|
||||||
import { useClient } from "../../ClientProvider";
|
|
||||||
|
|
||||||
export function useFlows() {
|
export function useFlows() {
|
||||||
const client = useClient();
|
|
||||||
const { config, app, actions: bkndActions } = useBknd();
|
const { config, app, actions: bkndActions } = useBknd();
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
|||||||
@@ -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<QueryObserverOptions>): Partial<QueryObserverOptions> {
|
|
||||||
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<AuthResponse>
|
|
||||||
> => {
|
|
||||||
return await this.api.auth.loginWithPassword(data);
|
|
||||||
},
|
|
||||||
register: async (data: any): Promise<ApiResponse<AuthResponse>> => {
|
|
||||||
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<QueryObserverOptions>) => {
|
|
||||||
const queryOptions = this.queryOptions(options);
|
|
||||||
return {
|
|
||||||
api: () => {
|
|
||||||
return this.api.media;
|
|
||||||
},
|
|
||||||
list: (query: Partial<RepoQuery> = { limit: 10 }): UseQueryResult<ApiResponse> => {
|
|
||||||
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<QueryObserverOptions>) => {
|
|
||||||
const queryOptions = this.queryOptions(options);
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
entity: (name: string) => {
|
|
||||||
return {
|
|
||||||
readOne: (
|
|
||||||
id: number,
|
|
||||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
|
||||||
): any => {
|
|
||||||
return useQuery({
|
|
||||||
...queryOptions,
|
|
||||||
queryKey: ["data", "entity", name, id, { query }],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await this.api.data.readOne(name, id, query);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
readMany: (
|
|
||||||
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
|
|
||||||
): UseQueryResult<ApiResponse> => {
|
|
||||||
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<RepoQuery> = { limit: 10, offset: 0 }
|
|
||||||
): UseQueryResult<Pick<RepositoryResponse, "meta" | "data">> => {
|
|
||||||
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<ApiResponse<{ entity: string; count: number }>> => {
|
|
||||||
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] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useBknd } from "../../client/BkndProvider";
|
import { useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function Logo({ scale = 0.2, fill }: { scale?: number; fill?: string }) {
|
export function Logo({
|
||||||
const { app } = useBknd();
|
scale = 0.2,
|
||||||
const theme = app.getAdminConfig().color_scheme;
|
fill,
|
||||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
theme = "light"
|
||||||
|
}: { scale?: number; fill?: string; theme?: string }) {
|
||||||
|
const $bknd = useBknd();
|
||||||
|
const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light";
|
||||||
|
const svgFill = fill ? fill : _theme === "light" ? "black" : "white";
|
||||||
|
|
||||||
const dim = {
|
const dim = {
|
||||||
width: Math.round(578 * scale),
|
width: Math.round(578 * scale),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const Check = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DataTableProps<Data> = {
|
export type DataTableProps<Data> = {
|
||||||
data: Data[];
|
data: Data[] | null; // "null" implies loading
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
checkable?: boolean;
|
checkable?: boolean;
|
||||||
onClickRow?: (row: Data) => void;
|
onClickRow?: (row: Data) => void;
|
||||||
@@ -71,10 +71,10 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
renderValue,
|
renderValue,
|
||||||
onClickNew
|
onClickNew
|
||||||
}: DataTableProps<Data>) {
|
}: DataTableProps<Data>) {
|
||||||
total = total || data.length;
|
total = total || data?.length || 0;
|
||||||
page = page || 1;
|
page = page || 1;
|
||||||
|
|
||||||
const select = columns && columns.length > 0 ? columns : Object.keys(data[0] || {});
|
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
||||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||||
const CellRender = renderValue || CellValue;
|
const CellRender = renderValue || CellValue;
|
||||||
|
|
||||||
@@ -129,7 +129,9 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
||||||
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
||||||
<i className="opacity-50">No data to show</i>
|
<i className="opacity-50">
|
||||||
|
{Array.isArray(data) ? "No data to show" : "Loading..."}
|
||||||
|
</i>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -188,7 +190,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="hidden md:flex text-primary/40">
|
<div className="hidden md:flex text-primary/40">
|
||||||
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} />
|
<TableDisplay
|
||||||
|
perPage={perPage}
|
||||||
|
page={page}
|
||||||
|
items={data?.length || 0}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||||
{perPageOptions && (
|
{perPageOptions && (
|
||||||
|
|||||||
@@ -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 extends EntityData = EntityData> = {
|
|
||||||
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<RepoQuery>;
|
|
||||||
queryOptions?: Partial<UseQueryOptions>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useEntities(
|
|
||||||
entity: string,
|
|
||||||
query?: Partial<RepoQuery>,
|
|
||||||
queryOptions?: Partial<UseQueryOptions>
|
|
||||||
): 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: <explanation>
|
|
||||||
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: <explanation>
|
|
||||||
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<Data extends EntityData = EntityData>({
|
|
||||||
entity,
|
|
||||||
query,
|
|
||||||
queryOptions,
|
|
||||||
children
|
|
||||||
}: EntitiesContainerProps & {
|
|
||||||
children(params: RenderParams<Data>): any;
|
|
||||||
}) {
|
|
||||||
const params = useEntities(entity, query, queryOptions);
|
|
||||||
return children(params as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Entities = EntitiesContainer;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import type { UseQueryResult } from "@tanstack/react-query";
|
|
||||||
import type { RepoQuery } from "data";
|
|
||||||
import { useClient } from "../client";
|
|
||||||
|
|
||||||
export type EntityData = Record<string, any>;
|
|
||||||
|
|
||||||
export type EntityContainerRenderParams<Data extends EntityData = EntityData> = {
|
|
||||||
data: Data | null;
|
|
||||||
client: ReturnType<typeof useClient>;
|
|
||||||
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<Omit<RepoQuery, "where" | "limit" | "offset">>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @todo: add option to disable fetches (for form updates)
|
|
||||||
// @todo: must return a way to indicate error!
|
|
||||||
export function useEntity<Data extends EntityData = EntityData>(
|
|
||||||
entity: string,
|
|
||||||
id?: number,
|
|
||||||
options?: { fetch?: FetchOptions }
|
|
||||||
): EntityContainerRenderParams<Data> {
|
|
||||||
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: <explanation>
|
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
await createMutation?.mutate(obj, {
|
|
||||||
onSuccess: resolve,
|
|
||||||
onError: reject
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(obj: any) {
|
|
||||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
|
|
||||||
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: <explanation>
|
|
||||||
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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./EntitiesContainer";
|
|
||||||
export * from "./EntityContainer";
|
|
||||||
@@ -1,11 +1 @@
|
|||||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||||
export {
|
|
||||||
EntitiesContainer,
|
|
||||||
useEntities,
|
|
||||||
type EntitiesContainerProps
|
|
||||||
} from "./container/EntitiesContainer";
|
|
||||||
export {
|
|
||||||
EntityContainer,
|
|
||||||
useEntity,
|
|
||||||
type EntityContainerProps
|
|
||||||
} from "./container/EntityContainer";
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useClickOutside, useDisclosure, useHotkeys, useViewportSize } from "@mantine/hooks";
|
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
import { ucFirst } from "core/utils";
|
|
||||||
import { throttle } from "lodash-es";
|
import { throttle } from "lodash-es";
|
||||||
import { type ComponentProps, createContext, useContext, useEffect, useRef, useState } from "react";
|
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
||||||
import type { IconType } from "react-icons";
|
import type { IconType } from "react-icons";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||||
import { Link } from "wouter";
|
|
||||||
import { useEvent } from "../../hooks/use-event";
|
import { useEvent } from "../../hooks/use-event";
|
||||||
|
|
||||||
export function Root({ children }) {
|
export function Root({ children }) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useNavigate } from "ui/lib/routes";
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { NavLink } from "./AppShell";
|
import { NavLink } from "./AppShell";
|
||||||
|
|
||||||
function HeaderNavigation() {
|
export function HeaderNavigation() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
|
|
||||||
const items: {
|
const items: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { twMerge } from "tailwind-merge";
|
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"): {
|
export function createMantineTheme(scheme: "light" | "dark"): {
|
||||||
theme: ReturnType<typeof createTheme>;
|
theme: ReturnType<typeof createTheme>;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "data";
|
} from "data";
|
||||||
import { MediaField } from "media/MediaField";
|
import { MediaField } from "media/MediaField";
|
||||||
import { type ComponentProps, Suspense } from "react";
|
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 { JsonEditor } from "ui/components/code/JsonEditor";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { FieldLabel } from "ui/components/form/Formy";
|
import { FieldLabel } from "ui/components/form/Formy";
|
||||||
@@ -215,7 +215,9 @@ function EntityMediaFormField({
|
|||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
|
||||||
const client = useClient();
|
const api = useApi();
|
||||||
|
const baseUrl = useBaseUrl();
|
||||||
|
const invalidate = useInvalidate();
|
||||||
const value = formApi.useStore((state) => {
|
const value = formApi.useStore((state) => {
|
||||||
const val = state.values[field.name];
|
const val = state.values[field.name];
|
||||||
if (!val || typeof val === "undefined") return [];
|
if (!val || typeof val === "undefined") return [];
|
||||||
@@ -227,22 +229,21 @@ function EntityMediaFormField({
|
|||||||
value.length === 0
|
value.length === 0
|
||||||
? []
|
? []
|
||||||
: mediaItemsToFileStates(value, {
|
: mediaItemsToFileStates(value, {
|
||||||
baseUrl: client.baseUrl,
|
baseUrl: api.baseUrl,
|
||||||
overrides: { state: "uploaded" }
|
overrides: { state: "uploaded" }
|
||||||
});
|
});
|
||||||
|
|
||||||
const getUploadInfo = useEvent(() => {
|
const getUploadInfo = useEvent(() => {
|
||||||
const api = client.media().api();
|
|
||||||
return {
|
return {
|
||||||
url: api.getEntityUploadUrl(entity.name, entityId, field.name),
|
url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
|
||||||
headers: api.getUploadHeaders(),
|
headers: api.media.getUploadHeaders(),
|
||||||
method: "POST"
|
method: "POST"
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file) => {
|
const handleDelete = useEvent(async (file: FileState) => {
|
||||||
client.__invalidate(entity.name, entityId);
|
invalidate((api) => api.data.readOne(entity.name, entityId));
|
||||||
return await client.media().deleteFile(file);
|
return api.media.deleteFile(file.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import { ucFirst } from "core/utils";
|
|||||||
import type { EntityData, RelationField } from "data";
|
import type { EntityData, RelationField } from "data";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { TbEye } from "react-icons/tb";
|
import { TbEye } from "react-icons/tb";
|
||||||
import { useClient } from "ui/client";
|
import { useEntityQuery } from "ui/client";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { Popover } from "ui/components/overlay/Popover";
|
import { Popover } from "ui/components/overlay/Popover";
|
||||||
import { useEntities } from "ui/container";
|
|
||||||
import { routes } from "ui/lib/routes";
|
import { routes } from "ui/lib/routes";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { EntityTable } from "../EntityTable";
|
import { EntityTable } from "../EntityTable";
|
||||||
@@ -31,25 +30,20 @@ export function EntityRelationalFormField({
|
|||||||
const { app } = useBknd();
|
const { app } = useBknd();
|
||||||
const entity = app.entity(field.target())!;
|
const entity = app.entity(field.target())!;
|
||||||
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
|
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
|
||||||
const [location, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const ref = useRef<any>(null);
|
const ref = useRef<any>(null);
|
||||||
const client = useClient();
|
const $q = useEntityQuery(field.target(), undefined, {
|
||||||
const container = useEntities(
|
limit: query.limit,
|
||||||
field.target(),
|
offset: (query.page - 1) * query.limit
|
||||||
{
|
});
|
||||||
limit: query.limit,
|
|
||||||
offset: (query.page - 1) * query.limit
|
|
||||||
//select: entity.getSelect(undefined, "form")
|
|
||||||
},
|
|
||||||
{ enabled: true }
|
|
||||||
);
|
|
||||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||||
|
|
||||||
const referenceField = data?.[field.reference()];
|
const referenceField = data?.[field.reference()];
|
||||||
const relationalField = data?.[field.name];
|
const relationalField = data?.[field.name];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_setValue(data?.[field.reference()]);
|
const value = data?.[field.reference()];
|
||||||
|
_setValue(value);
|
||||||
}, [referenceField]);
|
}, [referenceField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,62 +51,40 @@ export function EntityRelationalFormField({
|
|||||||
const rel_value = field.target();
|
const rel_value = field.target();
|
||||||
if (!rel_value || !relationalField) return;
|
if (!rel_value || !relationalField) return;
|
||||||
|
|
||||||
console.log("-- need to fetch", field.target(), relationalField);
|
const fetched = await $q.api.readOne(field.target(), relationalField);
|
||||||
const fetched = await client.api.data.readOne(field.target(), relationalField);
|
if (fetched.ok && fetched.data) {
|
||||||
if (fetched.res.ok && fetched.data) {
|
|
||||||
_setValue(fetched.data as any);
|
_setValue(fetched.data as any);
|
||||||
}
|
}
|
||||||
console.log("-- fetched", fetched);
|
|
||||||
|
|
||||||
console.log("relation", {
|
|
||||||
referenceField,
|
|
||||||
relationalField,
|
|
||||||
data,
|
|
||||||
field,
|
|
||||||
entity
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
}, [relationalField]);
|
}, [relationalField]);
|
||||||
|
|
||||||
/*const initialValue: { id: number | undefined; [key: string]: any } = data?.[
|
|
||||||
field.reference()
|
|
||||||
] ?? {
|
|
||||||
id: data?.[field.name],
|
|
||||||
};*/
|
|
||||||
|
|
||||||
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
|
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
console.log("yo");
|
|
||||||
if (_value) {
|
if (_value) {
|
||||||
navigate(routes.data.entity.edit(entity.name, _value.id as any));
|
navigate(routes.data.entity.edit(entity.name, _value.id as any));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*console.log(
|
|
||||||
"relationfield:data",
|
|
||||||
{ _value, initialValue },
|
|
||||||
data,
|
|
||||||
field.reference(),
|
|
||||||
entity,
|
|
||||||
//container.entity,
|
|
||||||
//data[field.reference()],
|
|
||||||
data?.[field.name],
|
|
||||||
field,
|
|
||||||
);*/
|
|
||||||
|
|
||||||
// fix missing value on fields that are required
|
// fix missing value on fields that are required
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (field.isRequired() && !fieldApi.state.value) {
|
if (field.isRequired() && !fieldApi.state.value) {
|
||||||
fieldApi.setValue(container.data?.[0]?.id);
|
const firstValue = $q.data?.[0];
|
||||||
|
if (!firstValue) return;
|
||||||
|
|
||||||
|
console.warn("setting first value because field is required", field.name, firstValue.id);
|
||||||
|
fieldApi.setValue(firstValue.id);
|
||||||
|
_setValue(firstValue as any);
|
||||||
}
|
}
|
||||||
}, [container.data]);
|
}, [$q.data]);
|
||||||
|
|
||||||
|
const fetching = $q.isLoading || $q.isValidating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
||||||
<div
|
<div
|
||||||
data-disabled={!Array.isArray(container.data) || disabled ? 1 : undefined}
|
data-disabled={fetching || disabled ? 1 : undefined}
|
||||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||||
>
|
>
|
||||||
<Popover
|
<Popover
|
||||||
@@ -120,7 +92,7 @@ export function EntityRelationalFormField({
|
|||||||
className=""
|
className=""
|
||||||
target={({ toggle }) => (
|
target={({ toggle }) => (
|
||||||
<PopoverTable
|
<PopoverTable
|
||||||
container={container}
|
container={$q.data}
|
||||||
entity={entity}
|
entity={entity}
|
||||||
query={query}
|
query={query}
|
||||||
toggle={toggle}
|
toggle={toggle}
|
||||||
@@ -198,28 +170,6 @@ export function EntityRelationalFormField({
|
|||||||
onChange={console.log}
|
onChange={console.log}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
{/*<Formy.Select
|
|
||||||
ref={ref}
|
|
||||||
name={fieldApi.name}
|
|
||||||
id={fieldApi.name}
|
|
||||||
value={fieldApi.state.value}
|
|
||||||
data-value={fieldApi.state.value}
|
|
||||||
onBlur={fieldApi.handleBlur}
|
|
||||||
onChange={handleUpdate}
|
|
||||||
disabled={!Array.isArray(container.data)}
|
|
||||||
>
|
|
||||||
{container.data ? (
|
|
||||||
<>
|
|
||||||
{emptyOption}
|
|
||||||
{!field.isRequired() && emptyOption}
|
|
||||||
{container.data?.map(renderRow)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<option value={undefined} disabled>
|
|
||||||
Loading...
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</Formy.Select>*/}
|
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,16 @@ export function useEntityForm({
|
|||||||
// @todo: check if virtual must be filtered
|
// @todo: check if virtual must be filtered
|
||||||
const fields = entity.getFillableFields(action, true);
|
const fields = entity.getFillableFields(action, true);
|
||||||
|
|
||||||
console.log("useEntityForm:data", data);
|
|
||||||
|
|
||||||
// filter defaultValues to only contain fillable fields
|
// filter defaultValues to only contain fillable fields
|
||||||
const defaultValues = getDefaultValues(fields, data);
|
const defaultValues = getDefaultValues(fields, data);
|
||||||
console.log("useEntityForm:defaultValues", data);
|
//console.log("useEntityForm", { data, defaultValues });
|
||||||
|
|
||||||
const Form = useForm({
|
const Form = useForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
validators: {
|
validators: {
|
||||||
onSubmitAsync: async ({ value }): Promise<any> => {
|
onSubmitAsync: async ({ value }): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
console.log("validating", value, entity.isValidData(value, action));
|
//console.log("validating", value, entity.isValidData(value, action));
|
||||||
entity.isValidData(value, action, true);
|
entity.isValidData(value, action, true);
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -40,7 +38,7 @@ export function useEntityForm({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value, formApi }) => {
|
onSubmit: async ({ value, formApi }) => {
|
||||||
console.log("onSubmit", value);
|
//console.log("onSubmit", value);
|
||||||
if (!entity.isValidData(value, action)) {
|
if (!entity.isValidData(value, action)) {
|
||||||
console.error("invalid data", value);
|
console.error("invalid data", value);
|
||||||
return;
|
return;
|
||||||
@@ -49,7 +47,7 @@ export function useEntityForm({
|
|||||||
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const changeSet = getChangeSet(action, value, data, fields);
|
const changeSet = getChangeSet(action, value, data, fields);
|
||||||
console.log("changesSet", action, changeSet);
|
//console.log("changesSet", action, changeSet, { data });
|
||||||
|
|
||||||
// only submit change set if there were changes
|
// only submit change set if there were changes
|
||||||
await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet);
|
await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet);
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
import { useClient } from "ui/client";
|
import { useApiQuery } from "ui/client";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
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 { Alert } from "ui/components/display/Alert";
|
||||||
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { routes } from "ui/lib/routes";
|
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() {
|
export function AuthIndex() {
|
||||||
const client = useClient();
|
|
||||||
const { app } = useBknd();
|
const { app } = useBknd();
|
||||||
const {
|
const {
|
||||||
config: { roles, strategies, entity_name, enabled }
|
config: { roles, strategies, entity_name, enabled }
|
||||||
} = useBkndAuth();
|
} = useBkndAuth();
|
||||||
const users_entity = entity_name;
|
const users_entity = entity_name;
|
||||||
const query = client.query().data.entity("users").count();
|
const $q = useApiQuery((api) => api.data.count(users_entity));
|
||||||
const usersTotal = query.data?.body.count ?? 0;
|
const usersTotal = $q.data?.count ?? 0;
|
||||||
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
|
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
|
||||||
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
|
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { ucFirst } from "core/utils";
|
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 { Fragment, useState } from "react";
|
||||||
import { TbDots } from "react-icons/tb";
|
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 { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||||
import { useEntity } from "ui/container";
|
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||||
@@ -25,22 +24,23 @@ export function DataEntityUpdate({ params }) {
|
|||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
|
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
|
||||||
const targetRelations = relations.listableRelationsOf(entity);
|
const targetRelations = relations.listableRelationsOf(entity);
|
||||||
//console.log("targetRelations", targetRelations, relations.relationsOf(entity));
|
|
||||||
// filter out polymorphic for now
|
|
||||||
//.filter((r) => r.type() !== "poly");
|
|
||||||
const local_relation_refs = relations
|
const local_relation_refs = relations
|
||||||
.sourceRelationsOf(entity)
|
.sourceRelationsOf(entity)
|
||||||
?.map((r) => r.other(entity).reference);
|
?.map((r) => r.other(entity).reference);
|
||||||
|
|
||||||
const container = useEntity(entity.name, entityId, {
|
const $q = useEntityQuery(
|
||||||
fetch: {
|
entity.name,
|
||||||
query: {
|
entityId,
|
||||||
with: local_relation_refs
|
{
|
||||||
}
|
with: local_relation_refs
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
function goBack(state?: Record<string, any>) {
|
function goBack() {
|
||||||
window.history.go(-1);
|
window.history.go(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,43 +52,39 @@ export function DataEntityUpdate({ params }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await container.actions.update(changeSet);
|
try {
|
||||||
console.log("update:res", res);
|
await $q.update(changeSet);
|
||||||
if (res.data?.error) {
|
if (error) setError(null);
|
||||||
setError(res.data.error);
|
|
||||||
} else {
|
|
||||||
error && setError(null);
|
|
||||||
goBack();
|
goBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (confirm("Are you sure to delete?")) {
|
if (confirm("Are you sure to delete?")) {
|
||||||
const res = await container.actions.remove();
|
try {
|
||||||
if (res.error) {
|
await $q._delete();
|
||||||
setError(res.error);
|
if (error) setError(null);
|
||||||
} else {
|
|
||||||
error && setError(null);
|
|
||||||
goBack();
|
goBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to delete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = $q.data;
|
||||||
const { Form, handleSubmit } = useEntityForm({
|
const { Form, handleSubmit } = useEntityForm({
|
||||||
action: "update",
|
action: "update",
|
||||||
entity,
|
entity,
|
||||||
initialData: container.data,
|
initialData: $q.data?.toJSON(),
|
||||||
onSubmitted
|
onSubmitted
|
||||||
});
|
});
|
||||||
//console.log("form.data", Form.state.values, container.data);
|
|
||||||
|
|
||||||
const makeKey = (key: string | number = "") =>
|
const makeKey = (key: string | number = "") =>
|
||||||
`${params.entity.name}_${entityId}_${String(key)}`;
|
`${params.entity.name}_${entityId}_${String(key)}`;
|
||||||
|
|
||||||
const fieldsDisabled =
|
const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
|
||||||
container.raw.fetch?.isLoading ||
|
|
||||||
container.status.fetch.isUpdating ||
|
|
||||||
Form.state.isSubmitting;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={makeKey()}>
|
<Fragment key={makeKey()}>
|
||||||
@@ -103,7 +99,7 @@ export function DataEntityUpdate({ params }) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
bkndModals.open("debug", {
|
bkndModals.open("debug", {
|
||||||
data: {
|
data: {
|
||||||
data: container.data as any,
|
data: data as any,
|
||||||
entity: entity.toJSON(),
|
entity: entity.toJSON(),
|
||||||
schema: entity.toSchema(true),
|
schema: entity.toSchema(true),
|
||||||
form: Form.state.values,
|
form: Form.state.values,
|
||||||
@@ -165,7 +161,7 @@ export function DataEntityUpdate({ params }) {
|
|||||||
entityId={entityId}
|
entityId={entityId}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
fieldsDisabled={fieldsDisabled}
|
fieldsDisabled={fieldsDisabled}
|
||||||
data={container.data ?? undefined}
|
data={data ?? undefined}
|
||||||
Form={Form}
|
Form={Form}
|
||||||
action="update"
|
action="update"
|
||||||
className="flex flex-grow flex-col gap-3 p-3"
|
className="flex flex-grow flex-col gap-3 p-3"
|
||||||
@@ -236,18 +232,17 @@ function EntityDetailInner({
|
|||||||
relation: EntityRelation;
|
relation: EntityRelation;
|
||||||
}) {
|
}) {
|
||||||
const other = relation.other(entity);
|
const other = relation.other(entity);
|
||||||
const client = useClient();
|
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
|
|
||||||
const search = {
|
const search: Partial<RepoQuery> = {
|
||||||
select: other.entity.getSelect(undefined, "table"),
|
select: other.entity.getSelect(undefined, "table"),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0
|
offset: 0
|
||||||
};
|
};
|
||||||
const query = client
|
// @todo: add custom key for invalidation
|
||||||
.query()
|
const $q = useApiQuery((api) =>
|
||||||
.data.entity(entity.name)
|
api.data.readManyByReference(entity.name, id, other.reference, search)
|
||||||
.readManyByReference(id, other.reference, other.entity.name, search);
|
);
|
||||||
|
|
||||||
function handleClickRow(row: Record<string, any>) {
|
function handleClickRow(row: Record<string, any>) {
|
||||||
navigate(routes.data.entity.edit(other.entity.name, row.id));
|
navigate(routes.data.entity.edit(other.entity.name, row.id));
|
||||||
@@ -266,12 +261,11 @@ function EntityDetailInner({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.isPending) {
|
if (!$q.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUpdating = query.isInitialLoading || query.isFetching;
|
const isUpdating = $q.isValidating || $q.isLoading;
|
||||||
//console.log("query", query, search.select);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -280,13 +274,12 @@ function EntityDetailInner({
|
|||||||
>
|
>
|
||||||
<EntityTable2
|
<EntityTable2
|
||||||
select={search.select}
|
select={search.select}
|
||||||
data={query.data?.data ?? []}
|
data={$q.data ?? null}
|
||||||
entity={other.entity}
|
entity={other.entity}
|
||||||
onClickRow={handleClickRow}
|
onClickRow={handleClickRow}
|
||||||
onClickNew={handleClickNew}
|
onClickNew={handleClickNew}
|
||||||
page={1}
|
page={1}
|
||||||
/* @ts-ignore */
|
total={$q.data?.body?.meta?.count ?? 1}
|
||||||
total={query.data?.body?.meta?.count ?? 1}
|
|
||||||
/*onClickPage={handleClickPage}*/
|
/*onClickPage={handleClickPage}*/
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Type } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
|
import type { EntityData } from "data";
|
||||||
import { useState } from "react";
|
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 { EntityForm } from "ui/modules/data/components/EntityForm";
|
||||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||||
import { useBknd } from "../../client/BkndProvider";
|
|
||||||
import { Button } from "../../components/buttons/Button";
|
|
||||||
import { type EntityData, useEntity } from "../../container";
|
|
||||||
import { useBrowserTitle } from "../../hooks/use-browser-title";
|
|
||||||
import { useSearch } from "../../hooks/use-search";
|
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
|
||||||
import { Breadcrumbs2 } from "../../layouts/AppShell/Breadcrumbs2";
|
|
||||||
import { routes } from "../../lib/routes";
|
|
||||||
|
|
||||||
export function DataEntityCreate({ params }) {
|
export function DataEntityCreate({ params }) {
|
||||||
const { app } = useBknd();
|
const { app } = useBknd();
|
||||||
@@ -17,40 +18,37 @@ export function DataEntityCreate({ params }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useBrowserTitle(["Data", entity.label, "Create"]);
|
useBrowserTitle(["Data", entity.label, "Create"]);
|
||||||
|
|
||||||
const container = useEntity(entity.name);
|
const $q = useEntityMutate(entity.name);
|
||||||
|
|
||||||
// @todo: use entity schema for prefilling
|
// @todo: use entity schema for prefilling
|
||||||
const search = useSearch(Type.Object({}), {});
|
const search = useSearch(Type.Object({}), {});
|
||||||
console.log("search", search.value);
|
|
||||||
|
|
||||||
function goBack(state?: Record<string, any>) {
|
function goBack() {
|
||||||
window.history.go(-1);
|
window.history.go(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitted(changeSet?: EntityData) {
|
async function onSubmitted(changeSet?: EntityData) {
|
||||||
console.log("create:changeSet", changeSet);
|
console.log("create:changeSet", changeSet);
|
||||||
//return;
|
if (!changeSet) return;
|
||||||
const res = await container.actions.create(changeSet);
|
|
||||||
console.log("create:res", res);
|
try {
|
||||||
if (res.data?.error) {
|
await $q.create(changeSet);
|
||||||
setError(res.data.error);
|
if (error) setError(null);
|
||||||
} else {
|
|
||||||
error && setError(null);
|
|
||||||
// @todo: navigate to created?
|
// @todo: navigate to created?
|
||||||
goBack();
|
goBack();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to create");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Form, handleSubmit, values } = useEntityForm({
|
const { Form, handleSubmit } = useEntityForm({
|
||||||
action: "create",
|
action: "create",
|
||||||
entity,
|
entity,
|
||||||
initialData: search.value,
|
initialData: search.value,
|
||||||
onSubmitted
|
onSubmitted
|
||||||
});
|
});
|
||||||
|
|
||||||
const fieldsDisabled =
|
const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
|
||||||
container.raw.fetch?.isLoading ||
|
|
||||||
container.status.fetch.isUpdating ||
|
|
||||||
Form.state.isSubmitting;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Type } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
import { querySchema } from "data";
|
import { querySchema } from "data";
|
||||||
import { TbDots } from "react-icons/tb";
|
import { TbDots } from "react-icons/tb";
|
||||||
|
import { useApiQuery } from "ui/client";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Message } from "ui/components/display/Message";
|
import { Message } from "ui/components/display/Message";
|
||||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||||
import { EntitiesContainer } from "ui/container";
|
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { useSearch } from "ui/hooks/use-search";
|
import { useSearch } from "ui/hooks/use-search";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
@@ -25,19 +25,33 @@ const searchSchema = Type.Composite(
|
|||||||
{ additionalProperties: false }
|
{ additionalProperties: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PER_PAGE_OPTIONS = [5, 10, 25];
|
||||||
|
|
||||||
export function DataEntityList({ params }) {
|
export function DataEntityList({ params }) {
|
||||||
const { $data, relations } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
const entity = $data.entity(params.entity as string);
|
const entity = $data.entity(params.entity as string)!;
|
||||||
|
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const search = useSearch(searchSchema, {
|
const search = useSearch(searchSchema, {
|
||||||
select: entity?.getSelect(undefined, "table") ?? [],
|
select: entity?.getSelect(undefined, "table") ?? [],
|
||||||
sort: entity?.getDefaultSort()
|
sort: entity?.getDefaultSort()
|
||||||
});
|
});
|
||||||
console.log("search", search.value);
|
|
||||||
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
|
||||||
const PER_PAGE_OPTIONS = [5, 10, 25];
|
|
||||||
|
|
||||||
//console.log("search", search.value);
|
const $q = useApiQuery(
|
||||||
|
(api) =>
|
||||||
|
api.data.readMany(entity.name, {
|
||||||
|
select: search.value.select,
|
||||||
|
limit: search.value.perPage,
|
||||||
|
offset: (search.value.page - 1) * search.value.perPage,
|
||||||
|
sort: search.value.sort
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
keepPreviousData: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = $q.data?.data;
|
||||||
|
const meta = $q.data?.body.meta;
|
||||||
|
|
||||||
function handleClickRow(row: Record<string, any>) {
|
function handleClickRow(row: Record<string, any>) {
|
||||||
if (entity) navigate(routes.data.entity.edit(entity.name, row.id));
|
if (entity) navigate(routes.data.entity.edit(entity.name, row.id));
|
||||||
@@ -65,6 +79,8 @@ export function DataEntityList({ params }) {
|
|||||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUpdating = $q.isLoading && $q.isValidating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
@@ -103,45 +119,25 @@ export function DataEntityList({ params }) {
|
|||||||
<SearchInput placeholder={`Filter ${entity.label}`} />
|
<SearchInput placeholder={`Filter ${entity.label}`} />
|
||||||
</div>*/}
|
</div>*/}
|
||||||
|
|
||||||
<EntitiesContainer
|
<div
|
||||||
entity={entity.name}
|
data-updating={isUpdating ? 1 : undefined}
|
||||||
query={{
|
className="data-[updating]:opacity-50 transition-opacity pb-10"
|
||||||
select: search.value.select,
|
|
||||||
limit: search.value.perPage,
|
|
||||||
offset: (search.value.page - 1) * search.value.perPage,
|
|
||||||
sort: search.value.sort
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(params) => {
|
<EntityTable2
|
||||||
if (params.status.fetch.isLoading) {
|
data={data ?? null}
|
||||||
return null;
|
entity={entity}
|
||||||
}
|
/*select={search.value.select}*/
|
||||||
|
onClickRow={handleClickRow}
|
||||||
const isUpdating = params.status.fetch.isUpdating;
|
page={search.value.page}
|
||||||
|
sort={search.value.sort}
|
||||||
return (
|
onClickSort={handleSortClick}
|
||||||
<div
|
perPage={search.value.perPage}
|
||||||
data-updating={isUpdating ? 1 : undefined}
|
perPageOptions={PER_PAGE_OPTIONS}
|
||||||
className="data-[updating]:opacity-50 transition-opacity pb-10"
|
total={meta?.count}
|
||||||
>
|
onClickPage={handleClickPage}
|
||||||
<EntityTable2
|
onClickPerPage={handleClickPerPage}
|
||||||
data={params.data ?? []}
|
/>
|
||||||
entity={entity}
|
</div>
|
||||||
select={search.value.select}
|
|
||||||
onClickRow={handleClickRow}
|
|
||||||
page={search.value.page}
|
|
||||||
sort={search.value.sort}
|
|
||||||
onClickSort={handleSortClick}
|
|
||||||
perPage={search.value.perPage}
|
|
||||||
perPageOptions={PER_PAGE_OPTIONS}
|
|
||||||
total={params.meta?.count}
|
|
||||||
onClickPage={handleClickPage}
|
|
||||||
onClickPerPage={handleClickPerPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</EntitiesContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { IconPhoto } from "@tabler/icons-react";
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
|
import type { MediaFieldSchema } from "modules";
|
||||||
import { TbSettings } from "react-icons/tb";
|
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 { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||||
import { useLocation } from "wouter";
|
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 }) {
|
export function MediaRoot({ children }) {
|
||||||
const { app, config } = useBknd();
|
const { app, config } = useBknd();
|
||||||
@@ -62,32 +63,30 @@ export function MediaRoot({ children }) {
|
|||||||
// @todo: add infinite load
|
// @todo: add infinite load
|
||||||
export function MediaEmpty() {
|
export function MediaEmpty() {
|
||||||
useBrowserTitle(["Media"]);
|
useBrowserTitle(["Media"]);
|
||||||
const client = useClient();
|
const baseUrl = useBaseUrl();
|
||||||
const query = client.media().list({ limit: 50 });
|
const api = useApi();
|
||||||
|
const $q = useEntityQuery("media", undefined, { limit: 50 });
|
||||||
|
|
||||||
const getUploadInfo = useEvent((file) => {
|
const getUploadInfo = useEvent((file) => {
|
||||||
const api = client.media().api();
|
|
||||||
return {
|
return {
|
||||||
url: api.getFileUploadUrl(file),
|
url: api.media.getFileUploadUrl(file),
|
||||||
headers: api.getUploadHeaders(),
|
headers: api.media.getUploadHeaders(),
|
||||||
method: "POST"
|
method: "POST"
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file) => {
|
const handleDelete = useEvent(async (file: FileState) => {
|
||||||
return await client.media().deleteFile(file);
|
return api.media.deleteFile(file.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = query.data?.data || [];
|
const media = ($q.data || []) as MediaFieldSchema[];
|
||||||
const initialItems = mediaItemsToFileStates(media, { baseUrl: client.baseUrl });
|
const initialItems = mediaItemsToFileStates(media, { baseUrl });
|
||||||
|
|
||||||
console.log("initialItems", initialItems);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-1 p-3">
|
<div className="flex flex-1 p-3">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
key={query.isSuccess ? "loaded" : "initial"}
|
key={$q.isLoading ? "loaded" : "initial"}
|
||||||
getUploadInfo={getUploadInfo}
|
getUploadInfo={getUploadInfo}
|
||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
autoUpload
|
autoUpload
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { IconHome } from "@tabler/icons-react";
|
import { IconHome } from "@tabler/icons-react";
|
||||||
import { Suspense, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuth } from "ui/client";
|
import { useAuth } from "ui/client";
|
||||||
import { Empty } from "../components/display/Empty";
|
import { Empty } from "../components/display/Empty";
|
||||||
import { useBrowserTitle } from "../hooks/use-browser-title";
|
import { useBrowserTitle } from "../hooks/use-browser-title";
|
||||||
import * as AppShell from "../layouts/AppShell/AppShell";
|
import * as AppShell from "../layouts/AppShell/AppShell";
|
||||||
import { useNavigate } from "../lib/routes";
|
import { useNavigate } from "../lib/routes";
|
||||||
|
|
||||||
// @todo: package is still required somehow
|
|
||||||
const ReactQueryDevtools = (p: any) => null; /*!isDebug()
|
|
||||||
? () => null // Render nothing in production
|
|
||||||
: lazy(() =>
|
|
||||||
import("@tanstack/react-query-devtools").then((res) => ({
|
|
||||||
default: res.ReactQueryDevtools,
|
|
||||||
})),
|
|
||||||
);*/
|
|
||||||
|
|
||||||
export const Root = ({ children }) => {
|
export const Root = ({ children }) => {
|
||||||
const { verify } = useAuth();
|
const { verify } = useAuth();
|
||||||
|
|
||||||
@@ -26,10 +17,6 @@ export const Root = ({ children }) => {
|
|||||||
<AppShell.Root>
|
<AppShell.Root>
|
||||||
<AppShell.Header />
|
<AppShell.Header />
|
||||||
<AppShell.Content>{children}</AppShell.Content>
|
<AppShell.Content>{children}</AppShell.Content>
|
||||||
|
|
||||||
<Suspense>
|
|
||||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
|
||||||
</Suspense>
|
|
||||||
</AppShell.Root>
|
</AppShell.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
||||||
import SwaggerTest from "ui/routes/test/tests/swagger-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 { Route, useParams } from "wouter";
|
||||||
import { Empty } from "../../components/display/Empty";
|
import { Empty } from "../../components/display/Empty";
|
||||||
import { Link } from "../../components/wouter/Link";
|
import { Link } from "../../components/wouter/Link";
|
||||||
@@ -37,7 +39,9 @@ const tests = {
|
|||||||
EntityFieldsForm,
|
EntityFieldsForm,
|
||||||
FlowsTest,
|
FlowsTest,
|
||||||
AppShellAccordionsTest,
|
AppShellAccordionsTest,
|
||||||
SwaggerTest
|
SwaggerTest,
|
||||||
|
SWRAndAPI,
|
||||||
|
SwrAndDataApi
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function TestRoutes() {
|
export default function TestRoutes() {
|
||||||
|
|||||||
45
app/src/ui/routes/test/tests/swr-and-api.tsx
Normal file
45
app/src/ui/routes/test/tests/swr-and-api.tsx
Normal file
@@ -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 (
|
||||||
|
<Scrollable>
|
||||||
|
<pre>{JSON.stringify(r.promise.keyArray({ search: false }))}</pre>
|
||||||
|
{r.error && <div>failed to load</div>}
|
||||||
|
{r.isLoading && <div>loading...</div>}
|
||||||
|
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||||
|
{data && (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
<button type="submit">submit</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/src/ui/routes/test/tests/swr-and-data-api.tsx
Normal file
55
app/src/ui/routes/test/tests/swr-and-data-api.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<DirectDataApi />
|
||||||
|
<QueryDataApi />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueryDataApi() {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const { data, update, ...r } = useEntityQuery("comments", 1, {});
|
||||||
|
const comment = data ? data : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setText(comment?.content ?? "");
|
||||||
|
}, [comment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scrollable>
|
||||||
|
<pre>{JSON.stringify(r.key)}</pre>
|
||||||
|
{r.error && <div>failed to load</div>}
|
||||||
|
{r.isLoading && <div>loading...</div>}
|
||||||
|
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||||
|
{data && (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!comment) return;
|
||||||
|
await update({ content: text });
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
<button type="submit">submit</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectDataApi() {
|
||||||
|
const [data, setData] = useState<any>();
|
||||||
|
const { create, read, update, _delete } = useEntity("comments", 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
read().then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"navigation": [
|
"navigation": [
|
||||||
{
|
{
|
||||||
"group": "Getting Started",
|
"group": "Getting Started",
|
||||||
"pages": ["introduction", "setup", "sdk", "cli"]
|
"pages": ["introduction", "setup", "sdk", "react", "cli"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Modules",
|
"group": "Modules",
|
||||||
|
|||||||
194
docs/react.mdx
Normal file
194
docs/react.mdx
Normal file
@@ -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 <div>Error</div>
|
||||||
|
if (swr.isLoading) return <div>Loading...</div>
|
||||||
|
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<Data> = 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 <div>Error</div>
|
||||||
|
if (q.isLoading) return <div>Loading...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
<button type="submit">Update</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `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<any>();
|
||||||
|
const { create, read, update, _delete } = useEntity("comments", 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
read().then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
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<RepoQuery> = {})`: 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 <pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <div>Error</div>
|
||||||
|
if (q.isLoading) return <div>Loading...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!comment) return;
|
||||||
|
|
||||||
|
// this will automatically revalidate the query
|
||||||
|
await update({ content: text });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
<button type="submit">Update</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
1
examples/plasmic/.gitignore
vendored
Normal file
1
examples/plasmic/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.next
|
||||||
5
examples/plasmic/next-env.d.ts
vendored
Normal file
5
examples/plasmic/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||||
8
examples/plasmic/next.config.ts
Normal file
8
examples/plasmic/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
26
examples/plasmic/package.json
Normal file
26
examples/plasmic/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
examples/plasmic/postcss.config.mjs
Normal file
8
examples/plasmic/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
78
examples/plasmic/src/pages/[[...catchall]].tsx
Normal file
78
examples/plasmic/src/pages/[[...catchall]].tsx
Normal file
@@ -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: <explanation>
|
||||||
|
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(
|
||||||
|
<PlasmicRootProvider
|
||||||
|
loader={PLASMIC}
|
||||||
|
prefetchedData={plasmicData}
|
||||||
|
pageRoute={pageMeta.path}
|
||||||
|
pageParams={pageMeta.params}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<PlasmicComponent component={pageMeta.displayName} />
|
||||||
|
</PlasmicRootProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass the data in as props.
|
||||||
|
return {
|
||||||
|
props: { plasmicData, queryCache }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CatchallPage(props: {
|
||||||
|
plasmicData?: ComponentRenderData;
|
||||||
|
queryCache?: Record<string, any>;
|
||||||
|
}) {
|
||||||
|
const { plasmicData, queryCache } = props;
|
||||||
|
const router = useRouter();
|
||||||
|
if (!plasmicData || plasmicData.entryCompMetas.length === 0) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
const pageMeta = plasmicData.entryCompMetas[0];
|
||||||
|
return (
|
||||||
|
// Pass in the data fetched in getStaticProps as prefetchedData
|
||||||
|
<PlasmicRootProvider
|
||||||
|
loader={PLASMIC}
|
||||||
|
prefetchedData={plasmicData}
|
||||||
|
prefetchedQueryData={queryCache}
|
||||||
|
pageRoute={pageMeta.path}
|
||||||
|
pageParams={pageMeta.params}
|
||||||
|
pageQuery={router.query}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<PlasmicComponent component={pageMeta.displayName} />
|
||||||
|
</PlasmicRootProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
examples/plasmic/src/pages/_app.tsx
Normal file
11
examples/plasmic/src/pages/_app.tsx
Normal file
@@ -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 (
|
||||||
|
<ClientProvider baseUrl="http://localhost:3000">
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
examples/plasmic/src/pages/_document.tsx
Normal file
13
examples/plasmic/src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Html, Head, Main, NextScript } from "next/document";
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en">
|
||||||
|
<Head />
|
||||||
|
<body className="antialiased">
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
examples/plasmic/src/pages/admin/[[...admin]].tsx
Normal file
25
examples/plasmic/src/pages/admin/[[...admin]].tsx
Normal file
@@ -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 <Admin withProvider config={{ basepath: "/admin" }} />;
|
||||||
|
}
|
||||||
16
examples/plasmic/src/pages/api/[...route].ts
Normal file
16
examples/plasmic/src/pages/api/[...route].ts
Normal file
@@ -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!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
7
examples/plasmic/src/pages/plasmic-host.tsx
Normal file
7
examples/plasmic/src/pages/plasmic-host.tsx
Normal file
@@ -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 && <PlasmicCanvasHost />;
|
||||||
|
}
|
||||||
6
examples/plasmic/src/pages/test.tsx
Normal file
6
examples/plasmic/src/pages/test.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useApi } from "bknd/client";
|
||||||
|
|
||||||
|
export default function Test() {
|
||||||
|
const api = useApi(undefined);
|
||||||
|
return <div>{api.baseUrl}</div>;
|
||||||
|
}
|
||||||
17
examples/plasmic/src/plasmic-init.ts
Normal file
17
examples/plasmic/src/plasmic-init.ts
Normal file
@@ -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);*/
|
||||||
3
examples/plasmic/src/styles/globals.css
Normal file
3
examples/plasmic/src/styles/globals.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
18
examples/plasmic/tailwind.config.ts
Normal file
18
examples/plasmic/tailwind.config.ts
Normal file
@@ -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;
|
||||||
23
examples/plasmic/tsconfig.json
Normal file
23
examples/plasmic/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { CodeComponentMeta } from "@plasmicapp/host";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
|
|
||||||
export function WouterLink({ href, className, children, ...props }) {
|
|
||||||
return (
|
|
||||||
<Link href={href ?? "#"} className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WouterLinkMeta: CodeComponentMeta<any> = {
|
|
||||||
name: "WouterLink",
|
|
||||||
importPath: import.meta.dir,
|
|
||||||
props: {
|
|
||||||
href: {
|
|
||||||
type: "href",
|
|
||||||
},
|
|
||||||
children: {
|
|
||||||
type: "slot",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { loader as loadBkndComponents, CatchAllPage, createWouterPlasmicApp } from "./loader";
|
|
||||||
|
|
||||||
export * from "./components";
|
|
||||||
export * from "./contexts";
|
|
||||||
@@ -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<any, any>((props, ref) => {
|
|
||||||
//console.log("rendering custom link", props);
|
|
||||||
//return null;
|
|
||||||
if ("data-replace" in props) {
|
|
||||||
return <a ref={ref} {...props} />;
|
|
||||||
}
|
|
||||||
//return <a ref={ref} {...props} />;
|
|
||||||
// @ts-ignore it's because of the link
|
|
||||||
return <Link ref={ref} {...props} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
const Wrapper = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100wh",
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ opacity: 0.5, textTransform: "uppercase" }}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CatchAllPage({
|
|
||||||
PLASMIC,
|
|
||||||
prefix = ""
|
|
||||||
}: { PLASMIC: PlasmicComponentLoader; prefix?: string }) {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [pageData, setPageData] = useState<ComponentRenderData | null>(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 <Wrapper>Loading ...</Wrapper>;
|
|
||||||
}
|
|
||||||
if (!pageData) {
|
|
||||||
return <Wrapper>Not found</Wrapper>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageMeta = pageData.entryCompMetas[0];
|
|
||||||
|
|
||||||
// The page will already be cached from the `load` call above.
|
|
||||||
return (
|
|
||||||
<PlasmicRootProvider loader={PLASMIC} pageParams={pageMeta.params} Link={CustomLink}>
|
|
||||||
<PlasmicComponent component={path} />
|
|
||||||
</PlasmicRootProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWouterPlasmicApp(PLASMIC: PlasmicComponentLoader, prefix = "") {
|
|
||||||
return function App() {
|
|
||||||
return (
|
|
||||||
<Router base={prefix}>
|
|
||||||
<Switch>
|
|
||||||
<Route path="/host" component={PlasmicCanvasHost as any} />
|
|
||||||
<Route
|
|
||||||
path="/*"
|
|
||||||
component={() => <CatchAllPage PLASMIC={PLASMIC} prefix={prefix} />}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bknd/plasmic",
|
"name": "@bknd/plasmic",
|
||||||
|
"version": "0.3.4-alpha1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,41 +9,61 @@
|
|||||||
"build:only": "rm -rf dist && bun tsup",
|
"build:only": "rm -rf dist && bun tsup",
|
||||||
"types": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
"types": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
||||||
"build:types": "bun tsc --emitDeclarationOnly",
|
"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": {
|
"publishConfig": {
|
||||||
"wouter": "^3.3.5"
|
"access": "public"
|
||||||
},
|
},
|
||||||
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"bknd": "workspace:*",
|
||||||
|
"tsdx": "^0.14.1",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@plasmicapp/host": ">=1.0.0",
|
"bknd": "*",
|
||||||
"bknd": "workspace:*",
|
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
"react-dom": ">=18"
|
"react-dom": ">=18",
|
||||||
|
"@plasmicapp/host": ">=1.0.0",
|
||||||
|
"@plasmicapp/query": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"tsup": {
|
"tsup": {
|
||||||
"entry": ["index.ts"],
|
"entry": [
|
||||||
"minify": true,
|
"src/index.ts"
|
||||||
|
],
|
||||||
|
"minify": false,
|
||||||
"clean": true,
|
"clean": true,
|
||||||
"external": ["react", "react-dom", "@plasmicapp/host", "@plasmicapp/loader-react", "@plasmicapp/loader-core"],
|
"external": [
|
||||||
"format": ["esm"],
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"@plasmicapp/host",
|
||||||
|
"@plasmicapp/query",
|
||||||
|
"swr"
|
||||||
|
],
|
||||||
|
"format": [
|
||||||
|
"esm",
|
||||||
|
"cjs"
|
||||||
|
],
|
||||||
"platform": "browser",
|
"platform": "browser",
|
||||||
"shims": true,
|
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"metafile": true,
|
"metafile": true,
|
||||||
"splitting": false,
|
"splitting": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"exports": {
|
"types": "dist/index.d.ts",
|
||||||
".": {
|
"module": "dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"main": "dist/index.cjs",
|
||||||
"import": "./dist/index.js",
|
"files": [
|
||||||
"require": "./dist/index.js"
|
"dist",
|
||||||
}
|
"README.md",
|
||||||
},
|
"!dist/*.tsbuildinfo",
|
||||||
"files": ["dist"]
|
"!dist/*.map",
|
||||||
|
"!dist/**/*.map",
|
||||||
|
"!dist/metafile*",
|
||||||
|
"!dist/**/metafile*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { CodeComponentMeta } from "@plasmicapp/host";
|
import type { CodeComponentMeta } from "@plasmicapp/host";
|
||||||
|
import registerComponent, { type ComponentMeta } from "@plasmicapp/host/registerComponent";
|
||||||
|
// biome-ignore lint/style/useImportType: <explanation>
|
||||||
|
import React from "react";
|
||||||
//import { PlasmicCanvasContext } from "@plasmicapp/loader-react";
|
//import { PlasmicCanvasContext } from "@plasmicapp/loader-react";
|
||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ function numeric(value: number | string): number {
|
|||||||
function getDimensionDefaults(
|
function getDimensionDefaults(
|
||||||
width: number | string | undefined,
|
width: number | string | undefined,
|
||||||
height: number | string | undefined,
|
height: number | string | undefined,
|
||||||
ratio: number | undefined,
|
ratio: number | undefined
|
||||||
) {
|
) {
|
||||||
let _width = width;
|
let _width = width;
|
||||||
let _height = height;
|
let _height = height;
|
||||||
@@ -61,7 +62,7 @@ function getDimensionDefaults(
|
|||||||
function getPlaceholderStyle(
|
function getPlaceholderStyle(
|
||||||
width: number | string | undefined,
|
width: number | string | undefined,
|
||||||
height: number | string | undefined,
|
height: number | string | undefined,
|
||||||
ratio: number | undefined,
|
ratio: number | undefined
|
||||||
) {
|
) {
|
||||||
let paddingBottom = 0;
|
let paddingBottom = 0;
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
@@ -73,7 +74,7 @@ function getPlaceholderStyle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paddingBottom: paddingBottom + "%",
|
paddingBottom: paddingBottom + "%"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ threshold: loadTreshold },
|
{ threshold: loadTreshold }
|
||||||
);
|
);
|
||||||
if (imgRef.current) {
|
if (imgRef.current) {
|
||||||
observer.observe(imgRef.current);
|
observer.observe(imgRef.current);
|
||||||
@@ -150,7 +151,7 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
const {
|
const {
|
||||||
width: _width,
|
width: _width,
|
||||||
height: _height,
|
height: _height,
|
||||||
ratio: _ratio,
|
ratio: _ratio
|
||||||
} = getDimensionDefaults(width, height, ratio);
|
} = getDimensionDefaults(width, height, ratio);
|
||||||
|
|
||||||
const imgStyle: any = {
|
const imgStyle: any = {
|
||||||
@@ -163,7 +164,7 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
height: "auto",
|
height: "auto",
|
||||||
//height: _height || "auto",
|
//height: _height || "auto",
|
||||||
//height: !transitioned ? _height || "auto" : "auto",
|
//height: !transitioned ? _height || "auto" : "auto",
|
||||||
opacity: forceLoad || loaded ? 1 : 0,
|
opacity: forceLoad || loaded ? 1 : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const placeholderStyle: any = {
|
const placeholderStyle: any = {
|
||||||
@@ -174,7 +175,7 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
width: _width || "100%",
|
width: _width || "100%",
|
||||||
height: 0,
|
height: 0,
|
||||||
//height: transitioned ? "auto" : 0,
|
//height: transitioned ? "auto" : 0,
|
||||||
...getPlaceholderStyle(_width, _height, _ratio),
|
...getPlaceholderStyle(_width, _height, _ratio)
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapperStyle: any = {
|
const wrapperStyle: any = {
|
||||||
@@ -186,7 +187,7 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
//height: _height,
|
//height: _height,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%"
|
||||||
};
|
};
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
wrapperStyle.height = "auto";
|
wrapperStyle.height = "auto";
|
||||||
@@ -213,13 +214,24 @@ export const Image: React.FC<ImageProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageMeta: CodeComponentMeta<React.ComponentType<ImageProps>> = {
|
export function registerImage(
|
||||||
|
loader?: { registerComponent: typeof registerComponent },
|
||||||
|
customMeta?: ComponentMeta<ImageProps>
|
||||||
|
) {
|
||||||
|
if (loader) {
|
||||||
|
loader.registerComponent(Image, customMeta ?? ImageMeta);
|
||||||
|
} else {
|
||||||
|
registerComponent(Image, customMeta ?? ImageMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageMeta: CodeComponentMeta<ImageProps> = {
|
||||||
name: "ImageLazy",
|
name: "ImageLazy",
|
||||||
importPath: import.meta.dir,
|
importPath: "@bknd/plasmic",
|
||||||
props: {
|
props: {
|
||||||
src: {
|
src: {
|
||||||
type: "imageUrl",
|
type: "imageUrl",
|
||||||
displayName: "Image",
|
displayName: "Image"
|
||||||
},
|
},
|
||||||
alt: "string",
|
alt: "string",
|
||||||
width: "number",
|
width: "number",
|
||||||
@@ -230,14 +242,14 @@ export const ImageMeta: CodeComponentMeta<React.ComponentType<ImageProps>> = {
|
|||||||
//backgroundColor: "color",
|
//backgroundColor: "color",
|
||||||
transitionSpeed: {
|
transitionSpeed: {
|
||||||
type: "number",
|
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: {
|
loadTreshold: {
|
||||||
type: "number",
|
type: "number",
|
||||||
displayName: "Treshold",
|
displayName: "Treshold",
|
||||||
//defaultValue: 0.1,
|
//defaultValue: 0.1,
|
||||||
helpText:
|
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"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { CodeComponentMeta } from "@plasmicapp/host";
|
import type { CodeComponentMeta } from "@plasmicapp/host";
|
||||||
|
import registerComponent, { type ComponentMeta } from "@plasmicapp/host/registerComponent";
|
||||||
|
// biome-ignore lint/style/useImportType: <explanation>
|
||||||
|
import React from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface LazyRenderProps {
|
interface LazyRenderProps {
|
||||||
@@ -22,7 +25,7 @@ export const LazyRender: React.FC<LazyRenderProps> = ({
|
|||||||
threshold = 0.1,
|
threshold = 0.1,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
fallback = <DefaultFallback />,
|
fallback = <DefaultFallback />,
|
||||||
onBecomesVisible,
|
onBecomesVisible
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(forceLoad);
|
const [isVisible, setIsVisible] = useState(forceLoad);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -43,7 +46,7 @@ export const LazyRender: React.FC<LazyRenderProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const observerOptions: IntersectionObserverInit = {
|
const observerOptions: IntersectionObserverInit = {
|
||||||
threshold: threshold < 1 ? threshold : 0.1,
|
threshold: threshold < 1 ? threshold : 0.1
|
||||||
};
|
};
|
||||||
|
|
||||||
const observerCallback: IntersectionObserverCallback = (entries) => {
|
const observerCallback: IntersectionObserverCallback = (entries) => {
|
||||||
@@ -74,38 +77,49 @@ export const LazyRender: React.FC<LazyRenderProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LazyRenderMeta: CodeComponentMeta<React.ComponentType<LazyRenderProps>> = {
|
export function registerLazyRender(
|
||||||
|
loader?: { registerComponent: typeof registerComponent },
|
||||||
|
customMeta?: ComponentMeta<LazyRenderProps>
|
||||||
|
) {
|
||||||
|
if (loader) {
|
||||||
|
loader.registerComponent(LazyRender, customMeta ?? LazyRenderMeta);
|
||||||
|
} else {
|
||||||
|
registerComponent(LazyRender, customMeta ?? LazyRenderMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LazyRenderMeta: CodeComponentMeta<LazyRenderProps> = {
|
||||||
name: "LazyRender",
|
name: "LazyRender",
|
||||||
importPath: import.meta.dir,
|
importPath: "@bknd/plasmic",
|
||||||
props: {
|
props: {
|
||||||
forceLoad: {
|
forceLoad: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: false,
|
defaultValue: false
|
||||||
},
|
},
|
||||||
forceFallback: {
|
forceFallback: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: false,
|
defaultValue: false
|
||||||
},
|
},
|
||||||
threshold: {
|
threshold: {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0.1,
|
defaultValue: 0.1
|
||||||
},
|
},
|
||||||
fallback: {
|
fallback: {
|
||||||
type: "slot",
|
type: "slot"
|
||||||
//allowedComponents: ["*"],
|
//allowedComponents: ["*"],
|
||||||
},
|
},
|
||||||
delay: {
|
delay: {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 0,
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
onBecomesVisible: {
|
onBecomesVisible: {
|
||||||
type: "code",
|
type: "code",
|
||||||
lang: "javascript",
|
lang: "javascript"
|
||||||
},
|
},
|
||||||
|
|
||||||
children: {
|
children: {
|
||||||
type: "slot",
|
type: "slot"
|
||||||
//allowedComponents: ["*"],
|
//allowedComponents: ["*"],
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -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 type { RepoQuery } from "bknd/data";
|
||||||
import { useEntities, useEntity } from "bknd/ui";
|
// biome-ignore lint/style/useImportType: <explanation>
|
||||||
import { encodeSearch } from "bknd/utils";
|
import React from "react";
|
||||||
import { useContext, useEffect, useState } from "react";
|
|
||||||
import { usePlasmicBkndContext } from "../../contexts/BkndContext";
|
import { usePlasmicBkndContext } from "../../contexts/BkndContext";
|
||||||
|
|
||||||
type BkndEntitiesProps = {
|
type BkndDataProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
loading?: React.ReactNode;
|
loading?: React.ReactNode;
|
||||||
error?: React.ReactNode;
|
error?: React.ReactNode;
|
||||||
@@ -23,10 +25,11 @@ type BkndEntitiesProps = {
|
|||||||
dataName?: string;
|
dataName?: string;
|
||||||
entityId?: number;
|
entityId?: number;
|
||||||
entity?: string;
|
entity?: string;
|
||||||
|
select?: string[];
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
sortDir: "asc" | "desc";
|
sortDir: "asc" | "desc";
|
||||||
where?: string;
|
where?: string;
|
||||||
mode?: "fetch" | "react-query";
|
mode?: "fetch" | "swr";
|
||||||
noLayout?: boolean;
|
noLayout?: boolean;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
previewSlot?: "loading" | "error" | "empty";
|
previewSlot?: "loading" | "error" | "empty";
|
||||||
@@ -61,11 +64,13 @@ export function BkndData({
|
|||||||
sortBy = "id",
|
sortBy = "id",
|
||||||
sortDir = "asc",
|
sortDir = "asc",
|
||||||
mode = "fetch",
|
mode = "fetch",
|
||||||
|
select = [],
|
||||||
noLayout,
|
noLayout,
|
||||||
preview,
|
preview,
|
||||||
previewSlot,
|
previewSlot,
|
||||||
...props
|
...props
|
||||||
}: BkndEntitiesProps) {
|
}: BkndDataProps) {
|
||||||
|
//console.log("--bknd data");
|
||||||
const inEditor = !!usePlasmicCanvasContext();
|
const inEditor = !!usePlasmicCanvasContext();
|
||||||
const plasmicContext = usePlasmicBkndContext();
|
const plasmicContext = usePlasmicBkndContext();
|
||||||
|
|
||||||
@@ -100,6 +105,7 @@ export function BkndData({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = {
|
const query = {
|
||||||
|
select: select.length > 0 ? select : undefined,
|
||||||
limit: entityId ? undefined : limit,
|
limit: entityId ? undefined : limit,
|
||||||
offset: entityId ? undefined : offset,
|
offset: entityId ? undefined : offset,
|
||||||
where: _where,
|
where: _where,
|
||||||
@@ -108,7 +114,7 @@ export function BkndData({
|
|||||||
join: joinRefs
|
join: joinRefs
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("---context", plasmicContext);
|
//console.log("---context", plasmicContext);
|
||||||
if (plasmicContext.appConfig?.data?.entities) {
|
if (plasmicContext.appConfig?.data?.entities) {
|
||||||
const { entities, relations } = plasmicContext.appConfig.data;
|
const { entities, relations } = plasmicContext.appConfig.data;
|
||||||
console.log("entities", entities);
|
console.log("entities", entities);
|
||||||
@@ -149,8 +155,7 @@ export function BkndData({
|
|||||||
children
|
children
|
||||||
};
|
};
|
||||||
|
|
||||||
const Component =
|
const Component = mode === "swr" ? <ModeSWR {...modeProps} /> : <ModeFetch {...modeProps} />;
|
||||||
mode === "react-query" ? <ModeReactQuery {...modeProps} /> : <ModeFetch {...modeProps} />;
|
|
||||||
return noLayout ? Component : <div className={props.className}>{Component}</div>;
|
return noLayout ? Component : <div className={props.className}>{Component}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,32 +180,19 @@ const ModeFetch = ({
|
|||||||
entity,
|
entity,
|
||||||
query
|
query
|
||||||
}: ModeProps) => {
|
}: ModeProps) => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const api = useApi();
|
||||||
const [isLoading, setLoading] = useState(true);
|
const endpoint = entityId
|
||||||
const [hasError, setError] = useState<string>();
|
? api.data.readOne(entity, entityId, query)
|
||||||
const plasmicContext = usePlasmicBkndContext();
|
: api.data.readMany(entity, query);
|
||||||
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]);
|
|
||||||
|
|
||||||
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) {
|
if (isLoading) {
|
||||||
return <LoadingComponent loading={loading} />;
|
return <LoadingComponent loading={loading} />;
|
||||||
@@ -213,7 +205,6 @@ const ModeFetch = ({
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <EmptyComponent empty={empty} />;
|
return <EmptyComponent empty={empty} />;
|
||||||
}
|
}
|
||||||
console.log("--here1");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider name={dataName ?? entity ?? "data"} data={data}>
|
<DataProvider name={dataName ?? entity ?? "data"} data={data}>
|
||||||
@@ -222,85 +213,48 @@ const ModeFetch = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModeReactQuery = (props: ModeProps) => {
|
const ModeSWR = ({ children, loading, error, dataName, entityId, empty, entity }: ModeProps) => {
|
||||||
return props.entityId ? (
|
const $q = useEntityQuery(entity, entityId);
|
||||||
<ModeReactQuerySingle {...props} />
|
|
||||||
) : (
|
|
||||||
<ModeReactQueryMultiple {...props} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ModeReactQuerySingle = ({
|
if ($q.isLoading) {
|
||||||
children,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
dataName,
|
|
||||||
entityId,
|
|
||||||
empty,
|
|
||||||
entity
|
|
||||||
}: ModeProps) => {
|
|
||||||
const container = useEntity(entity, entityId);
|
|
||||||
const { isLoading, isError } = container.status.fetch;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingComponent loading={loading} />;
|
return <LoadingComponent loading={loading} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if ($q.error) {
|
||||||
return <ErrorComponent error={error} />;
|
return <ErrorComponent error={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container.data) {
|
if (!$q.data) {
|
||||||
return <EmptyComponent empty={empty} />;
|
return <EmptyComponent empty={empty} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider name={dataName ?? entity ?? "data"} data={container.data}>
|
<DataProvider name={dataName ?? entity ?? "data"} data={$q.data}>
|
||||||
{children}
|
{children}
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModeReactQueryMultiple = ({
|
export function registerBkndData(
|
||||||
children,
|
loader?: { registerComponent: typeof registerComponent },
|
||||||
loading,
|
customMeta?: ComponentMeta<BkndDataProps>
|
||||||
error,
|
) {
|
||||||
empty,
|
if (loader) {
|
||||||
dataName,
|
loader.registerComponent(BkndData, customMeta ?? BkndDataMeta);
|
||||||
entity,
|
} else {
|
||||||
query
|
registerComponent(BkndData, customMeta ?? BkndDataMeta);
|
||||||
}: ModeProps) => {
|
|
||||||
const container = useEntities(entity, query);
|
|
||||||
const { isLoading, isError } = container.status.fetch;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingComponent loading={loading} />;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isError) {
|
export const BkndDataMeta: ComponentMeta<BkndDataProps> = {
|
||||||
return <ErrorComponent error={error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!container.data || container.data.length === 0) {
|
|
||||||
return <EmptyComponent empty={empty} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataProvider name={dataName ?? entity ?? "data"} data={container.data}>
|
|
||||||
{children}
|
|
||||||
</DataProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BkndDataMeta: CodeComponentMeta<React.ComponentType<BkndEntitiesProps>> = {
|
|
||||||
name: "BKND Data",
|
name: "BKND Data",
|
||||||
section: "BKND",
|
section: "BKND",
|
||||||
importPath: import.meta.dir,
|
importPath: "@bknd/plasmic",
|
||||||
providesData: true,
|
providesData: true,
|
||||||
props: {
|
props: {
|
||||||
entity: {
|
entity: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
options: (props, ctx) => ctx.entities
|
options: (props, ctx) => ctx?.entities ?? []
|
||||||
},
|
},
|
||||||
dataName: {
|
dataName: {
|
||||||
type: "string"
|
type: "string"
|
||||||
@@ -308,6 +262,10 @@ export const BkndDataMeta: CodeComponentMeta<React.ComponentType<BkndEntitiesPro
|
|||||||
entityId: {
|
entityId: {
|
||||||
type: "number"
|
type: "number"
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
type: "choice",
|
||||||
|
options: (props, ctx) => ctx?.fields ?? []
|
||||||
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: 10,
|
defaultValue: 10,
|
||||||
@@ -326,13 +284,13 @@ export const BkndDataMeta: CodeComponentMeta<React.ComponentType<BkndEntitiesPro
|
|||||||
displayName: "With",
|
displayName: "With",
|
||||||
type: "choice",
|
type: "choice",
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
options: (props, ctx) => ctx.references
|
options: (props, ctx) => ctx?.references ?? []
|
||||||
},
|
},
|
||||||
joinRefs: {
|
joinRefs: {
|
||||||
displayName: "Join",
|
displayName: "Join",
|
||||||
type: "choice",
|
type: "choice",
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
options: (props, ctx) => ctx.references
|
options: (props, ctx) => ctx?.references ?? []
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
type: "code",
|
type: "code",
|
||||||
@@ -340,7 +298,7 @@ export const BkndDataMeta: CodeComponentMeta<React.ComponentType<BkndEntitiesPro
|
|||||||
},
|
},
|
||||||
sortBy: {
|
sortBy: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
options: (props, ctx) => ctx.fields
|
options: (props, ctx) => ctx?.fields ?? []
|
||||||
},
|
},
|
||||||
sortDir: {
|
sortDir: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
@@ -361,7 +319,7 @@ export const BkndDataMeta: CodeComponentMeta<React.ComponentType<BkndEntitiesPro
|
|||||||
},
|
},
|
||||||
mode: {
|
mode: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
options: ["fetch", "react-query"],
|
options: ["fetch", "swr"],
|
||||||
defaultValue: "fetch",
|
defaultValue: "fetch",
|
||||||
advanced: true
|
advanced: true
|
||||||
},
|
},
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export { BkndData, BkndDataMeta } from "./data/BkndData";
|
export { BkndData, BkndDataMeta } from "./data/BkndData";
|
||||||
export { WouterLink, WouterLinkMeta } from "./WouterLink";
|
|
||||||
export { Image, ImageMeta } from "./Image";
|
export { Image, ImageMeta } from "./Image";
|
||||||
export { LazyRender, LazyRenderMeta } from "./LazyRender";
|
export { LazyRender, LazyRenderMeta } from "./LazyRender";
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { DataProvider, GlobalActionsProvider, usePlasmicCanvasContext } from "@plasmicapp/host";
|
import { DataProvider, GlobalActionsProvider, usePlasmicCanvasContext } from "@plasmicapp/host";
|
||||||
|
import registerGlobalContext, {
|
||||||
|
type GlobalContextMeta
|
||||||
|
} from "@plasmicapp/host/registerGlobalContext";
|
||||||
import type { AppConfig } from "bknd";
|
import type { AppConfig } from "bknd";
|
||||||
import { ClientProvider, useAuth, useBaseUrl } from "bknd/ui";
|
// @ts-ignore
|
||||||
|
import { ClientProvider, useApi, useAuth, useBaseUrl } from "bknd/client";
|
||||||
|
// biome-ignore lint/style/useImportType: <explanation>
|
||||||
|
import React from "react";
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
// Users will be able to set these props in Studio.
|
// Users will be able to set these props in Studio.
|
||||||
@@ -18,17 +24,6 @@ type BkndContextProps = {
|
|||||||
|
|
||||||
const BkndContextContext = createContext<BkndGlobalContextProps>({} as any);
|
const BkndContextContext = createContext<BkndGlobalContextProps>({} 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?)
|
// @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 = ({
|
export const BkndContext = ({
|
||||||
children,
|
children,
|
||||||
@@ -36,19 +31,15 @@ export const BkndContext = ({
|
|||||||
initialAuth
|
initialAuth
|
||||||
}: React.PropsWithChildren<BkndContextProps>) => {
|
}: React.PropsWithChildren<BkndContextProps>) => {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const baseurl = useBaseUrl();
|
const baseurl = baseUrl ?? useBaseUrl();
|
||||||
|
const api = useApi({ host: baseurl });
|
||||||
|
|
||||||
const [data, setData] = useState<BkndGlobalContextProps>({
|
const [data, setData] = useState<BkndGlobalContextProps>({
|
||||||
baseUrl: baseurl,
|
baseUrl: baseurl,
|
||||||
/*baseUrl: (baseUrl && baseUrl.length > 0 ? baseUrl : getBaseUrlFromWindow()).replace(
|
|
||||||
/\/+$/,
|
|
||||||
""
|
|
||||||
),*/
|
|
||||||
auth: auth ?? initialAuth,
|
auth: auth ?? initialAuth,
|
||||||
appConfig: undefined
|
appConfig: undefined
|
||||||
});
|
});
|
||||||
const inEditor = !!usePlasmicCanvasContext();
|
const inEditor = !!usePlasmicCanvasContext();
|
||||||
console.log("context:user", data);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData((prev) => ({ ...prev, auth: auth }));
|
setData((prev) => ({ ...prev, auth: auth }));
|
||||||
@@ -57,8 +48,10 @@ export const BkndContext = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (inEditor) {
|
if (inEditor) {
|
||||||
const res = await fetch(`${baseurl}/api/system/config`);
|
const result = await api.system.readConfig();
|
||||||
const result = (await res.json()) as BkndGlobalContextProps["appConfig"];
|
|
||||||
|
/*const res = await fetch(`${baseurl}/api/system/config`);
|
||||||
|
const result = (await res.json()) as BkndGlobalContextProps["appConfig"];*/
|
||||||
console.log("appconfig", result);
|
console.log("appconfig", result);
|
||||||
setData((prev) => ({ ...prev, appConfig: result }));
|
setData((prev) => ({ ...prev, appConfig: result }));
|
||||||
}
|
}
|
||||||
@@ -101,13 +94,12 @@ export const BkndContext = ({
|
|||||||
[baseUrl]
|
[baseUrl]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("plasmic.bknd.context", data);
|
console.log("plasmic.bknd.context", { baseUrl });
|
||||||
return (
|
return (
|
||||||
<GlobalActionsProvider contextName="BkndContext" actions={actions}>
|
<GlobalActionsProvider contextName="BkndContext" actions={actions}>
|
||||||
<BkndContextContext.Provider value={data}>
|
<BkndContextContext.Provider value={data}>
|
||||||
<DataProvider name="bknd" data={data}>
|
<DataProvider name="bknd" data={data}>
|
||||||
{/*<ClientProvider baseUrl={data.baseUrl}>{children}</ClientProvider>*/}
|
<ClientProvider baseUrl={data.baseUrl}>{children}</ClientProvider>
|
||||||
{children}
|
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
</BkndContextContext.Provider>
|
</BkndContextContext.Provider>
|
||||||
</GlobalActionsProvider>
|
</GlobalActionsProvider>
|
||||||
@@ -119,8 +111,20 @@ export function usePlasmicBkndContext() {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BkndContextMeta = {
|
export function registerBkndContext(
|
||||||
|
loader?: { registerGlobalContext: typeof registerGlobalContext },
|
||||||
|
customMeta?: GlobalContextMeta<BkndContextProps>
|
||||||
|
) {
|
||||||
|
if (loader) {
|
||||||
|
loader.registerGlobalContext(BkndContext, customMeta ?? BkndContextMeta);
|
||||||
|
} else {
|
||||||
|
registerGlobalContext(BkndContext, customMeta ?? BkndContextMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BkndContextMeta: GlobalContextMeta<BkndContextProps> = {
|
||||||
name: "BkndContext",
|
name: "BkndContext",
|
||||||
|
importPath: "@bknd/plasmic",
|
||||||
props: { baseUrl: { type: "string" }, initialAuth: { type: "object" } },
|
props: { baseUrl: { type: "string" }, initialAuth: { type: "object" } },
|
||||||
providesData: true,
|
providesData: true,
|
||||||
globalActions: {
|
globalActions: {
|
||||||
17
packages/plasmic/src/index.ts
Normal file
17
packages/plasmic/src/index.ts
Normal file
@@ -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 };
|
||||||
@@ -4,19 +4,23 @@
|
|||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"declarationDir": "dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"rootDir": "src",
|
||||||
|
"baseUrl": "src",
|
||||||
|
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "loader.tsx", "components", "contexts"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["@bknd/app", "@bknd/core", "dist", "node_modules", "build.ts"]
|
"exclude": ["bknd", "dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user