mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
71
app/__test__/api/Api.spec.ts
Normal file
71
app/__test__/api/Api.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { sign } from "hono/jwt";
|
||||
import { Api } from "../../src/Api";
|
||||
|
||||
describe("Api", async () => {
|
||||
it("should construct without options", () => {
|
||||
const api = new Api();
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
});
|
||||
|
||||
it("should ignore force verify if no claims given", () => {
|
||||
const api = new Api({ verified: true });
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
});
|
||||
|
||||
it("should construct from request (token)", async () => {
|
||||
const token = await sign({ foo: "bar" }, "1234");
|
||||
const request = new Request("http://example.com/test", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const api = new Api({ request });
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
const params = api.getParams();
|
||||
expect(params.token).toBe(token);
|
||||
expect(params.token_transport).toBe("header");
|
||||
expect(params.host).toBe("http://example.com");
|
||||
});
|
||||
|
||||
it("should construct from request (cookie)", async () => {
|
||||
const token = await sign({ foo: "bar" }, "1234");
|
||||
const request = new Request("http://example.com/test", {
|
||||
headers: {
|
||||
Cookie: `auth=${token}`
|
||||
}
|
||||
});
|
||||
const api = new Api({ request });
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
const params = api.getParams();
|
||||
console.log(params);
|
||||
expect(params.token).toBe(token);
|
||||
expect(params.token_transport).toBe("cookie");
|
||||
expect(params.host).toBe("http://example.com");
|
||||
});
|
||||
|
||||
it("should construct from token", async () => {
|
||||
const token = await sign({ foo: "bar" }, "1234");
|
||||
const api = new Api({ token });
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
const params = api.getParams();
|
||||
expect(params.token).toBe(token);
|
||||
expect(params.token_transport).toBe("header");
|
||||
expect(params.host).toBe("http://localhost");
|
||||
});
|
||||
|
||||
it("should prefer host when request is given", async () => {
|
||||
const request = new Request("http://example.com/test");
|
||||
const api = new Api({ request, host: "http://another.com" });
|
||||
|
||||
const params = api.getParams();
|
||||
expect(params.token).toBeUndefined();
|
||||
expect(params.token_transport).toBe("header");
|
||||
expect(params.host).toBe("http://another.com");
|
||||
});
|
||||
});
|
||||
@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
|
||||
|
||||
it("fetches endpoint", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
const api = new Api({ host }, app.request as typeof fetch);
|
||||
|
||||
const res = await api.get("/endpoint");
|
||||
expect(res.res.ok).toEqual(true);
|
||||
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
|
||||
|
||||
it("has accessible request", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
const api = new Api({ host }, app.request as typeof fetch);
|
||||
|
||||
const promise = api.get("/endpoint");
|
||||
expect(promise.request).toBeDefined();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Perf } from "../../src/core/utils";
|
||||
import * as reqres from "../../src/core/utils/reqres";
|
||||
import * as strings from "../../src/core/utils/strings";
|
||||
import * as utils from "../../src/core/utils";
|
||||
|
||||
async function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
|
||||
describe("[core] strings", async () => {
|
||||
test("objectToKeyValueArray", async () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
const result = strings.objectToKeyValueArray(obj);
|
||||
const result = utils.objectToKeyValueArray(obj);
|
||||
expect(result).toEqual([
|
||||
{ key: "a", value: 1 },
|
||||
{ key: "b", value: 2 },
|
||||
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
|
||||
});
|
||||
|
||||
test("snakeToPascalWithSpaces", async () => {
|
||||
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
expect(result).toBe("Snake To Pascal");
|
||||
});
|
||||
|
||||
test("randomString", async () => {
|
||||
const result = strings.randomString(10);
|
||||
const result = utils.randomString(10);
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
test("pascalToKebab", async () => {
|
||||
const result = strings.pascalToKebab("PascalCase");
|
||||
const result = utils.pascalToKebab("PascalCase");
|
||||
expect(result).toBe("pascal-case");
|
||||
});
|
||||
|
||||
test("replaceSimplePlaceholders", async () => {
|
||||
const str = "Hello, {$name}!";
|
||||
const vars = { name: "John" };
|
||||
const result = strings.replaceSimplePlaceholders(str, vars);
|
||||
const result = utils.replaceSimplePlaceholders(str, vars);
|
||||
expect(result).toBe("Hello, John!");
|
||||
});
|
||||
});
|
||||
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Authorization", "Bearer 123");
|
||||
const obj = reqres.headersToObject(headers);
|
||||
const obj = utils.headersToObject(headers);
|
||||
expect(obj).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer 123"
|
||||
@@ -59,21 +58,21 @@ describe("Core Utils", async () => {
|
||||
test("replaceUrlParam", () => {
|
||||
const url = "/api/:id/:name";
|
||||
const params = { id: "123", name: "test" };
|
||||
const result = reqres.replaceUrlParam(url, params);
|
||||
const result = utils.replaceUrlParam(url, params);
|
||||
expect(result).toBe("/api/123/test");
|
||||
});
|
||||
|
||||
test("encode", () => {
|
||||
const obj = { id: "123", name: "test" };
|
||||
const result = reqres.encodeSearch(obj);
|
||||
const result = utils.encodeSearch(obj);
|
||||
expect(result).toBe("id=123&name=test");
|
||||
|
||||
const obj2 = { id: "123", name: ["test1", "test2"] };
|
||||
const result2 = reqres.encodeSearch(obj2);
|
||||
const result2 = utils.encodeSearch(obj2);
|
||||
expect(result2).toBe("id=123&name=test1&name=test2");
|
||||
|
||||
const obj3 = { id: "123", name: { test: "test" } };
|
||||
const result3 = reqres.encodeSearch(obj3, { encode: true });
|
||||
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
||||
});
|
||||
});
|
||||
@@ -108,4 +107,91 @@ describe("Core Utils", async () => {
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects", () => {
|
||||
test("omitKeys", () => {
|
||||
const objects = [
|
||||
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
|
||||
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
|
||||
] as [object, string[], object][];
|
||||
|
||||
for (const [obj, keys, expected] of objects) {
|
||||
const result = utils.omitKeys(obj, keys as any);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("isEqual", () => {
|
||||
const objects = [
|
||||
[1, 1, true],
|
||||
[1, "1", false],
|
||||
[1, 2, false],
|
||||
["1", "1", true],
|
||||
["1", "2", false],
|
||||
[true, true, true],
|
||||
[true, false, false],
|
||||
[false, false, true],
|
||||
[1, NaN, false],
|
||||
[NaN, NaN, true],
|
||||
[null, null, true],
|
||||
[null, undefined, false],
|
||||
[undefined, undefined, true],
|
||||
[new Map([["a", 1]]), new Map([["a", 1]]), true],
|
||||
[new Map([["a", 1]]), new Map([["a", 2]]), false],
|
||||
[new Map([["a", 1]]), new Map([["b", 1]]), false],
|
||||
[
|
||||
new Map([["a", 1]]),
|
||||
new Map([
|
||||
["a", 1],
|
||||
["b", 2]
|
||||
]),
|
||||
false
|
||||
],
|
||||
[{ a: 1 }, { a: 1 }, true],
|
||||
[{ a: 1 }, { a: 2 }, false],
|
||||
[{ a: 1 }, { b: 1 }, false],
|
||||
[{ a: "1" }, { a: "1" }, true],
|
||||
[{ a: "1" }, { a: "2" }, false],
|
||||
[{ a: "1" }, { b: "1" }, false],
|
||||
[{ a: 1 }, { a: 1, b: 2 }, false],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
|
||||
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
|
||||
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
|
||||
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
|
||||
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
|
||||
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
|
||||
[[1, 2, 3], [1, 2, 3], true],
|
||||
[[1, 2, 3], [1, 2, 4], false],
|
||||
[[1, 2, 3], [1, 2, 3, 4], false],
|
||||
[[{ a: 1 }], [{ a: 1 }], true],
|
||||
[[{ a: 1 }], [{ a: 2 }], false],
|
||||
[[{ a: 1 }], [{ b: 1 }], false]
|
||||
] as [any, any, boolean][];
|
||||
|
||||
for (const [a, b, expected] of objects) {
|
||||
const result = utils.isEqual(a, b);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("getPath", () => {
|
||||
const tests = [
|
||||
[{ a: 1, b: 2, c: 3 }, "a", 1],
|
||||
[{ a: 1, b: 2, c: 3 }, "b", 2],
|
||||
[{ a: { b: 1 } }, "a.b", 1],
|
||||
[{ a: { b: 1 } }, "a.b.c", null, null],
|
||||
[{ a: { b: 1 } }, "a.b.c", 1, 1],
|
||||
[[[1]], "0.0", 1]
|
||||
] as [object, string, any, any][];
|
||||
|
||||
for (const [obj, path, expected, defaultValue] of tests) {
|
||||
const result = utils.getPath(obj, path, defaultValue);
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,23 @@ describe("data-query-impl", () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// over http
|
||||
{
|
||||
const output = { with: { images: {} } };
|
||||
decode({ with: "images" }, output);
|
||||
decode({ with: '["images"]' }, output);
|
||||
decode({ with: ["images"] }, output);
|
||||
decode({ with: { images: {} } }, output);
|
||||
}
|
||||
|
||||
{
|
||||
const output = { with: { images: {}, comments: {} } };
|
||||
decode({ with: "images,comments" }, output);
|
||||
decode({ with: ["images", "comments"] }, output);
|
||||
decode({ with: '["images", "comments"]' }, output);
|
||||
decode({ with: { images: {}, comments: {} } }, output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
138
app/__test__/ui/json-form.spec.ts
Normal file
138
app/__test__/ui/json-form.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Draft2019 } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import * as utils from "../../src/ui/components/form/json-schema-form/utils";
|
||||
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
|
||||
|
||||
describe("json form", () => {
|
||||
test("coerse", () => {
|
||||
const examples = [
|
||||
["test", { type: "string" }, "test"],
|
||||
["1", { type: "integer" }, 1],
|
||||
["1", { type: "number" }, 1],
|
||||
["true", { type: "boolean" }, true],
|
||||
["false", { type: "boolean" }, false],
|
||||
["1", { type: "boolean" }, true],
|
||||
["0", { type: "boolean" }, false],
|
||||
["on", { type: "boolean" }, true],
|
||||
["off", { type: "boolean" }, false],
|
||||
["null", { type: "null" }, null]
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
|
||||
|
||||
for (const [input, schema, output] of examples) {
|
||||
expect(utils.coerce(input, schema)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("isType", () => {
|
||||
const examples = [
|
||||
["string", "string", true],
|
||||
["integer", "number", false],
|
||||
["number", "number", true],
|
||||
["boolean", "boolean", true],
|
||||
["null", "null", true],
|
||||
["object", "object", true],
|
||||
["array", "array", true],
|
||||
["object", "array", false],
|
||||
[["string", "number"], "number", true],
|
||||
["number", ["string", "number"], true]
|
||||
] satisfies [IsTypeType, IsTypeType, boolean][];
|
||||
|
||||
for (const [type, schemaType, output] of examples) {
|
||||
expect(utils.isType(type, schemaType)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("getParentPointer", () => {
|
||||
const examples = [
|
||||
["#/nested/property/0/name", "#/nested/property/0"],
|
||||
["#/nested/property/0", "#/nested/property"],
|
||||
["#/nested/property", "#/nested"],
|
||||
["#/nested", "#"]
|
||||
];
|
||||
|
||||
for (const [input, output] of examples) {
|
||||
expect(utils.getParentPointer(input)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("isRequired", () => {
|
||||
const examples = [
|
||||
[
|
||||
"#/description",
|
||||
{ type: "object", properties: { description: { type: "string" } } },
|
||||
false
|
||||
],
|
||||
[
|
||||
"#/description",
|
||||
{
|
||||
type: "object",
|
||||
required: ["description"],
|
||||
properties: { description: { type: "string" } }
|
||||
},
|
||||
true
|
||||
],
|
||||
[
|
||||
"#/nested/property",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { property: { type: "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
false
|
||||
],
|
||||
[
|
||||
"#/nested/property",
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
required: ["property"],
|
||||
properties: { property: { type: "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
true
|
||||
]
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||
|
||||
for (const [pointer, schema, output] of examples) {
|
||||
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("prefixPath", () => {
|
||||
const examples = [
|
||||
["normal", "", "normal"],
|
||||
["", "prefix", "prefix"],
|
||||
["tags", "0", "0.tags"],
|
||||
["tags", 0, "0.tags"],
|
||||
["nested.property", "prefix", "prefix.nested.property"],
|
||||
["nested.property", "", "nested.property"]
|
||||
] satisfies [string, any, string][];
|
||||
|
||||
for (const [path, prefix, output] of examples) {
|
||||
expect(utils.prefixPath(path, prefix)).toBe(output);
|
||||
}
|
||||
});
|
||||
|
||||
test("suffixPath", () => {
|
||||
const examples = [
|
||||
["normal", "", "normal"],
|
||||
["", "suffix", "suffix"],
|
||||
["tags", "0", "tags.0"],
|
||||
["tags", 0, "tags.0"],
|
||||
["nested.property", "suffix", "nested.property.suffix"],
|
||||
["nested.property", "", "nested.property"]
|
||||
] satisfies [string, any, string][];
|
||||
|
||||
for (const [path, suffix, output] of examples) {
|
||||
expect(utils.suffixPath(path, suffix)).toBe(output);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -173,13 +173,20 @@ function baseConfig(adapter: string): tsup.Options {
|
||||
],
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// base adapter handles
|
||||
await tsup.build({
|
||||
...baseConfig(""),
|
||||
entry: ["src/adapter/index.ts"],
|
||||
outDir: "dist/adapter"
|
||||
});
|
||||
|
||||
// specific adatpers
|
||||
await tsup.build(baseConfig("remix"));
|
||||
await tsup.build(baseConfig("bun"));
|
||||
await tsup.build(baseConfig("astro"));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0-rc.11",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -33,26 +33,30 @@
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^2.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@sinclair/typebox": "^0.32.34",
|
||||
"@tanstack/react-form": "0.19.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"aws4fetch": "^1.0.18",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"hono": "^4.6.12",
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"json-schema-library": "^10.0.0-rc7",
|
||||
"kysely": "^0.27.4",
|
||||
"liquidjs": "^10.15.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"swr": "^2.2.5",
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@hello-pangea/dnd": "^17.0.0"
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"radix-ui": "^1.1.2",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.613.0",
|
||||
@@ -62,7 +66,6 @@
|
||||
"@hono/zod-validator": "^0.4.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@rjsf/core": "^5.22.2",
|
||||
"@tabler/icons-react": "3.18.0",
|
||||
"@types/node": "^22.10.0",
|
||||
@@ -148,6 +151,10 @@
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||
},
|
||||
"./adapter": {
|
||||
"types": "./dist/types/adapter/index.d.ts",
|
||||
"import": "./dist/adapter/index.js"
|
||||
},
|
||||
"./adapter/vite": {
|
||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||
"import": "./dist/adapter/vite/index.js",
|
||||
|
||||
@@ -17,13 +17,21 @@ declare global {
|
||||
}
|
||||
|
||||
export type ApiOptions = {
|
||||
host: string;
|
||||
user?: TApiUser;
|
||||
token?: string;
|
||||
host?: string;
|
||||
headers?: Headers;
|
||||
key?: string;
|
||||
localStorage?: boolean;
|
||||
};
|
||||
fetcher?: typeof fetch;
|
||||
verified?: boolean;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
user?: TApiUser;
|
||||
}
|
||||
| {
|
||||
request: Request;
|
||||
}
|
||||
);
|
||||
|
||||
export type AuthState = {
|
||||
token?: string;
|
||||
@@ -42,14 +50,26 @@ export class Api {
|
||||
public auth!: AuthApi;
|
||||
public media!: MediaApi;
|
||||
|
||||
constructor(private readonly options: ApiOptions) {
|
||||
if (options.user) {
|
||||
this.user = options.user;
|
||||
this.token_transport = "none";
|
||||
this.verified = true;
|
||||
} else if (options.token) {
|
||||
constructor(private options: ApiOptions = {}) {
|
||||
// only mark verified if forced
|
||||
this.verified = options.verified === true;
|
||||
|
||||
// prefer request if given
|
||||
if ("request" in options) {
|
||||
this.options.host = options.host ?? new URL(options.request.url).origin;
|
||||
this.options.headers = options.headers ?? options.request.headers;
|
||||
this.extractToken();
|
||||
|
||||
// then check for a token
|
||||
} else if ("token" in options) {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(options.token);
|
||||
|
||||
// then check for an user object
|
||||
} else if ("user" in options) {
|
||||
this.token_transport = "none";
|
||||
this.user = options.user;
|
||||
this.verified = options.verified !== false;
|
||||
} else {
|
||||
this.extractToken();
|
||||
}
|
||||
@@ -58,7 +78,7 @@ export class Api {
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this.options.host;
|
||||
return this.options.host ?? "http://localhost";
|
||||
}
|
||||
|
||||
get tokenKey() {
|
||||
@@ -66,13 +86,15 @@ export class Api {
|
||||
}
|
||||
|
||||
private extractToken() {
|
||||
// if token has to be extracted, it's never verified
|
||||
this.verified = false;
|
||||
|
||||
if (this.options.headers) {
|
||||
// try cookies
|
||||
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||
if (cookieToken) {
|
||||
this.updateToken(cookieToken);
|
||||
this.token_transport = "cookie";
|
||||
this.verified = true;
|
||||
this.updateToken(cookieToken);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,6 +118,8 @@ export class Api {
|
||||
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
|
||||
if (token) {
|
||||
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||
} else {
|
||||
@@ -115,11 +139,15 @@ export class Api {
|
||||
if (rebuild) this.buildApis();
|
||||
}
|
||||
|
||||
markAuthVerified(verfied: boolean) {
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
this.verified = verfied;
|
||||
return this;
|
||||
}
|
||||
|
||||
isAuthVerified(): boolean {
|
||||
return this.verified;
|
||||
}
|
||||
|
||||
getAuthState(): AuthState {
|
||||
return {
|
||||
token: this.token,
|
||||
@@ -128,6 +156,11 @@ export class Api {
|
||||
};
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
const { token, user } = this.getAuthState();
|
||||
return !!token && !!user;
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(): Promise<AuthState> {
|
||||
await this.verifyAuth();
|
||||
return this.getAuthState();
|
||||
@@ -140,11 +173,13 @@ export class Api {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.auth.me();
|
||||
if (!res.ok || !res.body.user) {
|
||||
const { ok, data } = await this.auth.me();
|
||||
const user = data?.user;
|
||||
if (!ok || !user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.markAuthVerified(true);
|
||||
} catch (e) {
|
||||
this.markAuthVerified(false);
|
||||
@@ -156,21 +191,29 @@ export class Api {
|
||||
return this.user || null;
|
||||
}
|
||||
|
||||
private buildApis() {
|
||||
const baseParams = {
|
||||
host: this.options.host,
|
||||
getParams() {
|
||||
return Object.freeze({
|
||||
host: this.baseUrl,
|
||||
token: this.token,
|
||||
headers: this.options.headers,
|
||||
token_transport: this.token_transport
|
||||
};
|
||||
|
||||
this.system = new SystemApi(baseParams);
|
||||
this.data = new DataApi(baseParams);
|
||||
this.auth = new AuthApi({
|
||||
...baseParams,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||
});
|
||||
this.media = new MediaApi(baseParams);
|
||||
}
|
||||
|
||||
private buildApis() {
|
||||
const baseParams = this.getParams();
|
||||
const fetcher = this.options.fetcher;
|
||||
|
||||
this.system = new SystemApi(baseParams, fetcher);
|
||||
this.data = new DataApi(baseParams, fetcher);
|
||||
this.auth = new AuthApi(
|
||||
{
|
||||
...baseParams,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||
},
|
||||
fetcher
|
||||
);
|
||||
this.media = new MediaApi(baseParams, fetcher);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { Event } from "core/events";
|
||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
ModuleManager,
|
||||
@@ -132,7 +133,7 @@ export class App {
|
||||
return this.modules.ctx().em;
|
||||
}
|
||||
|
||||
get fetch(): any {
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch;
|
||||
}
|
||||
|
||||
@@ -155,6 +156,10 @@ export class App {
|
||||
return this.modules.version();
|
||||
}
|
||||
|
||||
isBuilt(): boolean {
|
||||
return this.modules.isBuilt();
|
||||
}
|
||||
|
||||
registerAdminController(config?: AdminControllerOptions) {
|
||||
// register admin
|
||||
this.adminController = new AdminController(this, config);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import { Api, type ApiOptions, type App } from "bknd";
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { Api, type ApiOptions } from "bknd/client";
|
||||
|
||||
export type AstroBkndConfig = FrameworkBkndConfig;
|
||||
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
|
||||
|
||||
type TAstro = {
|
||||
request: Request;
|
||||
@@ -13,18 +14,20 @@ export type Options = {
|
||||
host?: string;
|
||||
};
|
||||
|
||||
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
return new Api({
|
||||
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
const api = new Api({
|
||||
host: new URL(Astro.request.url).origin,
|
||||
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
||||
});
|
||||
await api.verifyAuth();
|
||||
return api;
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: AstroBkndConfig = {}) {
|
||||
return async (args: TAstro) => {
|
||||
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
|
||||
return async (args: Context) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
app = await createFrameworkApp(config, args);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import path from "node:path";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { config } from "bknd/core";
|
||||
import type { ServeOptions } from "bun";
|
||||
import { config } from "core";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
|
||||
let app: App;
|
||||
|
||||
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
|
||||
if (!app) {
|
||||
registerLocalMediaAdapter();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { CreateAppConfig } from "bknd";
|
||||
import type { FrameworkBkndConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { FrameworkBkndConfig } from "../index";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
|
||||
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (env: Env) => {
|
||||
bindings?: (args: Context<Env>) => {
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
};
|
||||
static?: "kv" | "assets";
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
forceHttps?: boolean;
|
||||
@@ -21,28 +20,33 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
export type Context<Env = any> = {
|
||||
request: Request;
|
||||
env: any;
|
||||
env: Env;
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
export function serve(config: CloudflareBkndConfig) {
|
||||
export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
const manifest = config.manifest;
|
||||
|
||||
if (manifest) {
|
||||
if (config.manifest && config.static === "assets") {
|
||||
console.warn("manifest is not useful with static 'assets'");
|
||||
} else if (!config.manifest && config.static === "kv") {
|
||||
throw new Error("manifest is required with static 'kv'");
|
||||
}
|
||||
|
||||
if (config.manifest && config.static !== "assets") {
|
||||
const pathname = url.pathname.slice(1);
|
||||
const assetManifest = JSON.parse(manifest);
|
||||
const assetManifest = JSON.parse(config.manifest);
|
||||
if (pathname && pathname in assetManifest) {
|
||||
const hono = new Hono();
|
||||
|
||||
hono.all("*", async (c, next) => {
|
||||
const res = await serveStatic({
|
||||
path: `./${pathname}`,
|
||||
manifest
|
||||
manifest: config.manifest!
|
||||
})(c as any, next);
|
||||
if (res instanceof Response) {
|
||||
const ttl = 60 * 60 * 24 * 365;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import { App } from "bknd";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
|
||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
|
||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
|
||||
const { kv } = config.bindings?.(env)!;
|
||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
|
||||
},
|
||||
adminOptions: { html: config.html }
|
||||
},
|
||||
env
|
||||
{ env, ctx, ...args }
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
|
||||
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||
const { dobj } = config.bindings?.(ctx.env)!;
|
||||
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||
const id = dobj.idFromName(key);
|
||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
|
||||
const create_config = makeConfig(config, ctx);
|
||||
|
||||
const res = await stub.fire(ctx.request, {
|
||||
config: create_config,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
|
||||
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
|
||||
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
adminOptions: config.html ? { html: config.html } : undefined
|
||||
},
|
||||
env
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,29 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { App, type CreateAppConfig, registries } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { config as $config } from "bknd/core";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
|
||||
export type BkndConfig<Env = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
|
||||
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
|
||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||
distPath?: string;
|
||||
};
|
||||
|
||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
let protocol = "http";
|
||||
try {
|
||||
protocol = req.headers["x-forwarded-proto"] as string;
|
||||
} catch (e) {}
|
||||
const host = req.headers.host;
|
||||
const url = `${protocol}://${host}${req.url}`;
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
headers.append(key, value.join(", "));
|
||||
} else if (value) {
|
||||
headers.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const method = req.method || "GET";
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
}
|
||||
|
||||
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
|
||||
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
if ("app" in config && config.app) {
|
||||
if (typeof config.app === "function") {
|
||||
if (!env) {
|
||||
throw new Error("env is required when config.app is a function");
|
||||
if (!args) {
|
||||
throw new Error("args is required when config.app is a function");
|
||||
}
|
||||
additionalConfig = config.app(env);
|
||||
additionalConfig = config.app(args);
|
||||
} else {
|
||||
additionalConfig = config.app;
|
||||
}
|
||||
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
|
||||
return { ...config, ...additionalConfig };
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Env = any>(
|
||||
export async function createFrameworkApp<Args = any>(
|
||||
config: FrameworkBkndConfig,
|
||||
env?: Env
|
||||
args?: Args
|
||||
): Promise<App> {
|
||||
const app = App.create(makeConfig(config, env));
|
||||
const app = App.create(makeConfig(config, args));
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
|
||||
export async function createRuntimeApp<Env = any>(
|
||||
{
|
||||
serveStatic,
|
||||
registerLocalMedia,
|
||||
adminOptions,
|
||||
...config
|
||||
}: RuntimeBkndConfig & {
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
registerLocalMedia?: boolean;
|
||||
adminOptions?: AdminControllerOptions | false;
|
||||
},
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
if (registerLocalMedia) {
|
||||
registerLocalMediaAdapter();
|
||||
}
|
||||
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
app.emgr.onEvent(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Api, type App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
||||
import { nodeRequestToRequest } from "adapter/utils";
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||
cleanSearch?: string[];
|
||||
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
|
||||
}
|
||||
|
||||
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
||||
return (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||
return handler({ ...ctx, api: createApi(ctx) });
|
||||
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||
const api = createApi(ctx);
|
||||
await api.verifyAuth();
|
||||
return handler({ ...ctx, api });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export * from "./node.adapter";
|
||||
export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
import { registries } from "bknd";
|
||||
import {
|
||||
type LocalAdapterConfig,
|
||||
StorageLocalAdapter
|
||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||
export { registerLocalMediaAdapter } from "../index";
|
||||
|
||||
export * from "./node.adapter";
|
||||
export { StorageLocalAdapter, type LocalAdapterConfig };
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { registerLocalMediaAdapter } from "adapter/node/index";
|
||||
import type { App } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { config as $config } from "bknd/core";
|
||||
|
||||
export type NodeBkndConfig = RuntimeBkndConfig & {
|
||||
port?: number;
|
||||
@@ -37,9 +38,9 @@ export function serve({
|
||||
hostname,
|
||||
fetch: async (req: Request) => {
|
||||
if (!app) {
|
||||
registerLocalMediaAdapter();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useAuth } from "bknd/client";
|
||||
import type { BkndAdminProps } from "bknd/ui";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
|
||||
export function adminPage(props?: BkndAdminProps) {
|
||||
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
||||
return () => {
|
||||
const auth = useAuth();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Admin {...props} />
|
||||
<Admin withProvider={{ user: auth.user }} {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
export type RemixBkndConfig = FrameworkBkndConfig;
|
||||
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
|
||||
|
||||
type RemixContext = {
|
||||
request: Request;
|
||||
};
|
||||
|
||||
let app: App;
|
||||
export function serve(config: RemixBkndConfig = {}) {
|
||||
return async (args: { request: Request }) => {
|
||||
export function serve<Args extends RemixContext = RemixContext>(
|
||||
config: RemixBkndConfig<Args> = {}
|
||||
) {
|
||||
return async (args: Args) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
app = await createFrameworkApp(config, args);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
}
|
||||
|
||||
export function withApi<Args extends { request: Request; context: { api: Api } }, R>(
|
||||
handler: (args: Args, api: Api) => Promise<R>
|
||||
) {
|
||||
return async (args: Args) => {
|
||||
if (!args.context.api) {
|
||||
args.context.api = new Api({
|
||||
host: new URL(args.request.url).origin,
|
||||
headers: args.request.headers
|
||||
});
|
||||
await args.context.api.verifyAuth();
|
||||
}
|
||||
|
||||
return handler(args, args.context.api);
|
||||
};
|
||||
}
|
||||
|
||||
25
app/src/adapter/utils.ts
Normal file
25
app/src/adapter/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
let protocol = "http";
|
||||
try {
|
||||
protocol = req.headers["x-forwarded-proto"] as string;
|
||||
} catch (e) {}
|
||||
const host = req.headers.host;
|
||||
const url = `${protocol}://${host}${req.url}`;
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
headers.append(key, value.join(", "));
|
||||
} else if (value) {
|
||||
headers.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const method = req.method || "GET";
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
|
||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
}
|
||||
|
||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
adminOptions:
|
||||
config.setAdminHtml === false
|
||||
? undefined
|
||||
|
||||
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
async login(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input);
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
async register(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input);
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export class AuthController extends Controller {
|
||||
return this.auth.ctx.guard;
|
||||
}
|
||||
|
||||
get em() {
|
||||
return this.auth.ctx.em;
|
||||
}
|
||||
|
||||
get userRepo() {
|
||||
const entity_name = this.auth.config.entity_name;
|
||||
return this.em.repo(entity_name as "users");
|
||||
}
|
||||
|
||||
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
||||
const actions = strategy.getActions?.();
|
||||
if (!actions) {
|
||||
@@ -96,7 +105,10 @@ export class AuthController extends Controller {
|
||||
|
||||
hono.get("/me", auth(), async (c) => {
|
||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||
return c.json({ user: this.auth.authenticator.getUser() });
|
||||
const claims = this.auth.authenticator.getUser()!;
|
||||
const { data: user } = await this.userRepo.findId(claims.id);
|
||||
|
||||
return c.json({ user });
|
||||
}
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
|
||||
@@ -259,7 +259,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
async requestCookieRefresh(c: Context) {
|
||||
if (this.config.cookie.renew) {
|
||||
if (this.config.cookie.renew && this.isUserLoggedIn()) {
|
||||
const token = await this.getAuthCookie(c);
|
||||
if (token) {
|
||||
await this.setAuthCookie(c, token);
|
||||
@@ -299,8 +299,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
private getSuccessPath(c: Context) {
|
||||
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
|
||||
private getSafeUrl(c: Context, path: string) {
|
||||
const p = path.replace(/\/+$/, "/");
|
||||
|
||||
// nextjs doesn't support non-fq urls
|
||||
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
||||
@@ -312,21 +312,26 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data);
|
||||
}
|
||||
|
||||
const successUrl = this.getSuccessPath(c);
|
||||
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
|
||||
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||
|
||||
if ("token" in data) {
|
||||
await this.setAuthCookie(c, data.token);
|
||||
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data);
|
||||
}
|
||||
|
||||
// can't navigate to "/" – doesn't work on nextjs
|
||||
//console.log("auth success, redirecting to", successUrl);
|
||||
return c.redirect(successUrl);
|
||||
}
|
||||
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data, 400);
|
||||
}
|
||||
|
||||
let message = "An error occured";
|
||||
if (data instanceof Exception) {
|
||||
message = data.message;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { isDebug, tbValidator as tb } from "core";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
|
||||
const hono = new Hono();
|
||||
|
||||
return hono
|
||||
.post("/login", async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
.post(
|
||||
"/login",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
redirect: Type.Optional(Type.String())
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve(
|
||||
"login",
|
||||
this,
|
||||
payload.password,
|
||||
payload
|
||||
);
|
||||
|
||||
return await authenticator.respond(c, data);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
return await authenticator.respond(c, data, redirect);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
)
|
||||
.post(
|
||||
"/register",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
redirect: Type.Optional(Type.String())
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve(
|
||||
"register",
|
||||
this,
|
||||
payload.password,
|
||||
payload
|
||||
);
|
||||
|
||||
return await authenticator.respond(c, data);
|
||||
});
|
||||
return await authenticator.respond(c, data, redirect);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getActions(): StrategyActions {
|
||||
|
||||
@@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
export function omitKeys<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys_: readonly K[]
|
||||
): Omit<T, Extract<K, keyof T>> {
|
||||
const keys = new Set(keys_);
|
||||
const result = {} as Omit<T, Extract<K, keyof T>>;
|
||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||
if (!keys.has(key as K)) {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
@@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) {
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
export function isEqual(value1: any, value2: any): boolean {
|
||||
// Each type corresponds to a particular comparison algorithm
|
||||
const getType = (value: any) => {
|
||||
if (value !== Object(value)) return "primitive";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value instanceof Map) return "map";
|
||||
if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
|
||||
return "plainObject";
|
||||
if (value instanceof Function) return "function";
|
||||
throw new Error(
|
||||
`deeply comparing an instance of type ${value1.constructor?.name} is not supported.`
|
||||
);
|
||||
};
|
||||
|
||||
const type = getType(value1);
|
||||
if (type !== getType(value2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === "primitive") {
|
||||
return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
|
||||
} else if (type === "array") {
|
||||
return (
|
||||
value1.length === value2.length &&
|
||||
value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
|
||||
);
|
||||
} else if (type === "map") {
|
||||
// In this particular implementation, map keys are not
|
||||
// being deeply compared, only map values.
|
||||
return (
|
||||
value1.size === value2.size &&
|
||||
[...value1].every(([iterKey, iterValue]) => {
|
||||
return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
|
||||
})
|
||||
);
|
||||
} else if (type === "plainObject") {
|
||||
const value1AsMap = new Map(Object.entries(value1));
|
||||
const value2AsMap = new Map(Object.entries(value2));
|
||||
return (
|
||||
value1AsMap.size === value2AsMap.size &&
|
||||
[...value1AsMap].every(([iterKey, iterValue]) => {
|
||||
return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
|
||||
})
|
||||
);
|
||||
} else if (type === "function") {
|
||||
// just check signature
|
||||
return value1.toString() === value2.toString();
|
||||
} else {
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(
|
||||
object: object,
|
||||
_path: string | (string | number)[],
|
||||
defaultValue = undefined
|
||||
): any {
|
||||
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
|
||||
|
||||
if (path.length === 0) {
|
||||
return object;
|
||||
}
|
||||
|
||||
try {
|
||||
const [head, ...tail] = path;
|
||||
if (!head || !(head in object)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return getPath(object[head], tail, defaultValue);
|
||||
} catch (error) {
|
||||
if (typeof defaultValue !== "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid path: ${path.join(".")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) {
|
||||
case "SCREAMING_SNAKE_CASE":
|
||||
return snakeToPascalWithSpaces(str.toLowerCase());
|
||||
case "unknown":
|
||||
return str;
|
||||
return ucFirst(str);
|
||||
}
|
||||
}
|
||||
export function autoFormatString(str: string) {
|
||||
return identifierToHumanReadable(str);
|
||||
}
|
||||
|
||||
export function kebabToPascalWithSpaces(str: string): string {
|
||||
return str.split("-").map(ucFirst).join(" ");
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type ValueErrorIterator
|
||||
} from "@sinclair/typebox/errors";
|
||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: T[P] extends (infer U)[]
|
||||
@@ -73,7 +72,7 @@ export class TypeInvalidError extends Error {
|
||||
}
|
||||
|
||||
export function stripMark<O = any>(obj: O) {
|
||||
const newObj = cloneDeep(obj);
|
||||
const newObj = structuredClone(obj);
|
||||
mark(newObj, false);
|
||||
return newObj as O;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type StaticDecode,
|
||||
StringEnum,
|
||||
Type,
|
||||
Value
|
||||
Value,
|
||||
isObject
|
||||
} from "core/utils";
|
||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||
|
||||
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
|
||||
>;
|
||||
|
||||
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
|
||||
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)]))
|
||||
Type.Transform(
|
||||
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)])
|
||||
)
|
||||
.Decode((value) => {
|
||||
let _value = typeof value === "string" ? [value] : value;
|
||||
// images
|
||||
// images,comments
|
||||
// ["images","comments"]
|
||||
// { "images": {} }
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((v) => typeof v === "string")) {
|
||||
throw new Error("Invalid 'with' schema");
|
||||
}
|
||||
|
||||
_value = value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as RepoWithSchema);
|
||||
if (!Array.isArray(value) && isObject(value)) {
|
||||
console.log("is object");
|
||||
return value as RepoWithSchema;
|
||||
}
|
||||
|
||||
return _value as RepoWithSchema;
|
||||
let _value: any = null;
|
||||
if (typeof value === "string") {
|
||||
// if stringified object
|
||||
if (value.match(/^\{/)) {
|
||||
return JSON.parse(value) as RepoWithSchema;
|
||||
}
|
||||
|
||||
// if stringified array
|
||||
if (value.match(/^\[/)) {
|
||||
_value = JSON.parse(value) as string[];
|
||||
|
||||
// if comma-separated string
|
||||
} else if (value.includes(",")) {
|
||||
_value = value.split(",");
|
||||
|
||||
// if single string
|
||||
} else {
|
||||
_value = [value];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
_value = value;
|
||||
}
|
||||
|
||||
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
|
||||
throw new Error("Invalid 'with' schema");
|
||||
}
|
||||
|
||||
return _value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as RepoWithSchema);
|
||||
})
|
||||
.Encode((value) => value);
|
||||
|
||||
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string[] | Record<string, RepoQueryIn>;
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,5 @@ export {
|
||||
export * as middlewares from "modules/middlewares";
|
||||
export { registries } from "modules/registries";
|
||||
|
||||
export type * from "./adapter";
|
||||
export { Api, type ApiOptions } from "./Api";
|
||||
|
||||
export type { MediaFieldSchema } from "media/AppMedia";
|
||||
export type { UserFieldSchema } from "auth/AppAuth";
|
||||
|
||||
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
|
||||
config: adapter.schema
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
title: adapter.schema.title ?? name,
|
||||
description: adapter.schema.description,
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const cloudinaryAdapterConfig = Type.Object(
|
||||
api_secret: Type.String(),
|
||||
upload_preset: Type.Optional(Type.String())
|
||||
},
|
||||
{ title: "Cloudinary" }
|
||||
{ title: "Cloudinary", description: "Cloudinary media storage" }
|
||||
);
|
||||
|
||||
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
||||
|
||||
@@ -7,7 +7,7 @@ export const localAdapterConfig = Type.Object(
|
||||
{
|
||||
path: Type.String({ default: "./" })
|
||||
},
|
||||
{ title: "Local" }
|
||||
{ title: "Local", description: "Local file system storage" }
|
||||
);
|
||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
|
||||
})
|
||||
},
|
||||
{
|
||||
title: "S3"
|
||||
title: "AWS S3",
|
||||
description: "AWS S3 or compatible storage"
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ export type ApiResponse<Data = any> = {
|
||||
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 fetcher?: typeof fetch
|
||||
) {}
|
||||
|
||||
protected getDefaultOptions(): Partial<Options> {
|
||||
return {};
|
||||
|
||||
@@ -68,6 +68,12 @@ export type InitialModuleConfigs =
|
||||
} & ModuleConfigs)
|
||||
| PartialRec<ModuleConfigs>;
|
||||
|
||||
enum Verbosity {
|
||||
silent = 0,
|
||||
error = 1,
|
||||
log = 2
|
||||
}
|
||||
|
||||
export type ModuleManagerOptions = {
|
||||
initial?: InitialModuleConfigs;
|
||||
eventManager?: EventManager<any>;
|
||||
@@ -85,6 +91,8 @@ export type ModuleManagerOptions = {
|
||||
trustFetched?: boolean;
|
||||
// runs when initial config provided on a fresh database
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
// wether
|
||||
verbosity?: Verbosity;
|
||||
};
|
||||
|
||||
type ConfigTable<Json = ModuleConfigs> = {
|
||||
@@ -135,7 +143,7 @@ export class ModuleManager {
|
||||
private _built = false;
|
||||
private readonly _booted_with?: "provided" | "partial";
|
||||
|
||||
private logger = new DebugLogger(false);
|
||||
private logger: DebugLogger;
|
||||
|
||||
constructor(
|
||||
private readonly connection: Connection,
|
||||
@@ -144,6 +152,7 @@ export class ModuleManager {
|
||||
this.__em = new EntityManager([__bknd], this.connection);
|
||||
this.modules = {} as Modules;
|
||||
this.emgr = new EventManager();
|
||||
this.logger = new DebugLogger(this.verbosity === Verbosity.log);
|
||||
const context = this.ctx(true);
|
||||
let initial = {} as Partial<ModuleConfigs>;
|
||||
|
||||
@@ -171,6 +180,14 @@ export class ModuleManager {
|
||||
}
|
||||
}
|
||||
|
||||
private get verbosity() {
|
||||
return this.options?.verbosity ?? Verbosity.silent;
|
||||
}
|
||||
|
||||
isBuilt(): boolean {
|
||||
return this._built;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is set through module's setListener
|
||||
* It's called everytime a module's config is updated in SchemaObject
|
||||
@@ -241,20 +258,23 @@ export class ModuleManager {
|
||||
const startTime = performance.now();
|
||||
|
||||
// disabling console log, because the table might not exist yet
|
||||
const result = await withDisabledConsole(async () => {
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
sort: { by: "version", dir: "desc" }
|
||||
const result = await withDisabledConsole(
|
||||
async () => {
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
sort: { by: "version", dir: "desc" }
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw BkndError.with("no config");
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw BkndError.with("no config");
|
||||
}
|
||||
|
||||
return result as unknown as ConfigTable;
|
||||
}, ["log", "error", "warn"]);
|
||||
return result as unknown as ConfigTable;
|
||||
},
|
||||
this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"]
|
||||
);
|
||||
|
||||
this.logger
|
||||
.log("took", performance.now() - startTime, "ms", {
|
||||
|
||||
@@ -70,7 +70,8 @@ export class AdminController extends Controller {
|
||||
hono.use("*", async (c, next) => {
|
||||
const obj = {
|
||||
user: auth.authenticator?.getUser(),
|
||||
logout_route: this.withBasePath(authRoutes.logout)
|
||||
logout_route: this.withBasePath(authRoutes.logout),
|
||||
color_scheme: configs.server.admin.color_scheme
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
if (!html) {
|
||||
@@ -190,6 +191,10 @@ export class AdminController extends Controller {
|
||||
/>
|
||||
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||
<title>BKND</title>
|
||||
{/*<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>*/}
|
||||
{isProd ? (
|
||||
<Fragment>
|
||||
<script
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Notifications } from "@mantine/notifications";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import React from "react";
|
||||
import { BkndProvider, useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
@@ -40,8 +41,7 @@ export default function Admin({
|
||||
}
|
||||
|
||||
function AdminInternal() {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
@@ -18,11 +15,18 @@ type BkndContext = {
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
adminOverride?: ModuleConfigs["server"]["admin"];
|
||||
fallback: boolean;
|
||||
};
|
||||
|
||||
const BkndContext = createContext<BkndContext>(undefined!);
|
||||
export type { TSchemaActions };
|
||||
|
||||
enum Fetching {
|
||||
None = 0,
|
||||
Schema = 1,
|
||||
Secrets = 2
|
||||
}
|
||||
|
||||
export function BkndProvider({
|
||||
includeSecrets = false,
|
||||
adminOverride,
|
||||
@@ -34,10 +38,11 @@ export function BkndProvider({
|
||||
>) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [error, setError] = useState<boolean>();
|
||||
const errorShown = useRef<boolean>();
|
||||
const fetching = useRef<Fetching>(Fetching.None);
|
||||
const [local_version, set_local_version] = useState(0);
|
||||
const api = useApi();
|
||||
|
||||
@@ -46,7 +51,12 @@ export function BkndProvider({
|
||||
}
|
||||
|
||||
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
|
||||
const requesting = withSecrets ? Fetching.Secrets : Fetching.Schema;
|
||||
if (fetching.current === requesting) return;
|
||||
|
||||
if (withSecrets && !force) return;
|
||||
fetching.current = requesting;
|
||||
|
||||
const res = await api.system.readSchema({
|
||||
config: true,
|
||||
secrets: _includeSecrets
|
||||
@@ -57,32 +67,35 @@ export function BkndProvider({
|
||||
errorShown.current = true;
|
||||
|
||||
setError(true);
|
||||
//return;
|
||||
// if already has schema, don't overwrite
|
||||
if (fetched && schema?.schema) return;
|
||||
} else if (error) {
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const schema = res.ok
|
||||
const newSchema = res.ok
|
||||
? res.body
|
||||
: ({
|
||||
version: 0,
|
||||
schema: getDefaultSchema(),
|
||||
config: getDefaultConfig(),
|
||||
permissions: []
|
||||
permissions: [],
|
||||
fallback: true
|
||||
} as any);
|
||||
|
||||
if (adminOverride) {
|
||||
schema.config.server.admin = {
|
||||
...schema.config.server.admin,
|
||||
newSchema.config.server.admin = {
|
||||
...newSchema.config.server.admin,
|
||||
...adminOverride
|
||||
};
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setSchema(schema);
|
||||
setSchema(newSchema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
fetching.current = Fetching.None;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
|
||||
console.error("error .....", e);
|
||||
}
|
||||
|
||||
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
|
||||
return (
|
||||
@@ -60,6 +60,7 @@ export const useBaseUrl = () => {
|
||||
type BkndWindowContext = {
|
||||
user?: TApiUser;
|
||||
logout_route: string;
|
||||
color_scheme?: "light" | "dark";
|
||||
};
|
||||
export function useBkndWindowContext(): BkndWindowContext {
|
||||
if (typeof window !== "undefined" && window.__BKND__) {
|
||||
|
||||
@@ -9,4 +9,4 @@ export {
|
||||
export * from "./api/use-api";
|
||||
export * from "./api/use-entity";
|
||||
export { useAuth } from "./schema/auth/use-auth";
|
||||
export { Api } from "../../Api";
|
||||
export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";
|
||||
|
||||
22
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
22
app/src/ui/client/schema/media/use-bknd-media.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
|
||||
export function useBkndMedia() {
|
||||
const { config, schema, actions: bkndActions } = useBknd();
|
||||
|
||||
const actions = {
|
||||
config: {
|
||||
patch: async (data: Partial<TAppMediaConfig>) => {
|
||||
return await bkndActions.set("media", data, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const $media = {};
|
||||
|
||||
return {
|
||||
$media,
|
||||
config: config.media,
|
||||
schema: schema.media,
|
||||
actions
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export function useBkndSystem() {
|
||||
const { config, schema, actions: bkndActions } = useBknd();
|
||||
const theme = config.server.admin.color_scheme ?? "light";
|
||||
const { theme } = useTheme();
|
||||
|
||||
const actions = {
|
||||
theme: {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { useBkndWindowContext } from "ui/client/ClientProvider";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
|
||||
export function useTheme(): { theme: "light" | "dark" } {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme as any;
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
return { theme };
|
||||
export function useTheme(fallback: Theme = "light"): { theme: Theme } {
|
||||
const b = useBknd();
|
||||
const winCtx = useBkndWindowContext();
|
||||
if (b) {
|
||||
if (b?.adminOverride?.color_scheme) {
|
||||
return { theme: b.adminOverride.color_scheme };
|
||||
} else if (!b.fallback) {
|
||||
return { theme: b.config.server.admin.color_scheme ?? fallback };
|
||||
}
|
||||
}
|
||||
|
||||
return { theme: winCtx.color_scheme ?? fallback };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type React from "react";
|
||||
import { Children } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
@@ -19,7 +20,7 @@ const styles = {
|
||||
default: "bg-primary/5 hover:bg-primary/10 link text-primary/70",
|
||||
primary: "bg-primary hover:bg-primary/80 link text-background",
|
||||
ghost: "bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
outline: "border border-primary/70 bg-transparent hover:bg-primary/5 link text-primary/70",
|
||||
outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80",
|
||||
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
||||
subtlered:
|
||||
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link"
|
||||
@@ -50,7 +51,7 @@ const Base = ({
|
||||
}: BaseProps) => ({
|
||||
...props,
|
||||
className: twMerge(
|
||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed",
|
||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
|
||||
sizes[size ?? "default"],
|
||||
styles[variant ?? "default"],
|
||||
props.className
|
||||
@@ -58,7 +59,11 @@ const Base = ({
|
||||
children: (
|
||||
<>
|
||||
{IconLeft && <IconLeft size={iconSize} {...iconProps} />}
|
||||
{children && <span className={twMerge("leading-none", labelClassName)}>{children}</span>}
|
||||
{children && Children.count(children) === 1 ? (
|
||||
<span className={twMerge("leading-none", labelClassName)}>{children}</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{IconRight && <IconRight size={iconSize} {...iconProps} />}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IconCopy } from "@tabler/icons-react";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { JsonView } from "react-json-view-lite";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export function Logo({
|
||||
scale = 0.2,
|
||||
fill,
|
||||
theme = "light"
|
||||
...props
|
||||
}: { 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 t = useTheme();
|
||||
const theme = props.theme ?? t.theme;
|
||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||
|
||||
const dim = {
|
||||
width: Math.round(578 * scale),
|
||||
|
||||
@@ -3,9 +3,10 @@ import { forwardRef, useEffect, useState } from "react";
|
||||
|
||||
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
(props, ref) => {
|
||||
const [checked, setChecked] = useState(Boolean(props.value));
|
||||
const [checked, setChecked] = useState(Boolean(props.value ?? props.defaultValue));
|
||||
|
||||
useEffect(() => {
|
||||
console.log("value change", props.value);
|
||||
setChecked(Boolean(props.value));
|
||||
}, [props.value]);
|
||||
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Switch as RadixSwitch } from "radix-ui";
|
||||
import {
|
||||
type ChangeEventHandler,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementType,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
export const Group = <E extends ElementType = "div">({
|
||||
error,
|
||||
as,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
}: React.ComponentProps<E> & { error?: boolean; as?: E }) => {
|
||||
const Tag = as || "div";
|
||||
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5",
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
as === "fieldset" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const formElementFactory = (element: string, props: any) => {
|
||||
switch (element) {
|
||||
@@ -34,7 +50,21 @@ export const formElementFactory = (element: string, props: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const Label: React.FC<React.ComponentProps<"label">> = (props) => <label {...props} />;
|
||||
export const Label = <E extends ElementType = "label">({
|
||||
as,
|
||||
...props
|
||||
}: React.ComponentProps<E> & { as?: E }) => {
|
||||
const Tag = as || "label";
|
||||
return <Tag {...props} />;
|
||||
};
|
||||
|
||||
export const Help: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||
<div {...props} className={twMerge("text-sm text-primary/50", className)} />
|
||||
);
|
||||
|
||||
export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className, ...props }) => (
|
||||
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
||||
);
|
||||
|
||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
||||
field,
|
||||
@@ -145,20 +175,81 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, React.ComponentProps<"select">>(
|
||||
(props, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
"border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
|
||||
export const Switch = forwardRef<
|
||||
HTMLButtonElement,
|
||||
Pick<
|
||||
ComponentPropsWithoutRef<"input">,
|
||||
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
|
||||
> & {
|
||||
value?: SwitchValue;
|
||||
onChange?: (e: { target: { value: boolean } }) => void;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
>(({ type, required, ...props }, ref) => {
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
|
||||
onCheckedChange={(bool) => {
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
{...(props as any)}
|
||||
checked={
|
||||
typeof props.checked !== "undefined"
|
||||
? props.checked
|
||||
: typeof props.value !== "undefined"
|
||||
? Boolean(props.value)
|
||||
: undefined
|
||||
}
|
||||
ref={ref}
|
||||
>
|
||||
<RadixSwitch.Thumb className="block h-full aspect-square translate-x-0 rounded-full bg-background transition-transform duration-100 will-change-transform border border-muted data-[state=checked]:translate-x-[17px]" />
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<"select"> & {
|
||||
options?: { value: string; label: string }[] | (string | number)[];
|
||||
}
|
||||
>(({ children, options, ...props }, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
<select
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"appearance-none h-11 w-full",
|
||||
!props.multiple && "border-r-8 border-r-transparent",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{options ? (
|
||||
<>
|
||||
{!props.required && <option value="" />}
|
||||
{options
|
||||
.map((o) => {
|
||||
if (typeof o !== "object") {
|
||||
return { value: o, label: String(o) };
|
||||
}
|
||||
return o;
|
||||
})
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</select>
|
||||
{!props.multiple && (
|
||||
<TbChevronDown
|
||||
className="absolute right-3 top-0 bottom-0 h-full opacity-70 pointer-events-none"
|
||||
size={18}
|
||||
/>
|
||||
<TbChevronDown className="absolute right-3 top-0 bottom-0 h-full opacity-70" size={18} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
148
app/src/ui/components/form/json-schema-form/AnyOfField.tsx
Normal file
148
app/src/ui/components/form/json-schema-form/AnyOfField.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type { JsonError, JsonSchema } from "json-schema-library";
|
||||
import { type ChangeEvent, type ReactNode, createContext, useContext, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
|
||||
import { getLabel, getMultiSchemaMatched } from "./utils";
|
||||
|
||||
export type AnyOfFieldRootProps = {
|
||||
path?: string;
|
||||
schema?: JsonSchema;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type AnyOfFieldContext = {
|
||||
path: string;
|
||||
schema: JsonSchema;
|
||||
schemas?: JsonSchema[];
|
||||
selectedSchema?: JsonSchema;
|
||||
selected: number | null;
|
||||
select: (index: number | null) => void;
|
||||
options: string[];
|
||||
errors: JsonError[];
|
||||
selectSchema: JsonSchema;
|
||||
};
|
||||
|
||||
const AnyOfContext = createContext<AnyOfFieldContext>(undefined!);
|
||||
|
||||
export const useAnyOfContext = () => {
|
||||
return useContext(AnyOfContext);
|
||||
};
|
||||
|
||||
const selectedAtom = atom<number | null>(null);
|
||||
|
||||
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
|
||||
const {
|
||||
setValue,
|
||||
lib,
|
||||
pointer,
|
||||
value: { matchedIndex, schemas },
|
||||
schema
|
||||
} = useDerivedFieldContext(path, _schema, (ctx) => {
|
||||
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
|
||||
return { matchedIndex, schemas };
|
||||
});
|
||||
const errors = useFormError(path, { strict: true });
|
||||
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
|
||||
const [_selected, setSelected] = useAtom(selectedAtom);
|
||||
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
|
||||
|
||||
const select = useEvent((index: number | null) => {
|
||||
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
|
||||
setSelected(index);
|
||||
});
|
||||
|
||||
const context = useMemo(() => {
|
||||
const options = schemas.map((s, i) => s.title ?? `Option ${i + 1}`);
|
||||
const selectSchema = {
|
||||
type: "string",
|
||||
enum: options
|
||||
} satisfies JsonSchema;
|
||||
|
||||
const selectedSchema = selected !== null ? (schemas[selected] as JsonSchema) : undefined;
|
||||
|
||||
return {
|
||||
options,
|
||||
selectSchema,
|
||||
selectedSchema,
|
||||
schema,
|
||||
schemas,
|
||||
selected
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<AnyOfContext.Provider
|
||||
key={selected}
|
||||
value={{
|
||||
...context,
|
||||
select,
|
||||
path,
|
||||
errors
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AnyOfContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = () => {
|
||||
const { selected, select, path, schema, options, selectSchema } = useAnyOfContext();
|
||||
|
||||
const handleSelect = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const i = e.target.value ? Number(e.target.value) : null;
|
||||
select(i);
|
||||
});
|
||||
|
||||
const _options = useMemo(() => options.map((label, value) => ({ label, value })), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formy.Label>{getLabel(path, schema)}</Formy.Label>
|
||||
<FieldComponent
|
||||
schema={selectSchema as any}
|
||||
/* @ts-ignore */
|
||||
options={_options}
|
||||
onChange={handleSelect}
|
||||
value={selected ?? undefined}
|
||||
className="h-8 py-1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// @todo: add local validation for AnyOf fields
|
||||
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
|
||||
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||
if (selected === null) return null;
|
||||
return (
|
||||
<FormContextOverride prefix={path} schema={selectedSchema}>
|
||||
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
|
||||
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnyOf = {
|
||||
Root,
|
||||
Select,
|
||||
Field,
|
||||
useContext: useAnyOfContext
|
||||
};
|
||||
|
||||
export const AnyOfField = (props: Omit<AnyOfFieldRootProps, "children">) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<AnyOf.Root {...props}>
|
||||
<legend className="flex flex-row gap-2 items-center py-2">
|
||||
<AnyOf.Select />
|
||||
</legend>
|
||||
<AnyOf.Field />
|
||||
</AnyOf.Root>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
138
app/src/ui/components/form/json-schema-form/ArrayField.tsx
Normal file
138
app/src/ui/components/form/json-schema-form/ArrayField.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { IconLibraryPlus, IconTrash } from "@tabler/icons-react";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
schema: _schema
|
||||
}: { path?: string; schema?: JsonSchema }) => {
|
||||
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
// if unique items with enum
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldComponent
|
||||
required
|
||||
name={path}
|
||||
schema={schema.items}
|
||||
multiple
|
||||
className="h-auto"
|
||||
onChange={(e: any) => {
|
||||
// @ts-ignore
|
||||
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||
setValue(ctx.path, selected);
|
||||
}}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<ArrayIterator name={path}>
|
||||
{({ value }) =>
|
||||
value?.map((v, index: number) => (
|
||||
<ArrayItem key={index} path={path} index={index} schema={schema} />
|
||||
))
|
||||
}
|
||||
</ArrayIterator>
|
||||
<div className="flex flex-row">
|
||||
<ArrayAdd path={path} schema={schema} />
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
|
||||
return ctx.value?.[index];
|
||||
});
|
||||
const itemPath = suffixPath(path, index);
|
||||
let subschema = schema.items;
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
if (itemsMultiSchema) {
|
||||
const [, , _subschema] = getMultiSchemaMatched(schema.items, value);
|
||||
subschema = _subschema;
|
||||
}
|
||||
|
||||
const handleUpdate = useEvent((pointer: string, value: any) => {
|
||||
ctx.setValue(pointer, value);
|
||||
});
|
||||
|
||||
const handleDelete = useEvent((pointer: string) => {
|
||||
ctx.deleteValue(pointer);
|
||||
});
|
||||
|
||||
const DeleteButton = useMemo(
|
||||
() => <IconButton Icon={IconTrash} onClick={() => handleDelete(itemPath)} size="sm" />,
|
||||
[itemPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleUpdate(itemPath, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{DeleteButton}
|
||||
</div>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const ArrayIterator = memo(
|
||||
({ name, children }: any) => {
|
||||
return children(useFormValue(name));
|
||||
},
|
||||
(prev, next) => prev.value?.length === next.value?.length
|
||||
);
|
||||
|
||||
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, schema, (ctx) => {
|
||||
return { currentIndex: ctx.value?.length ?? 0 };
|
||||
});
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const newPath = suffixPath(path, currentIndex);
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
}
|
||||
|
||||
if (itemsMultiSchema) {
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownWrapperProps={{
|
||||
className: "min-w-0"
|
||||
}}
|
||||
items={itemsMultiSchema.map((s, i) => ({
|
||||
label: s!.title ?? `Option ${i + 1}`,
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!))
|
||||
}))}
|
||||
onClickItem={console.log}
|
||||
>
|
||||
<Button IconLeft={IconLibraryPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button onClick={() => handleAdd()}>Add</Button>;
|
||||
};
|
||||
141
app/src/ui/components/form/json-schema-form/Field.tsx
Normal file
141
app/src/ui/components/form/json-schema-form/Field.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { ArrayField } from "./ArrayField";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { ObjectField } from "./ObjectField";
|
||||
import { coerce, isType, isTypeSchema } from "./utils";
|
||||
|
||||
export type FieldProps = {
|
||||
name: string;
|
||||
schema?: JsonSchema;
|
||||
onChange?: (e: ChangeEvent<any>) => void;
|
||||
label?: string | false;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
|
||||
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!isTypeSchema(schema))
|
||||
return (
|
||||
<Pre>
|
||||
[Field] {path} has no schema ({JSON.stringify(schema)})
|
||||
</Pre>
|
||||
);
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} schema={schema} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
return <ArrayField path={name} schema={schema} />;
|
||||
}
|
||||
|
||||
const disabled = schema.readOnly ?? "const" in schema ?? false;
|
||||
|
||||
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = coerce(e.target.value, schema as any, { required });
|
||||
if (typeof value === "undefined" && !required && ctx.options?.keepEmpty !== true) {
|
||||
ctx.deleteValue(path);
|
||||
} else {
|
||||
setValue(path, value);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
|
||||
<FieldComponent
|
||||
schema={schema}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
onChange={onChange ?? handleChange}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pre = ({ children }) => (
|
||||
<pre className="dark:bg-red-950 bg-red-100 rounded-md px-3 py-1.5 text-wrap whitespace-break-spaces break-all">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
|
||||
export const FieldComponent = ({
|
||||
schema,
|
||||
..._props
|
||||
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => {
|
||||
const { value } = useFormValue(_props.name!, { strict: true });
|
||||
if (!isTypeSchema(schema)) return null;
|
||||
const props = {
|
||||
..._props,
|
||||
// allow override
|
||||
value: typeof _props.value !== "undefined" ? _props.value : value
|
||||
};
|
||||
|
||||
if (schema.enum) {
|
||||
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, ["number", "integer"])) {
|
||||
const additional = {
|
||||
min: schema.minimum,
|
||||
max: schema.maximum,
|
||||
step: schema.multipleOf
|
||||
};
|
||||
|
||||
return (
|
||||
<Formy.Input
|
||||
type="number"
|
||||
id={props.name}
|
||||
{...props}
|
||||
value={props.value ?? ""}
|
||||
{...additional}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isType(schema.type, "boolean")) {
|
||||
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "string") && schema.format === "date-time") {
|
||||
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
|
||||
return (
|
||||
<Formy.DateInput
|
||||
id={props.name}
|
||||
{...props}
|
||||
value={value}
|
||||
type="datetime-local"
|
||||
onChange={(e) => {
|
||||
const date = new Date(e.target.value);
|
||||
props.onChange?.({
|
||||
// @ts-ignore
|
||||
target: { value: date.toISOString() }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isType(schema.type, "string") && schema.format === "date") {
|
||||
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />;
|
||||
}
|
||||
|
||||
const additional = {
|
||||
maxLength: schema.maxLength,
|
||||
minLength: schema.minLength,
|
||||
pattern: schema.pattern
|
||||
} as any;
|
||||
|
||||
if (schema.format) {
|
||||
if (["password", "hidden", "url", "email", "tel"].includes(schema.format)) {
|
||||
additional.type = schema.format;
|
||||
}
|
||||
}
|
||||
|
||||
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />;
|
||||
};
|
||||
124
app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
Normal file
124
app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import {
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue
|
||||
} from "ui/components/form/json-schema-form/Form";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { getLabel } from "./utils";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
name: string;
|
||||
label?: string | false;
|
||||
required?: boolean;
|
||||
schema?: JsonSchema;
|
||||
debug?: object | boolean;
|
||||
wrapper?: "group" | "fieldset";
|
||||
hidden?: boolean;
|
||||
children: ReactElement | ReactNode;
|
||||
errorPlacement?: "top" | "bottom";
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
name,
|
||||
label: _label,
|
||||
required,
|
||||
schema,
|
||||
wrapper,
|
||||
hidden,
|
||||
errorPlacement = "bottom",
|
||||
children
|
||||
}: FieldwrapperProps) {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
const examples = schema?.examples || [];
|
||||
const examplesId = `${name}-examples`;
|
||||
const description = schema?.description;
|
||||
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
|
||||
|
||||
const Errors = errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
error={errors.length > 0}
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
>
|
||||
{errorPlacement === "top" && Errors}
|
||||
<FieldDebug name={name} schema={schema} required={required} />
|
||||
|
||||
{label && (
|
||||
<Formy.Label
|
||||
as={wrapper === "fieldset" ? "legend" : "label"}
|
||||
htmlFor={name}
|
||||
className="self-start"
|
||||
>
|
||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||
</Formy.Label>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Children.count(children) === 1 && isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
// @ts-ignore
|
||||
list: examples.length > 0 ? examplesId : undefined
|
||||
})
|
||||
: children}
|
||||
{examples.length > 0 && (
|
||||
<datalist id={examplesId}>
|
||||
{examples.map((e, i) => (
|
||||
<option key={i} value={e as any} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{description && <Formy.Help>{description}</Formy.Help>}
|
||||
{errorPlacement === "bottom" && Errors}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const FieldDebug = ({
|
||||
name,
|
||||
schema,
|
||||
required
|
||||
}: Pick<FieldwrapperProps, "name" | "schema" | "required">) => {
|
||||
const { options } = useFormContext();
|
||||
if (!options?.debug) return null;
|
||||
const { value } = useFormValue(name);
|
||||
const errors = useFormError(name, { strict: true });
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 right-0">
|
||||
<Popover
|
||||
overlayProps={{
|
||||
className: "max-w-none"
|
||||
}}
|
||||
position="bottom-end"
|
||||
target={({ toggle }) => (
|
||||
<JsonViewer
|
||||
className="bg-background pr-3 text-sm"
|
||||
json={{
|
||||
name,
|
||||
value,
|
||||
required,
|
||||
schema,
|
||||
errors
|
||||
}}
|
||||
expand={6}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
378
app/src/ui/components/form/json-schema-form/Form.tsx
Normal file
378
app/src/ui/components/form/json-schema-form/Form.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
type PrimitiveAtom,
|
||||
atom,
|
||||
getDefaultStore,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom
|
||||
} from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { Draft2019, type JsonError, type JsonSchema as LibJsonSchema } from "json-schema-library";
|
||||
import type { TemplateOptions as LibTemplateOptions } from "json-schema-library/dist/lib/getTemplate";
|
||||
import type { JSONSchema as $JSONSchema, FromSchema } from "json-schema-to-ts";
|
||||
import * as immutable from "object-path-immutable";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from "react";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Field } from "./Field";
|
||||
import {
|
||||
getPath,
|
||||
isEqual,
|
||||
isRequired,
|
||||
omitSchema,
|
||||
pathToPointer,
|
||||
prefixPath,
|
||||
prefixPointer
|
||||
} from "./utils";
|
||||
|
||||
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||
type FormState<Data = any> = {
|
||||
dirty: boolean;
|
||||
submitting: boolean;
|
||||
errors: JsonError[];
|
||||
data: Data;
|
||||
};
|
||||
|
||||
type FormOptions = {
|
||||
debug?: boolean;
|
||||
keepEmpty?: boolean;
|
||||
};
|
||||
|
||||
export type FormContext<Data> = {
|
||||
setData: (data: Data) => void;
|
||||
setValue: (pointer: string, value: any) => void;
|
||||
deleteValue: (pointer: string) => void;
|
||||
errors: JsonError[];
|
||||
dirty: boolean;
|
||||
submitting: boolean;
|
||||
schema: LibJsonSchema;
|
||||
lib: Draft2019;
|
||||
options: FormOptions;
|
||||
root: string;
|
||||
_formStateAtom: PrimitiveAtom<FormState<Data>>;
|
||||
};
|
||||
|
||||
const FormContext = createContext<FormContext<any>>(undefined!);
|
||||
FormContext.displayName = "FormContext";
|
||||
|
||||
export function Form<
|
||||
const Schema extends JSONSchema,
|
||||
const Data = Schema extends JSONSchema ? FromSchema<Schema> : any
|
||||
>({
|
||||
schema: _schema,
|
||||
initialValues: _initialValues,
|
||||
initialOpts,
|
||||
children,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onInvalidSubmit,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
ignoreKeys = [],
|
||||
options = {},
|
||||
...props
|
||||
}: Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
|
||||
schema: Schema;
|
||||
validateOn?: "change" | "submit";
|
||||
initialOpts?: LibTemplateOptions;
|
||||
ignoreKeys?: string[];
|
||||
onChange?: (data: Partial<Data>, name: string, value: any) => void;
|
||||
onSubmit?: (data: Data) => void | Promise<void>;
|
||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
options?: FormOptions;
|
||||
initialValues?: Schema extends JSONSchema ? FromSchema<Schema> : never;
|
||||
}) {
|
||||
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
|
||||
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
|
||||
const initialValues = initial ?? lib.getTemplate(undefined, schema, initialOpts);
|
||||
const _formStateAtom = useMemo(() => {
|
||||
return atom<FormState<Data>>({
|
||||
dirty: false,
|
||||
submitting: false,
|
||||
errors: [] as JsonError[],
|
||||
data: initialValues
|
||||
});
|
||||
}, [initialValues]);
|
||||
const setFormState = useSetAtom(_formStateAtom);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
validate();
|
||||
}
|
||||
}, [initialValues]);
|
||||
|
||||
// @ts-ignore
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
const { data, errors } = validate();
|
||||
if (onSubmit) {
|
||||
e.preventDefault();
|
||||
setFormState((prev) => ({ ...prev, submitting: true }));
|
||||
|
||||
try {
|
||||
if (errors.length === 0) {
|
||||
await onSubmit(data as Data);
|
||||
} else {
|
||||
console.log("invalid", errors);
|
||||
onInvalidSubmit?.(errors, data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
setFormState((prev) => ({ ...prev, submitting: false }));
|
||||
return false;
|
||||
} else if (errors.length > 0) {
|
||||
e.preventDefault();
|
||||
onInvalidSubmit?.(errors, data);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const setValue = useEvent((path: string, value: any) => {
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.set(prev, path, value);
|
||||
onChange?.(changed, path, value);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
check();
|
||||
});
|
||||
|
||||
const deleteValue = useEvent((path: string) => {
|
||||
setFormState((state) => {
|
||||
const prev = state.data;
|
||||
const changed = immutable.del(prev, path);
|
||||
onChange?.(changed, path, undefined);
|
||||
return { ...state, data: changed };
|
||||
});
|
||||
check();
|
||||
});
|
||||
|
||||
const getCurrentState = useEvent(() => getDefaultStore().get(_formStateAtom));
|
||||
|
||||
const check = useEvent(() => {
|
||||
const state = getCurrentState();
|
||||
setFormState((prev) => ({ ...prev, dirty: !isEqual(initialValues, state.data) }));
|
||||
|
||||
if (validateOn === "change") {
|
||||
validate();
|
||||
} else if (state?.errors?.length > 0) {
|
||||
validate();
|
||||
}
|
||||
});
|
||||
|
||||
const validate = useEvent((_data?: Partial<Data>) => {
|
||||
const actual = _data ?? getCurrentState()?.data;
|
||||
const errors = lib.validate(actual, schema);
|
||||
setFormState((prev) => ({ ...prev, errors }));
|
||||
return { data: actual, errors };
|
||||
});
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
_formStateAtom,
|
||||
setValue,
|
||||
deleteValue,
|
||||
schema,
|
||||
lib,
|
||||
options,
|
||||
root: "",
|
||||
path: ""
|
||||
}),
|
||||
[schema, initialValues]
|
||||
) as any;
|
||||
|
||||
return (
|
||||
<form {...props} ref={formRef} onSubmit={handleSubmit}>
|
||||
<FormContext.Provider value={context}>
|
||||
{children ? children : <Field name="" />}
|
||||
{options?.debug && <FormDebug />}
|
||||
</FormContext.Provider>
|
||||
{hiddenSubmit && (
|
||||
<button style={{ visibility: "hidden" }} type="submit">
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFormContext() {
|
||||
return useContext(FormContext);
|
||||
}
|
||||
|
||||
export function FormContextOverride({
|
||||
children,
|
||||
overrideData,
|
||||
prefix,
|
||||
...overrides
|
||||
}: Partial<FormContext<any>> & { children: ReactNode; prefix?: string; overrideData?: boolean }) {
|
||||
const ctx = useFormContext();
|
||||
const additional: Partial<FormContext<any>> = {};
|
||||
|
||||
// this makes a local schema down the three
|
||||
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
|
||||
if (prefix) {
|
||||
additional.root = prefix;
|
||||
additional.setValue = (pointer: string, value: any) => {
|
||||
ctx.setValue(prefixPointer(pointer, prefix), value);
|
||||
};
|
||||
additional.deleteValue = (pointer: string) => {
|
||||
ctx.deleteValue(prefixPointer(pointer, prefix));
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
...ctx,
|
||||
...overrides,
|
||||
...additional
|
||||
};
|
||||
|
||||
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFormValue(name: string, opts?: { strict?: boolean }) {
|
||||
const { _formStateAtom, root } = useFormContext();
|
||||
if ((typeof name !== "string" || name.length === 0) && opts?.strict === true)
|
||||
return { value: undefined, errors: [] };
|
||||
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const prefixedName = prefixPath(name, root);
|
||||
const pointer = pathToPointer(prefixedName);
|
||||
return {
|
||||
value: getPath(state.data, prefixedName),
|
||||
errors: state.errors.filter((error) => error.data.pointer.startsWith(pointer))
|
||||
};
|
||||
},
|
||||
[name]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
export function useFormError(name: string, opt?: { strict?: boolean; debug?: boolean }) {
|
||||
const { _formStateAtom, root } = useFormContext();
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const prefixedName = prefixPath(name, root);
|
||||
const pointer = pathToPointer(prefixedName);
|
||||
return state.errors.filter((error) => {
|
||||
return opt?.strict
|
||||
? error.data.pointer === pointer
|
||||
: error.data.pointer.startsWith(pointer);
|
||||
});
|
||||
},
|
||||
[name]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
export function useFormStateSelector<Data = any, Reduced = Data>(
|
||||
selector: (state: FormState<Data>) => Reduced
|
||||
): Reduced {
|
||||
const { _formStateAtom } = useFormContext();
|
||||
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
|
||||
return useAtom(selected)[0];
|
||||
}
|
||||
|
||||
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
||||
|
||||
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
||||
path,
|
||||
_schema?: LibJsonSchema,
|
||||
deriveFn?: SelectorFn<
|
||||
FormContext<Data> & {
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
path: string;
|
||||
},
|
||||
Reduced
|
||||
>
|
||||
): FormContext<Data> & {
|
||||
value: Reduced;
|
||||
pointer: string;
|
||||
required: boolean;
|
||||
path: string;
|
||||
} {
|
||||
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
|
||||
const schema = _schema ?? ctx.schema;
|
||||
const selected = selectAtom(
|
||||
_formStateAtom,
|
||||
useCallback(
|
||||
(state) => {
|
||||
const pointer = pathToPointer(path);
|
||||
const prefixedName = prefixPath(path, root);
|
||||
const prefixedPointer = pathToPointer(prefixedName);
|
||||
const value = getPath(state.data, prefixedName);
|
||||
/*const errors = state.errors.filter((error) =>
|
||||
error.data.pointer.startsWith(prefixedPointer)
|
||||
);*/
|
||||
const fieldSchema =
|
||||
pointer === "#/"
|
||||
? (schema as LibJsonSchema)
|
||||
: lib.getSchema({ pointer, data: value, schema });
|
||||
const required = isRequired(lib, prefixedPointer, schema, state.data);
|
||||
|
||||
const context = {
|
||||
...ctx,
|
||||
path: prefixedName,
|
||||
root,
|
||||
schema: fieldSchema as LibJsonSchema,
|
||||
pointer,
|
||||
required
|
||||
};
|
||||
const derived = deriveFn?.({ ...context, _formStateAtom, lib, value });
|
||||
|
||||
return {
|
||||
...context,
|
||||
value: derived
|
||||
};
|
||||
},
|
||||
[path, schema ?? {}, root]
|
||||
),
|
||||
isEqual
|
||||
);
|
||||
return {
|
||||
...useAtomValue(selected),
|
||||
_formStateAtom,
|
||||
lib
|
||||
} as any;
|
||||
}
|
||||
|
||||
export function Subscribe<Data = any, Refined = Data>({
|
||||
children,
|
||||
selector
|
||||
}: {
|
||||
children: (state: Refined) => ReactNode;
|
||||
selector?: SelectorFn<FormState<Data>, Refined>;
|
||||
}) {
|
||||
return children(useFormStateSelector(selector ?? ((state) => state as unknown as Refined)));
|
||||
}
|
||||
|
||||
export function FormDebug({ force = false }: { force?: boolean }) {
|
||||
const { options } = useFormContext();
|
||||
if (options?.debug !== true && force !== true) return null;
|
||||
const ctx = useFormStateSelector((s) => s);
|
||||
|
||||
return <JsonViewer json={ctx} expand={99} />;
|
||||
}
|
||||
47
app/src/ui/components/form/json-schema-form/ObjectField.tsx
Normal file
47
app/src/ui/components/form/json-schema-form/ObjectField.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext } from "./Form";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
schema?: Exclude<JSONSchema, boolean>;
|
||||
label?: string | false;
|
||||
wrapperProps?: Partial<FieldwrapperProps>;
|
||||
};
|
||||
|
||||
export const ObjectField = ({
|
||||
path = "",
|
||||
schema: _schema,
|
||||
label: _label,
|
||||
wrapperProps = {}
|
||||
}: ObjectFieldProps) => {
|
||||
const ctx = useDerivedFieldContext(path, _schema);
|
||||
const schema = _schema ?? ctx.schema;
|
||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
name={path}
|
||||
schema={{ ...schema, description: undefined }}
|
||||
wrapper="fieldset"
|
||||
errorPlacement="top"
|
||||
{...wrapperProps}
|
||||
>
|
||||
{Object.keys(properties).map((prop) => {
|
||||
const schema = properties[prop];
|
||||
const name = [path, prop].filter(Boolean).join(".");
|
||||
if (typeof schema === "undefined" || typeof schema === "boolean") return;
|
||||
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
return <AnyOfField key={name} path={name} />;
|
||||
}
|
||||
|
||||
return <Field key={name} name={name} />;
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
6
app/src/ui/components/form/json-schema-form/index.ts
Normal file
6
app/src/ui/components/form/json-schema-form/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./Field";
|
||||
export * from "./Form";
|
||||
export * from "./ObjectField";
|
||||
export * from "./ArrayField";
|
||||
export * from "./AnyOfField";
|
||||
export * from "./FieldWrapper";
|
||||
140
app/src/ui/components/form/json-schema-form/utils.ts
Normal file
140
app/src/ui/components/form/json-schema-form/utils.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { autoFormatString, omitKeys } from "core/utils";
|
||||
import { type Draft, Draft2019, type JsonSchema } from "json-schema-library";
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema";
|
||||
|
||||
export { isEqual, getPath } from "core/utils/objects";
|
||||
//export { isEqual } from "lodash-es";
|
||||
|
||||
export function coerce(value: any, schema: JsonSchema, opts?: { required?: boolean }) {
|
||||
if (!value && typeof opts?.required === "boolean" && !opts.required) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return String(value);
|
||||
case "integer":
|
||||
case "number":
|
||||
return Number(value);
|
||||
case "boolean":
|
||||
return ["true", "1", 1, "on", true].includes(value);
|
||||
case "null":
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const PathFilter = (value: any) => typeof value !== "undefined" && value !== null && value !== "";
|
||||
|
||||
export function pathToPointer(path: string) {
|
||||
const p = path.includes(".") ? path.split(".") : [path];
|
||||
return (
|
||||
"#" +
|
||||
p
|
||||
.filter(PathFilter)
|
||||
.map((part) => "/" + part)
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
export function prefixPointer(pointer: string, prefix: string) {
|
||||
const p = pointer.replace("#", "").split("/");
|
||||
return "#" + p.map((part, i) => (i === 1 ? prefix : part)).join("/");
|
||||
}
|
||||
|
||||
export function prefixPath(path: string = "", prefix: string | number = "") {
|
||||
const p = path.includes(".") ? path.split(".") : [path];
|
||||
return [prefix, ...p].filter(PathFilter).join(".");
|
||||
}
|
||||
|
||||
export function suffixPath(path: string = "", suffix: string | number = "") {
|
||||
const p = path.includes(".") ? path.split(".") : [path];
|
||||
return [...p, suffix].filter(PathFilter).join(".");
|
||||
}
|
||||
|
||||
export function getParentPointer(pointer: string) {
|
||||
return pointer.substring(0, pointer.lastIndexOf("/"));
|
||||
}
|
||||
|
||||
export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) {
|
||||
if (pointer === "#/" || !schema) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
|
||||
return !!required;
|
||||
}
|
||||
|
||||
export type IsTypeType =
|
||||
| JSONSchemaType
|
||||
| JSONSchemaType[]
|
||||
| readonly JSONSchemaType[]
|
||||
| string
|
||||
| undefined;
|
||||
export function isType(type: IsTypeType, compare: IsTypeType) {
|
||||
if (!type || !compare) return false;
|
||||
const _type = Array.isArray(type) ? type : [type];
|
||||
const _compare = Array.isArray(compare) ? compare : [compare];
|
||||
return _compare.some((t) => _type.includes(t));
|
||||
}
|
||||
|
||||
export function getLabel(name: string, schema: JsonSchema) {
|
||||
if (typeof schema === "object" && "title" in schema) return schema.title;
|
||||
if (!name) return "";
|
||||
const label = name.includes(".") ? (name.split(".").pop() ?? "") : name;
|
||||
return autoFormatString(label);
|
||||
}
|
||||
|
||||
export function getMultiSchema(schema: JsonSchema): JsonSchema[] | undefined {
|
||||
if (!schema || typeof schema !== "object") return;
|
||||
return (schema.anyOf ?? schema.oneOf) as any;
|
||||
}
|
||||
|
||||
export function getMultiSchemaMatched(
|
||||
schema: JsonSchema,
|
||||
data: any
|
||||
): [number, JsonSchema[], JsonSchema | undefined] {
|
||||
const multiSchema = getMultiSchema(schema);
|
||||
//console.log("getMultiSchemaMatched", schema, data, multiSchema);
|
||||
if (!multiSchema) return [-1, [], undefined];
|
||||
const index = multiSchema.findIndex((subschema) => {
|
||||
const lib = new Draft2019(subschema as any);
|
||||
return lib.validate(data, subschema).length === 0;
|
||||
});
|
||||
if (index === -1) return [-1, multiSchema, undefined];
|
||||
|
||||
return [index, multiSchema, multiSchema[index]];
|
||||
}
|
||||
|
||||
export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: string[], _data?: any) {
|
||||
if (typeof _schema !== "object" || !("properties" in _schema) || keys.length === 0)
|
||||
return [_schema, _data];
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
const data = _data ? JSON.parse(JSON.stringify(_data)) : undefined;
|
||||
|
||||
const updated = {
|
||||
...schema,
|
||||
properties: omitKeys(schema.properties, keys)
|
||||
};
|
||||
if (updated.required) {
|
||||
updated.required = updated.required.filter((key) => !keys.includes(key as any));
|
||||
}
|
||||
|
||||
const reducedConfig = omitKeys(data, keys) as any;
|
||||
|
||||
return [updated, reducedConfig];
|
||||
}
|
||||
|
||||
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
|
||||
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
|
||||
}
|
||||
215
app/src/ui/components/form/native-form/NativeForm.tsx
Normal file
215
app/src/ui/components/form/native-form/NativeForm.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import {
|
||||
type CleanOptions,
|
||||
type InputElement,
|
||||
cleanObject,
|
||||
coerce,
|
||||
getFormTarget,
|
||||
getTargetsByName,
|
||||
setPath
|
||||
} from "./utils";
|
||||
|
||||
export type NativeFormProps = {
|
||||
hiddenSubmit?: boolean;
|
||||
validateOn?: "change" | "submit";
|
||||
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
|
||||
reportValidity?: boolean;
|
||||
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
|
||||
onSubmitInvalid?: (
|
||||
errors: InputError[],
|
||||
ctx: { event: FormEvent<HTMLFormElement> }
|
||||
) => Promise<void> | void;
|
||||
onError?: (errors: InputError[]) => void;
|
||||
onChange?: (
|
||||
data: any,
|
||||
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }
|
||||
) => Promise<void> | void;
|
||||
clean?: CleanOptions | true;
|
||||
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
|
||||
|
||||
export type InputError = {
|
||||
name: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function NativeForm({
|
||||
children,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = false,
|
||||
errorFieldSelector,
|
||||
reportValidity,
|
||||
onSubmit,
|
||||
onSubmitInvalid,
|
||||
onError,
|
||||
clean,
|
||||
...props
|
||||
}: NativeFormProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [errors, setErrors] = useState<InputError[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formRef.current || props.noValidate) return;
|
||||
validate();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formRef.current || props.noValidate) return;
|
||||
|
||||
// find submit buttons and disable them if there are errors
|
||||
const invalid = errors.length > 0;
|
||||
formRef.current.querySelectorAll("[type=submit]").forEach((submit) => {
|
||||
if (!submit || !("type" in submit) || submit.type !== "submit") return;
|
||||
// @ts-ignore
|
||||
submit.disabled = invalid;
|
||||
});
|
||||
|
||||
onError?.(errors);
|
||||
}, [errors]);
|
||||
|
||||
const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => {
|
||||
if (props.noValidate || !el || !("name" in el)) return;
|
||||
const errorElement = formRef.current?.querySelector(
|
||||
errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`
|
||||
);
|
||||
|
||||
if (!el.checkValidity()) {
|
||||
const error = {
|
||||
name: el.name,
|
||||
message: el.validationMessage
|
||||
};
|
||||
|
||||
setErrors((prev) => [...prev.filter((e) => e.name !== el.name), error]);
|
||||
if (opts?.report) {
|
||||
if (errorElement) {
|
||||
errorElement.textContent = error.message;
|
||||
} else if (reportValidity) {
|
||||
el.reportValidity();
|
||||
}
|
||||
}
|
||||
|
||||
return error;
|
||||
} else {
|
||||
setErrors((prev) => prev.filter((e) => e.name !== el.name));
|
||||
if (errorElement) {
|
||||
errorElement.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
const validate = useEvent((opts?: { report?: boolean }) => {
|
||||
if (!formRef.current || props.noValidate) return [];
|
||||
|
||||
const errors: InputError[] = [];
|
||||
formRef.current.querySelectorAll("input, select, textarea").forEach((e) => {
|
||||
const el = e as InputElement | null;
|
||||
const error = validateElement(el, opts);
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
});
|
||||
|
||||
const getFormValues = useEvent(() => {
|
||||
if (!formRef.current) return {};
|
||||
|
||||
const formData = new FormData(formRef.current);
|
||||
const obj: any = {};
|
||||
formData.forEach((value, key) => {
|
||||
const targets = getTargetsByName(formRef.current!, key);
|
||||
if (targets.length === 0) {
|
||||
console.warn(`No target found for key: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = targets.length;
|
||||
const multiple = count > 1;
|
||||
targets.forEach((target, index) => {
|
||||
let _key = key;
|
||||
|
||||
if (multiple) {
|
||||
_key = `${key}[${index}]`;
|
||||
}
|
||||
|
||||
setPath(obj, _key, coerce(target, target.value));
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof clean === "undefined") return obj;
|
||||
return cleanObject(obj, clean === true ? undefined : clean);
|
||||
});
|
||||
|
||||
const handleChange = useEvent(async (e: ChangeEvent<HTMLFormElement>) => {
|
||||
const form = formRef.current;
|
||||
if (!form) return;
|
||||
const target = getFormTarget(e);
|
||||
if (!target) return;
|
||||
|
||||
if (validateOn === "change") {
|
||||
validateElement(target, { report: true });
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
await props.onChange(getFormValues(), {
|
||||
event: e,
|
||||
key: target.name,
|
||||
value: target.value,
|
||||
errors
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = useEvent(async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = formRef.current;
|
||||
if (!form) return;
|
||||
|
||||
const errors = validate({ report: true });
|
||||
if (errors.length > 0) {
|
||||
onSubmitInvalid?.(errors, { event: e });
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSubmit) {
|
||||
await onSubmit(getFormValues(), { event: e });
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDown = useEvent((e: KeyboardEvent) => {
|
||||
if (!formRef.current) return;
|
||||
|
||||
// if is enter key, submit is disabled, report errors
|
||||
if (e.keyCode === 13) {
|
||||
const invalid = errors.length > 0;
|
||||
if (invalid && !props.noValidate && reportValidity) {
|
||||
formRef.current.reportValidity();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
>
|
||||
{children}
|
||||
{hiddenSubmit && <input type="submit" style={{ visibility: "hidden" }} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
137
app/src/ui/components/form/native-form/utils.ts
Normal file
137
app/src/ui/components/form/native-form/utils.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
export type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
|
||||
export function ignoreTarget(target: InputElement | Element | null, form?: HTMLFormElement) {
|
||||
const tagName = target?.tagName.toLowerCase() ?? "";
|
||||
const tagNames = ["input", "select", "textarea"];
|
||||
|
||||
return (
|
||||
!target ||
|
||||
!form?.contains(target) ||
|
||||
!tagNames.includes(tagName) ||
|
||||
!("name" in target) ||
|
||||
target.hasAttribute("data-ignore") ||
|
||||
target.closest("[data-ignore]")
|
||||
);
|
||||
}
|
||||
|
||||
export function getFormTarget(e: FormEvent<HTMLFormElement>): InputElement | null {
|
||||
const form = e.currentTarget;
|
||||
const target = e.target as InputElement | null;
|
||||
|
||||
return ignoreTarget(target, form) ? null : target;
|
||||
}
|
||||
|
||||
export function getTargetsByName(form: HTMLFormElement, name: string): InputElement[] {
|
||||
const query = form.querySelectorAll(`[name="${name}"]`);
|
||||
return Array.from(query).filter((e) => ignoreTarget(e)) as InputElement[];
|
||||
}
|
||||
|
||||
export function coerce(target: InputElement | null, value?: any) {
|
||||
if (!target) return value;
|
||||
const required = target.required;
|
||||
if (!value && !required) return undefined;
|
||||
|
||||
if (target.type === "number") {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num) && !required) return undefined;
|
||||
|
||||
const min = "min" in target && target.min.length > 0 ? Number(target.min) : undefined;
|
||||
const max = "max" in target && target.max.length > 0 ? Number(target.max) : undefined;
|
||||
const step = "step" in target && target.step.length > 0 ? Number(target.step) : undefined;
|
||||
|
||||
if (min && num < min) return min;
|
||||
if (max && num > max) return max;
|
||||
if (step && step !== 1) return Math.round(num / step) * step;
|
||||
|
||||
return num;
|
||||
} else if (target.type === "text") {
|
||||
const maxLength =
|
||||
"maxLength" in target && target.maxLength > -1 ? Number(target.maxLength) : undefined;
|
||||
const pattern = "pattern" in target ? new RegExp(target.pattern) : undefined;
|
||||
|
||||
if (maxLength && value.length > maxLength) return value.slice(0, maxLength);
|
||||
if (pattern && !pattern.test(value)) return "";
|
||||
|
||||
return value;
|
||||
} else if (target.type === "checkbox") {
|
||||
if ("checked" in target) return !!target.checked;
|
||||
return ["on", "1", "true", 1, true].includes(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export type CleanOptions = {
|
||||
empty?: any[];
|
||||
emptyInArray?: any[];
|
||||
keepEmptyArray?: boolean;
|
||||
};
|
||||
export function cleanObject<Obj extends { [key: string]: any }>(
|
||||
obj: Obj,
|
||||
_opts?: CleanOptions
|
||||
): Obj {
|
||||
if (!obj) return obj;
|
||||
const _empty = [null, undefined, ""];
|
||||
const opts = {
|
||||
empty: _opts?.empty ?? _empty,
|
||||
emptyInArray: _opts?.emptyInArray ?? _empty,
|
||||
keepEmptyArray: _opts?.keepEmptyArray ?? false
|
||||
};
|
||||
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
|
||||
const nested = value.map((o) => cleanObject(o, opts));
|
||||
if (nested.length > 0 || opts?.keepEmptyArray) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const nested = cleanObject(value, opts);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
const nested = value.filter((v) => !opts.emptyInArray.includes(v));
|
||||
if (nested.length > 0 || opts?.keepEmptyArray) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (!opts.empty.includes(value)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
export function setPath(object, _path, value) {
|
||||
let path = _path;
|
||||
|
||||
// Optional string-path support.
|
||||
// You can remove this `if` block if you don't need it.
|
||||
if (typeof path === "string") {
|
||||
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
|
||||
path = path
|
||||
.split(/[.\[\]]+/)
|
||||
.filter((x) => x)
|
||||
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
|
||||
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
|
||||
}
|
||||
|
||||
if (path.length === 0) {
|
||||
throw new Error("The path must have at least one entry in it");
|
||||
}
|
||||
|
||||
const [head, ...tail] = path;
|
||||
|
||||
if (tail.length === 0) {
|
||||
object[head] = value;
|
||||
return object;
|
||||
}
|
||||
|
||||
if (!(head in object)) {
|
||||
object[head] = typeof tail[0] === "number" ? [] : {};
|
||||
}
|
||||
|
||||
setPath(object[head], tail, value);
|
||||
return object;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Modal, type ModalProps, Popover } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { ScrollArea } from "radix-ui";
|
||||
import { Fragment, forwardRef, useImperativeHandle } from "react";
|
||||
import { TbX } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import { type ReactElement, cloneElement, useState } from "react";
|
||||
import { type ComponentPropsWithoutRef, type ReactElement, cloneElement, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export type PopoverProps = {
|
||||
className?: string;
|
||||
@@ -10,6 +10,7 @@ export type PopoverProps = {
|
||||
backdrop?: boolean;
|
||||
target: (props: { toggle: () => void }) => ReactElement;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
overlayProps?: ComponentPropsWithoutRef<"div">;
|
||||
};
|
||||
|
||||
export function Popover({
|
||||
@@ -18,20 +19,21 @@ export function Popover({
|
||||
defaultOpen = false,
|
||||
backdrop = false,
|
||||
position = "bottom-start",
|
||||
className,
|
||||
overlayProps,
|
||||
className
|
||||
}: PopoverProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0),
|
||||
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
||||
);
|
||||
|
||||
const pos = {
|
||||
"bottom-start": "mt-1 top-[100%]",
|
||||
"bottom-end": "right-0 top-[100%] mt-1",
|
||||
"top-start": "bottom-[100%] mb-1",
|
||||
"top-end": "bottom-[100%] right-0 mb-1",
|
||||
"top-end": "bottom-[100%] right-0 mb-1"
|
||||
}[position];
|
||||
|
||||
return (
|
||||
@@ -43,9 +45,11 @@ export function Popover({
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
{open && (
|
||||
<div
|
||||
{...overlayProps}
|
||||
className={twMerge(
|
||||
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full max-w-20 backdrop-blur-sm",
|
||||
"animate-fade-in absolute z-20 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg backdrop-blur-sm min-w-0 max-w-20",
|
||||
pos,
|
||||
overlayProps?.className
|
||||
)}
|
||||
>
|
||||
{target({ toggle })}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as ReactScrollArea from "@radix-ui/react-scroll-area";
|
||||
|
||||
export const ScrollArea = ({ children, className }: any) => (
|
||||
<ReactScrollArea.Root className={`${className} `}>
|
||||
<ReactScrollArea.Viewport className="w-full h-full ">
|
||||
{children}
|
||||
</ReactScrollArea.Viewport>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Scrollbar
|
||||
className="ScrollAreaScrollbar"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ReactScrollArea.Thumb className="ScrollAreaThumb" />
|
||||
</ReactScrollArea.Scrollbar>
|
||||
<ReactScrollArea.Corner className="ScrollAreaCorner" />
|
||||
</ReactScrollArea.Root>
|
||||
);
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type ElementType,
|
||||
type ForwardedRef,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
forwardRef
|
||||
} from "react";
|
||||
|
||||
export function extend<ComponentType extends ElementType, AdditionalProps = {}>(
|
||||
Component: ComponentType,
|
||||
applyAdditionalProps?: (
|
||||
props: PropsWithChildren<ComponentPropsWithoutRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
}
|
||||
) => ComponentProps<ComponentType>
|
||||
) {
|
||||
return forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithoutRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
// Initialize newProps with a default empty object or the result of applyAdditionalProps
|
||||
let newProps: ComponentProps<ComponentType> & AdditionalProps = applyAdditionalProps
|
||||
? applyAdditionalProps(props as any)
|
||||
: (props as any);
|
||||
|
||||
// Append className if it exists in both props and newProps
|
||||
if (props.className && newProps.className) {
|
||||
newProps = {
|
||||
...newProps,
|
||||
className: `${props.className} ${newProps.className}`
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error haven't figured out the correct typing
|
||||
return <Component {...newProps} ref={ref} />;
|
||||
});
|
||||
}
|
||||
|
||||
type RenderFunction<ComponentType extends React.ElementType, AdditionalProps = {}> = (
|
||||
props: PropsWithChildren<ComponentPropsWithRef<ComponentType> & AdditionalProps> & {
|
||||
className?: string;
|
||||
},
|
||||
ref: ForwardedRef<ElementRef<ComponentType>>
|
||||
) => ReactElement;
|
||||
|
||||
export function extendComponent<ComponentType extends React.ElementType, AdditionalProps = {}>(
|
||||
renderFunction: RenderFunction<ComponentType, AdditionalProps>
|
||||
) {
|
||||
// The extended component using forwardRef to forward the ref to the custom component
|
||||
const ExtendedComponent = forwardRef<
|
||||
ElementRef<ComponentType>,
|
||||
ComponentPropsWithRef<ComponentType> & AdditionalProps
|
||||
>((props, ref) => {
|
||||
return renderFunction(props as any, ref);
|
||||
});
|
||||
|
||||
return ExtendedComponent;
|
||||
}
|
||||
|
||||
/*
|
||||
export const Content = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Content>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Content>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Content
|
||||
className={`flex flex-col ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
|
||||
export const Item = forwardRef<
|
||||
ElementRef<typeof DropdownMenu.Item>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenu.Item>
|
||||
>(({ className, ...props }, forwardedRef) => (
|
||||
<DropdownMenu.Item
|
||||
className={`flex flex-row flex-nowrap ${className}`}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
/>
|
||||
));
|
||||
*/
|
||||
@@ -1,14 +1,23 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { type TSchema, Type, Value } from "core/utils";
|
||||
import { Form, type Validator } from "json-schema-form-react";
|
||||
import { Type } from "core/utils";
|
||||
import { Form } from "json-schema-form-react";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import { type TSchema, Value } from "core/utils";
|
||||
import type { Validator } from "json-schema-form-react";
|
||||
|
||||
class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
className?: string;
|
||||
formData?: any;
|
||||
@@ -18,14 +27,7 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||
}
|
||||
}
|
||||
|
||||
const validator = new TypeboxValidator();
|
||||
|
||||
const schema = Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||
@@ -82,7 +84,7 @@ export function AuthForm({
|
||||
<Form
|
||||
method={method}
|
||||
action={password.action}
|
||||
{...props}
|
||||
{...(props as any)}
|
||||
schema={schema}
|
||||
validator={validator}
|
||||
validationMode="change"
|
||||
|
||||
@@ -22,9 +22,17 @@ export function SocialLink({
|
||||
basepath = "/api/auth",
|
||||
children
|
||||
}: SocialLinkProps) {
|
||||
const url = [basepath, provider, action].join("/");
|
||||
|
||||
return (
|
||||
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
|
||||
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
|
||||
<form method={method} action={url} className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="outline"
|
||||
className="justify-center w-full"
|
||||
IconLeft={icon}
|
||||
>
|
||||
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
|
||||
</Button>
|
||||
{children}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth";
|
||||
export * from "./media";
|
||||
export * from "../components/form/native-form/NativeForm";
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||
export * from "./components/form/json-schema-form";
|
||||
export { JsonViewer } from "./components/code/JsonViewer";
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
import { throttle } from "lodash-es";
|
||||
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "radix-ui";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithoutRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
export function Root({ children }) {
|
||||
return (
|
||||
@@ -68,8 +74,15 @@ export function Content({ children, center }: { children: React.ReactNode; cente
|
||||
}
|
||||
|
||||
export function Main({ children }) {
|
||||
const { sidebar } = useAppShell();
|
||||
return (
|
||||
<div data-shell="main" className="flex flex-col flex-grow w-1">
|
||||
<div
|
||||
data-shell="main"
|
||||
className={twMerge(
|
||||
"flex flex-col flex-grow w-1 flex-shrink-1",
|
||||
sidebar.open && "max-w-[calc(100%-350px)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -292,7 +305,7 @@ export function Scrollable({
|
||||
|
||||
return (
|
||||
<ScrollArea.Root style={{ height: `calc(100dvh - ${offset}px` }} ref={scrollRef}>
|
||||
<ScrollArea.Viewport className="w-full h-full ">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Viewport className="w-full h-full">{children}</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
forceMount
|
||||
className="flex select-none touch-none bg-transparent w-0.5"
|
||||
@@ -357,4 +370,8 @@ export const SectionHeaderAccordionItem = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
||||
);
|
||||
|
||||
export { Header } from "./Header";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useAuth, useBkndWindowContext } from "ui/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
@@ -72,18 +73,15 @@ export function HeaderNavigation() {
|
||||
<>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{items.map((item) => (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={item.tooltip}
|
||||
disabled={typeof item.tooltip === "undefined"}
|
||||
position="bottom"
|
||||
<NavLink
|
||||
key={item.href}
|
||||
as={Link}
|
||||
href={item.href}
|
||||
Icon={item.Icon}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div>
|
||||
<NavLink as={Link} href={item.href} Icon={item.Icon} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex md:hidden flex-row items-center">
|
||||
@@ -114,9 +112,9 @@ function SidebarToggler() {
|
||||
}
|
||||
|
||||
export function Header({ hasSidebar = true }) {
|
||||
//const logoReturnPath = "";
|
||||
const { app } = useBknd();
|
||||
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
|
||||
const { theme } = useTheme();
|
||||
const { logo_return_path = "/" } = app.getAdminConfig();
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -128,7 +126,7 @@ export function Header({ hasSidebar = true }) {
|
||||
native={logo_return_path !== "/"}
|
||||
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
|
||||
>
|
||||
<Logo theme={color_scheme} />
|
||||
<Logo theme={theme} />
|
||||
</Link>
|
||||
<HeaderNavigation />
|
||||
<div className="flex flex-grow" />
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
#bknd-admin.dark,
|
||||
.dark .bknd-admin {
|
||||
--color-primary: 250 250 250; /* zinc-50 */
|
||||
--color-background: 30 31 34;
|
||||
--color-muted: 47 47 52;
|
||||
--color-darkest: 255 255 255; /* white */
|
||||
--color-lightest: 24 24 27; /* black */
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
.bknd-admin {
|
||||
--color-primary: 9 9 11; /* zinc-950 */
|
||||
--color-background: 250 250 250; /* zinc-50 */
|
||||
--color-muted: 228 228 231; /* ? */
|
||||
--color-darkest: 0 0 0; /* black */
|
||||
--color-lightest: 255 255 255; /* white */
|
||||
|
||||
@mixin light {
|
||||
--mantine-color-body: rgb(250 250 250);
|
||||
}
|
||||
@mixin dark {
|
||||
--mantine-color-body: rgb(9 9 11);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
#bknd-admin {
|
||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DataEntityList({ params }) {
|
||||
useBrowserTitle(["Data", entity?.label ?? params.entity]);
|
||||
const [navigate] = useNavigate();
|
||||
const search = useSearch(searchSchema, {
|
||||
select: entity.getSelect(undefined, "form"),
|
||||
select: entity.getSelect(undefined, "table"),
|
||||
sort: entity.getDefaultSort()
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
import AuthRoutes from "./auth";
|
||||
import { AuthLogin } from "./auth/auth.login";
|
||||
@@ -20,11 +21,11 @@ const TestRoutes = lazy(() => import("./test"));
|
||||
|
||||
export function Routes() {
|
||||
const { app } = useBknd();
|
||||
const { color_scheme: theme } = app.getAdminConfig();
|
||||
const { theme } = useTheme();
|
||||
const { basepath } = app.getAdminConfig();
|
||||
|
||||
return (
|
||||
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
|
||||
<div id="bknd-admin" className={theme + " antialiased"}>
|
||||
<Router base={basepath}>
|
||||
<Switch>
|
||||
<Route path="/auth/login" component={AuthLogin} />
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||
import { TbSettings } from "react-icons/tb";
|
||||
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 { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export function MediaRoot({ children }) {
|
||||
const { app, config } = useBknd();
|
||||
const [, navigate] = useLocation();
|
||||
const mediaDisabled = !config.media.enabled;
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
if (!config.media.enabled) {
|
||||
return (
|
||||
<Empty
|
||||
Icon={IconPhoto}
|
||||
title="Media not enabled"
|
||||
description="Please enable media in the settings to continue."
|
||||
primary={{
|
||||
children: "Manage Settings",
|
||||
onClick: () => navigate(app.getSettingsPath(["media"]))
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell.Sidebar>
|
||||
@@ -42,31 +26,22 @@ export function MediaRoot({ children }) {
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable initialOffset={96}>
|
||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
||||
{/*<div>
|
||||
<SearchInput placeholder="Search buckets" />
|
||||
</div>*/}
|
||||
<nav className="flex flex-col flex-1 gap-1">
|
||||
<AppShell.SidebarLink as={Link} href="/media" className="active">
|
||||
Main Bucket
|
||||
<AppShell.SidebarLink
|
||||
as={Link}
|
||||
href={"/"}
|
||||
className="flex flex-row justify-between"
|
||||
>
|
||||
Main Bucket {mediaDisabled && <IconAlertHexagon className="size-5" />}
|
||||
</AppShell.SidebarLink>
|
||||
<AppShell.SidebarLink as={Link} href={"/settings"}>
|
||||
Settings
|
||||
</AppShell.SidebarLink>
|
||||
</nav>
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</AppShell.Sidebar>
|
||||
<main className="flex flex-col flex-grow">{children}</main>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// @todo: add infinite load
|
||||
export function MediaEmpty() {
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Route } from "wouter";
|
||||
import { MediaEmpty, MediaRoot } from "./_media.root";
|
||||
import { MediaRoot } from "./_media.root";
|
||||
import { MediaIndex } from "./media.index";
|
||||
import { MediaSettings } from "./media.settings";
|
||||
|
||||
export default function MediaRoutes() {
|
||||
return (
|
||||
<MediaRoot>
|
||||
<Route path="/" component={MediaEmpty} />
|
||||
<Route path="/" component={MediaIndex} />
|
||||
<Route path="/settings" component={MediaSettings} />
|
||||
</MediaRoot>
|
||||
);
|
||||
}
|
||||
|
||||
35
app/src/ui/routes/media/media.index.tsx
Normal file
35
app/src/ui/routes/media/media.index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export function MediaIndex() {
|
||||
const { app, config } = useBknd();
|
||||
const [, navigate] = useLocation();
|
||||
useBrowserTitle(["Media"]);
|
||||
|
||||
if (!config.media.enabled) {
|
||||
return (
|
||||
<Empty
|
||||
Icon={IconPhoto}
|
||||
title="Media not enabled"
|
||||
description="Please enable media in the settings to continue."
|
||||
primary={{
|
||||
children: "Manage Settings",
|
||||
onClick: () => navigate("/settings")
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
}
|
||||
179
app/src/ui/routes/media/media.settings.tsx
Normal file
179
app/src/ui/routes/media/media.settings.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react";
|
||||
import { isDebug } from "core";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import {
|
||||
AnyOf,
|
||||
Field,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectField,
|
||||
Subscribe,
|
||||
useFormError
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
export function MediaSettings(props) {
|
||||
useBrowserTitle(["Media", "Settings"]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Media Settings" />;
|
||||
}
|
||||
|
||||
return <MediaSettingsInternal {...props} />;
|
||||
}
|
||||
|
||||
const formConfig = {
|
||||
ignoreKeys: ["entity_name", "basepath"],
|
||||
options: { debug: isDebug(), keepEmpty: true }
|
||||
};
|
||||
|
||||
function MediaSettingsInternal() {
|
||||
const { config, schema: _schema, actions } = useBkndMedia();
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
|
||||
schema.if = { properties: { enabled: { const: true } } };
|
||||
// biome-ignore lint/suspicious/noThenProperty: <explanation>
|
||||
schema.then = { required: ["adapter"] };
|
||||
|
||||
async function onSubmit(data: any) {
|
||||
console.log("submit", data);
|
||||
await actions.config.patch(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
errors: state.errors.length > 0,
|
||||
submitting: state.submitting
|
||||
})}
|
||||
>
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Settings
|
||||
</AppShell.SectionHeader>
|
||||
)}
|
||||
</Subscribe>
|
||||
<AppShell.Scrollable>
|
||||
<RootFormError />
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<Field name="enabled" />
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
<Overlay />
|
||||
<Field name="storage.body_max_size" label="Storage Body Max Size" />
|
||||
</div>
|
||||
</div>
|
||||
<AppShell.Separator />
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<Overlay />
|
||||
<AnyOf.Root path="adapter">
|
||||
<Adapters />
|
||||
</AnyOf.Root>
|
||||
</div>
|
||||
<FormDebug />
|
||||
</AppShell.Scrollable>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const RootFormError = () => {
|
||||
const errors = useFormError("", { strict: true });
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Alert.Exception>
|
||||
{errors.map((error, i) => (
|
||||
<div key={i}>{error.message}</div>
|
||||
))}
|
||||
</Alert.Exception>
|
||||
);
|
||||
};
|
||||
|
||||
const Icons = [IconBrandAws, IconCloud, IconServer];
|
||||
|
||||
const AdapterIcon = ({ index }: { index: number }) => {
|
||||
const Icon = Icons[index];
|
||||
if (!Icon) return null;
|
||||
return <Icon />;
|
||||
};
|
||||
|
||||
function Adapters() {
|
||||
const ctx = AnyOf.useContext();
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Label className="flex flex-row items-center gap-1">
|
||||
<span className="font-bold">Media Adapter:</span>
|
||||
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
|
||||
</Formy.Label>
|
||||
<div className="flex flex-row gap-1 mb-2">
|
||||
{ctx.schemas?.map((schema: any, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
onClick={() => ctx.select(i)}
|
||||
variant={ctx.selected === i ? "primary" : "outline"}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center justify-center gap-3 border",
|
||||
ctx.selected === i && "border-primary"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<AdapterIcon index={i} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center">
|
||||
<span>{autoFormatString(schema.title)}</span>
|
||||
{schema.description && (
|
||||
<span className="text-xs opacity-70 text-left">{schema.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{ctx.selected !== null && (
|
||||
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
|
||||
<Formy.Label as="legend" className="font-mono px-2">
|
||||
{autoFormatString(ctx.selectedSchema!.title!)}
|
||||
</Formy.Label>
|
||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
||||
<Field name="type" hidden />
|
||||
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||
</FormContextOverride>
|
||||
</Formy.Group>
|
||||
)}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
const Overlay = () => (
|
||||
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
|
||||
{({ enabled }) =>
|
||||
!enabled && (
|
||||
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
|
||||
)
|
||||
}
|
||||
</Subscribe>
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
||||
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
||||
|
||||
import FormyTest from "ui/routes/test/tests/formy-test";
|
||||
import HtmlFormTest from "ui/routes/test/tests/html-form-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";
|
||||
@@ -15,6 +18,7 @@ import DropdownTest from "./tests/dropdown-test";
|
||||
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||
import FlowsTest from "./tests/flows-test";
|
||||
import JsonSchemaForm3 from "./tests/json-schema-form3";
|
||||
import JsonFormTest from "./tests/jsonform-test";
|
||||
import { LiquidJsTest } from "./tests/liquid-js-test";
|
||||
import MantineTest from "./tests/mantine-test";
|
||||
@@ -45,7 +49,10 @@ const tests = {
|
||||
SWRAndAPI,
|
||||
SwrAndDataApi,
|
||||
DropzoneElementTest,
|
||||
JsonSchemaFormReactTest
|
||||
JsonSchemaFormReactTest,
|
||||
JsonSchemaForm3,
|
||||
FormyTest,
|
||||
HtmlFormTest
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
@@ -83,7 +90,7 @@ function TestRoot({ children }) {
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
</AppShell.Sidebar>
|
||||
<main className="flex flex-col flex-grow">{children}</main>
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
17
app/src/ui/routes/test/tests/formy-test.tsx
Normal file
17
app/src/ui/routes/test/tests/formy-test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
export default function FormyTest() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
formy
|
||||
<Formy.Group>
|
||||
<Formy.Label>label</Formy.Label>
|
||||
<Formy.Switch onCheckedChange={console.log} />
|
||||
</Formy.Group>
|
||||
<Formy.Group>
|
||||
<Formy.Label>label</Formy.Label>
|
||||
<Formy.Input />
|
||||
</Formy.Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
app/src/ui/routes/test/tests/html-form-test.tsx
Normal file
39
app/src/ui/routes/test/tests/html-form-test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { NativeForm } from "ui/components/form/native-form/NativeForm";
|
||||
|
||||
export default function HtmlFormTest() {
|
||||
const [data, setData] = useState<any>();
|
||||
const [errors, setErrors] = useState<any>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-3">
|
||||
<h1>html</h1>
|
||||
|
||||
<NativeForm
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
onChange={setData}
|
||||
onSubmit={(data) => console.log("submit", data)}
|
||||
onSubmitInvalid={(errors) => console.log("invalid", errors)}
|
||||
onError={setErrors}
|
||||
reportValidity
|
||||
clean
|
||||
>
|
||||
<Formy.Input type="text" name="what" minLength={2} maxLength={5} required />
|
||||
<div data-role="input-error" data-name="what" />
|
||||
<Formy.Input type="number" name="age" step={5} required />
|
||||
<Formy.Input type="checkbox" name="verified" />
|
||||
|
||||
<Formy.Input type="text" name="tag" minLength={1} required />
|
||||
<Formy.Input type="number" name="tag" />
|
||||
|
||||
<Button type="submit">submit</Button>
|
||||
</NativeForm>
|
||||
|
||||
<JsonViewer json={{ data, errors }} expand={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export default function JsonSchemaFormReactTest() {
|
||||
<>
|
||||
<Form
|
||||
schema={schema}
|
||||
onChange={setData}
|
||||
onSubmit={setData}
|
||||
/*onChange={setData}
|
||||
onSubmit={setData}*/
|
||||
validator={validator}
|
||||
validationMode="change"
|
||||
>
|
||||
|
||||
371
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
371
app/src/ui/routes/test/tests/json-schema-form3.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import type { JSONSchema } from "json-schema-to-ts";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import {
|
||||
AnyOf,
|
||||
AnyOfField,
|
||||
Field,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectField,
|
||||
useFormError
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const schema2 = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter" },
|
||||
age: { type: "number" },
|
||||
gender: {
|
||||
type: "string",
|
||||
enum: ["male", "female", "uni"]
|
||||
},
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
export default function JsonSchemaForm3() {
|
||||
const { schema: _schema, config } = useBknd();
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
|
||||
config.media.storage.body_max_size = 1;
|
||||
schema.media.properties.storage.properties.body_max_size.minimum = 0;
|
||||
schema.media.if = { properties: { enabled: { const: true } } };
|
||||
// biome-ignore lint/suspicious/noThenProperty: <explanation>
|
||||
schema.media.then = { required: ["adapter"] };
|
||||
//schema.media.properties.adapter.anyOf[2].properties.config.properties.path.minLength = 1;
|
||||
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col p-3">
|
||||
<Form
|
||||
onChange={(data) => console.log("change", data)}
|
||||
onSubmit={(data) => console.log("submit", data)}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter", maxLength: 3 },
|
||||
age: { type: "number" },
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["age"],
|
||||
additionalProperties: false
|
||||
}}
|
||||
initialValues={{ name: "Peter", age: 20, deep: { nested: "hello" } }}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
options={{ debug: true }}
|
||||
/>
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter", minLength: 3 },
|
||||
age: { type: "number" },
|
||||
deep: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested2: { type: "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<Field name="" />
|
||||
<Subscribe2>
|
||||
{(state) => (
|
||||
<pre className="text-wrap whitespace-break-spaces break-all">
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</Subscribe2>
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "Peter", maxLength: 3 },
|
||||
age: { type: "number" },
|
||||
gender: {
|
||||
type: "string",
|
||||
enum: ["male", "female", "uni"]
|
||||
},
|
||||
deep: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["age"]
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<div>random thing</div>
|
||||
<Field name="name" />
|
||||
<Field name="age" />
|
||||
<FormDebug />
|
||||
<FormDebug2 name="name" />
|
||||
<hr />
|
||||
<Subscribe2
|
||||
selector={(state) => ({ dirty: state.dirty, submitting: state.submitting })}
|
||||
>
|
||||
{(state) => (
|
||||
<pre className="text-wrap whitespace-break-spaces break-all">
|
||||
{JSON.stringify(state)}
|
||||
</pre>
|
||||
)}
|
||||
</Subscribe2>
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
bla: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
start: { type: "string", enum: ["a", "b", "c"] },
|
||||
end: { type: "number" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: ["a", "b", "c"] }}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "number"
|
||||
}
|
||||
},
|
||||
method: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["GET", "POST", "PUT", "DELETE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1], method: ["GET"] }}
|
||||
options={{ debug: true }}
|
||||
>
|
||||
<Field name="" />
|
||||
<FormDebug />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string"
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string", title: "String" },
|
||||
{ type: "number", title: "Number" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
initialValues={{ tags: [0, 1] }}
|
||||
>
|
||||
<Field name="" />
|
||||
<FormDebug force />
|
||||
</Form>*/}
|
||||
|
||||
{/*<CustomMediaForm />*/}
|
||||
{/*<Form
|
||||
schema={schema.media}
|
||||
initialValues={config.media as any}
|
||||
onSubmit={console.log}
|
||||
validateOn="change"
|
||||
/>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
||||
initialValues={config.media}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form
|
||||
schema={removeKeyRecursively(schema.server, "pattern") as any}
|
||||
initialValues={config.server}
|
||||
>
|
||||
<AutoForm />
|
||||
</Form>*/}
|
||||
|
||||
{/*<Form schema={ss} validateOn="change" />*/}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
const ss = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: "string", format: "email" },
|
||||
interested: { type: "boolean" },
|
||||
bla: {
|
||||
type: "string",
|
||||
enum: ["small", "medium", "large"]
|
||||
},
|
||||
password: { type: "string", format: "password" },
|
||||
birthdate: { type: "string", format: "date" },
|
||||
dinnerTime: { type: "string", format: "date-time" },
|
||||
age: { type: "number", minimum: 0, multipleOf: 5 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
config: {
|
||||
type: "object",
|
||||
properties: {
|
||||
min: { type: "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
function CustomMediaForm() {
|
||||
const { schema: _schema, config } = useBknd();
|
||||
const schema = JSON.parse(JSON.stringify(_schema));
|
||||
|
||||
config.media.storage.body_max_size = 1;
|
||||
schema.media.properties.storage.properties.body_max_size.minimum = 0;
|
||||
schema.media.if = { properties: { enabled: { const: true } } };
|
||||
// biome-ignore lint/suspicious/noThenProperty: <explanation>
|
||||
schema.media.then = { required: ["adapter"] };
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={schema.media}
|
||||
/*initialValues={config.media as any}*/
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
>
|
||||
<Test />
|
||||
<Field name="enabled" />
|
||||
<Field name="basepath" />
|
||||
<Field name="entity_name" />
|
||||
<Field name="storage" />
|
||||
<AnyOf.Root path="adapter">
|
||||
<CustomMediaFormAdapter />
|
||||
</AnyOf.Root>
|
||||
{/*<FormDebug force />*/}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const Test = () => {
|
||||
const errors = useFormError("", { strict: true });
|
||||
return <div>{errors.map((e) => e.message).join("\n")}</div>;
|
||||
//return <pre>{JSON.stringify(errors, null, 2)}</pre>;
|
||||
};
|
||||
|
||||
function CustomMediaFormAdapter() {
|
||||
const ctx = AnyOf.useContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row gap-1">
|
||||
{ctx.schemas?.map((schema: any, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
onClick={() => ctx.select(i)}
|
||||
variant={ctx.selected === i ? "primary" : "default"}
|
||||
>
|
||||
{schema.title ?? `Option ${i + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ctx.selected !== null && (
|
||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
||||
<Field name="type" hidden />
|
||||
<ObjectField path="config" wrapperProps={{ label: false, wrapper: "group" }} />
|
||||
</FormContextOverride>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,34 +16,6 @@ html.fixed body {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
.bknd-admin {
|
||||
--color-primary: 9 9 11; /* zinc-950 */
|
||||
--color-background: 250 250 250; /* zinc-50 */
|
||||
--color-muted: 228 228 231; /* ? */
|
||||
--color-darkest: 0 0 0; /* black */
|
||||
--color-lightest: 255 255 255; /* white */
|
||||
|
||||
&.dark {
|
||||
--color-primary: 250 250 250; /* zinc-50 */
|
||||
--color-background: 30 31 34;
|
||||
--color-muted: 47 47 52;
|
||||
--color-darkest: 255 255 255; /* white */
|
||||
--color-lightest: 24 24 27; /* black */
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
--mantine-color-body: rgb(250 250 250);
|
||||
}
|
||||
@mixin dark {
|
||||
--mantine-color-body: rgb(9 9 11);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-size: 14px;
|
||||
@@ -63,9 +35,8 @@ body {
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div:first-child {
|
||||
min-width: auto !important;
|
||||
display: block !important;
|
||||
min-width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* hide calendar icon on inputs */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types", "@cloudflare/workers-types"],
|
||||
"composite": true,
|
||||
"composite": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
@@ -65,7 +65,7 @@ import "bknd/dist/styles.css";
|
||||
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
|
||||
const api = getApi(Astro, { mode: "dynamic" });
|
||||
const api = await getApi(Astro, { mode: "dynamic" });
|
||||
const user = api.getUser();
|
||||
|
||||
export const prerender = false;
|
||||
@@ -94,7 +94,7 @@ Here is an example of using the API in static context:
|
||||
```jsx
|
||||
---
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
const api = getApi(Astro);
|
||||
const api = await getApi(Astro);
|
||||
const { data } = await api.data.readMany("todos");
|
||||
---
|
||||
|
||||
@@ -109,7 +109,7 @@ On SSR pages, you can also access the authenticated user:
|
||||
```jsx
|
||||
---
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
const api = getApi(Astro, { mode: "dynamic" });
|
||||
const api = await getApi(Astro, { mode: "dynamic" });
|
||||
const user = api.getUser();
|
||||
const { data } = await api.data.readMany("todos");
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ and then install bknd as a dependency:
|
||||
If you don't choose anything specific, the following code will use the `warm` mode. See the
|
||||
chapter [Using a different mode](#using-a-different-mode) for available modes.
|
||||
|
||||
``` ts
|
||||
```ts src/index.ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
export default serve<Env>({
|
||||
app: ({ env }) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
@@ -42,32 +42,37 @@ And confirm it works by opening [http://localhost:8787](http://localhost:8787) i
|
||||
your browser.
|
||||
|
||||
## Serve the Admin UI
|
||||
Now in order to also server the static admin files, you have to modify the `wrangler.toml` to
|
||||
include the static assets:
|
||||
```toml
|
||||
[site]
|
||||
bucket = "node_modules/bknd/dist/static"
|
||||
```
|
||||
Now in order to also server the static admin files, you have to modify the `wrangler.toml` to include the static assets. You can do so by either serving the static using the new [Assets feature](https://developers.cloudflare.com/workers/static-assets/), or the deprecated [Workers Site](https://developers.cloudflare.com/workers/configuration/sites/configuration/).
|
||||
|
||||
And then modify the worker entry as follows:
|
||||
``` ts {2, 14, 15}
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
<Tabs>
|
||||
<Tab title="Assets">
|
||||
Make sure your assets point to the static assets included in the bknd package:
|
||||
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
}
|
||||
}),
|
||||
manifest,
|
||||
setAdminHtml: true
|
||||
});
|
||||
```
|
||||
```toml wrangler.toml
|
||||
assets = { directory = "node_modules/bknd/dist/static" }
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Workers Sites">
|
||||
Make sure your site points to the static assets included in the bknd package:
|
||||
|
||||
```toml wrangler.toml
|
||||
[site]
|
||||
bucket = "node_modules/bknd/dist/static"
|
||||
```
|
||||
|
||||
And then modify the worker entry as follows:
|
||||
```ts {2, 6} src/index.ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
export default serve<Env>({
|
||||
app: () => ({/* ... */}),
|
||||
manifest
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Adding custom routes
|
||||
You can also add custom routes by defining them after the app has been built, like so:
|
||||
@@ -75,8 +80,8 @@ You can also add custom routes by defining them after the app has been built, li
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
export default serve<Env>({
|
||||
app: ({ env }) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
@@ -111,7 +116,7 @@ mode`, like so:
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
// ...
|
||||
mode: "fresh" // mode: "fresh" | "warm" | "cache" | "durable"
|
||||
});
|
||||
```
|
||||
@@ -119,13 +124,14 @@ export default serve({
|
||||
### Mode: `cache`
|
||||
For the cache mode to work, you also need to specify the KV to be used. For this, use the
|
||||
`bindings` property:
|
||||
|
||||
```ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
export default serve<Env>({
|
||||
// ...
|
||||
mode: "cache",
|
||||
bindings: (env: Env) => ({ kv: env.KV })
|
||||
bindings: ({ env }) => ({ kv: env.KV })
|
||||
});
|
||||
```
|
||||
|
||||
@@ -136,10 +142,10 @@ environment, and additionally export the `DurableBkndApp` class:
|
||||
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
|
||||
|
||||
export { DurableBkndApp };
|
||||
export default serve({
|
||||
/* ... */,
|
||||
export default serve<Env>({
|
||||
// ...
|
||||
mode: "durable",
|
||||
bindings: (env: Env) => ({ dobj: env.DOBJ }),
|
||||
bindings: ({ env }) => ({ dobj: env.DOBJ }),
|
||||
keepAliveSeconds: 60 // optional
|
||||
});
|
||||
```
|
||||
@@ -164,9 +170,9 @@ import type { App } from "bknd";
|
||||
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
// ...
|
||||
mode: "durable",
|
||||
bindings: (env: Env) => ({ dobj: env.DOBJ }),
|
||||
bindings: ({ env }) => ({ dobj: env.DOBJ }),
|
||||
keepAliveSeconds: 60 // optional
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Install bknd as a dependency:
|
||||
|
||||
## Serve the API
|
||||
Create a new api splat route file at `app/routes/api.$.ts`:
|
||||
``` tsx
|
||||
```ts
|
||||
// app/routes/api.$.ts
|
||||
import { serve } from "bknd/adapter/remix";
|
||||
|
||||
@@ -32,6 +32,9 @@ Now make sure that you wrap your root layout with the `ClientProvider` so that a
|
||||
share the same context:
|
||||
```tsx
|
||||
// app/root.tsx
|
||||
import { withApi } from "bknd/adapter/remix"
|
||||
import { type Api, ClientProvider } from "bknd/client";
|
||||
|
||||
export function Layout(props) {
|
||||
// nothing to change here, just for orientation
|
||||
return (
|
||||
@@ -48,21 +51,12 @@ declare module "@remix-run/server-runtime" {
|
||||
}
|
||||
|
||||
// export a loader that initiates the API
|
||||
// and pass it through the context
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const api = new Api({
|
||||
host: new URL(args.request.url).origin,
|
||||
headers: args.request.headers
|
||||
});
|
||||
|
||||
// get the user from the API
|
||||
const user = api.getUser();
|
||||
|
||||
// add api to the context
|
||||
args.context.api = api;
|
||||
|
||||
return { user };
|
||||
};
|
||||
// and passes it down to args.context.api
|
||||
export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
|
||||
return {
|
||||
user: api.getUser()
|
||||
};
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const { user } = useLoaderData<typeof loader>();
|
||||
@@ -93,15 +87,9 @@ Since the API has already been constructed in the root layout, you can now use i
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const { api } = args.context;
|
||||
|
||||
// get the authenticated user
|
||||
const user = api.getAuthState().user;
|
||||
|
||||
// get the data from the API
|
||||
export const loader = async ({ context: { api } }: LoaderFunctionArgs) => {
|
||||
const { data } = await api.data.readMany("todos");
|
||||
return { data, user };
|
||||
return { data, user: api.getUser() };
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
|
||||
@@ -5,19 +5,74 @@ description: 'Use the bknd SDK in TypeScript'
|
||||
|
||||
To start using the bknd API, start by creating a new API instance:
|
||||
```ts
|
||||
import { Api } from "bknd";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
const api = new Api({
|
||||
host: "..." // point to your bknd instance
|
||||
});
|
||||
const api = new Api();
|
||||
|
||||
// make sure to verify auth
|
||||
// always make sure to verify auth
|
||||
await api.verifyAuth();
|
||||
```
|
||||
|
||||
The `Api` class is the main entry point for interacting with the bknd API. It provides methods
|
||||
for all available modules described below.
|
||||
|
||||
## Setup
|
||||
You can initialize an API instance by providing the `Request` object, or manually specifying the details such as `host` and `token`.
|
||||
|
||||
### Using the `Request` object
|
||||
The recommended way to create an API instance is by passing the current `Request` object. This will automatically point the API to your current instance and extract the token from the headers (either from cookies or `Authorization` header):
|
||||
|
||||
```ts
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
// replace this with the actual request
|
||||
let request: Request;
|
||||
|
||||
const api = new Api({ request });
|
||||
```
|
||||
|
||||
If the authentication details are contained in the current request, but you're hosting your bknd instance somewhere else, you can specify a `host` option:
|
||||
|
||||
```ts
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
// replace this with the actual request
|
||||
let request: Request;
|
||||
|
||||
const api = new Api({
|
||||
host: "https://<your-endpoint>",
|
||||
request,
|
||||
});
|
||||
```
|
||||
|
||||
### Using the `token` option
|
||||
If you want to have an API instance that is using a different token, e.g. an admin token, you can create it by specifying the `host` and `token` option:
|
||||
|
||||
```ts
|
||||
import { Api } from "bknd/client";
|
||||
const api = new Api({
|
||||
host: "https://<your-endpoint>",
|
||||
token: "<your-token>"
|
||||
});
|
||||
```
|
||||
|
||||
### Using a local API
|
||||
In case the place where you're using the API is the same as your bknd instance (e.g. when using it embedded in a React framework), you can specify a `fetcher` option to point to your bknd app. This way, requests won't travel over the network and instead processed locally:
|
||||
|
||||
```ts
|
||||
import type { App } from "bknd";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
// replace this with your actual `App` instance
|
||||
let app: App;
|
||||
|
||||
const api = new Api({
|
||||
fetcher: app.server.request as typeof fetch,
|
||||
// specify `host` and `token` or `request`
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Data (`api.data`)
|
||||
Access the `Data` specific API methods at `api.data`.
|
||||
|
||||
@@ -79,19 +134,25 @@ const { data } = await api.data.deleteOne("posts", 1);
|
||||
Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the
|
||||
API will automatically save the token and use it for subsequent requests.
|
||||
|
||||
### `auth.loginWithPassword([input])`
|
||||
To log in with a password, use the `loginWithPassword` method:
|
||||
### `auth.strategies()`
|
||||
To retrieve the available authentication strategies, use the `strategies` method:
|
||||
```ts
|
||||
const { data } = await api.auth.loginWithPassword({
|
||||
const { data } = await api.auth.strategies();
|
||||
```
|
||||
|
||||
### `auth.login([strategy], [input])`
|
||||
To log in with a password, use the `login` method:
|
||||
```ts
|
||||
const { data } = await api.auth.login("password", {
|
||||
email: "...",
|
||||
password: "..."
|
||||
});
|
||||
```
|
||||
|
||||
### `auth.registerWithPassword([input])`
|
||||
To register with a password, use the `registerWithPassword` method:
|
||||
### `auth.register([strategy], [input])`
|
||||
To register with a password, use the `register` method:
|
||||
```ts
|
||||
const { data } = await api.auth.registerWithPassword({
|
||||
const { data } = await api.auth.register("password", {
|
||||
email: "...",
|
||||
password: "..."
|
||||
});
|
||||
@@ -103,8 +164,3 @@ To retrieve the current user, use the `me` method:
|
||||
const { data } = await api.auth.me();
|
||||
```
|
||||
|
||||
### `auth.strategies()`
|
||||
To retrieve the available authentication strategies, use the `strategies` method:
|
||||
```ts
|
||||
const { data } = await api.auth.strategies();
|
||||
```
|
||||
@@ -4,8 +4,7 @@ import "bknd/dist/styles.css";
|
||||
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
|
||||
const api = getApi(Astro, { mode: "dynamic" });
|
||||
await api.verifyAuth();
|
||||
const api = await getApi(Astro, { mode: "dynamic" });
|
||||
const user = api.getUser();
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { App } from "bknd";
|
||||
import { serve } from "bknd/adapter/astro";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { registerLocalMediaAdapter, serve } from "bknd/adapter/astro";
|
||||
import { boolean, em, entity, text } from "bknd/data";
|
||||
import { secureRandomString } from "bknd/utils";
|
||||
|
||||
@@ -23,7 +23,7 @@ declare module "bknd/core" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
|
||||
export const ALL = serve({
|
||||
export const ALL = serve<APIContext>({
|
||||
// we can use any libsql config, and if omitted, uses in-memory
|
||||
connection: {
|
||||
type: "libsql",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
import Card from "../components/Card.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
const api = getApi(Astro);
|
||||
|
||||
const api = await getApi(Astro);
|
||||
const { data } = await api.data.readMany("todos");
|
||||
---
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { getApi } from "bknd/adapter/astro";
|
||||
import Card from "../components/Card.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
const api = getApi(Astro, { mode: "dynamic" });
|
||||
await api.verifyAuth();
|
||||
const api = await getApi(Astro, { mode: "dynamic" });
|
||||
|
||||
const { data } = await api.data.readMany("todos");
|
||||
const user = api.getUser();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
app: (args) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
@@ -13,6 +11,5 @@ export default serve({
|
||||
}),
|
||||
onBuilt: async (app) => {
|
||||
app.modules.server.get("/custom", (c) => c.json({ hello: "world" }));
|
||||
},
|
||||
manifest
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@ compatibility_date = "2024-11-06"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
workers_dev = true
|
||||
minify = true
|
||||
assets = { directory = "../../app/dist/static" }
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[site]
|
||||
bucket = "../../app/dist/static"
|
||||
#[site]
|
||||
#bucket = "../../app/dist/static"
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
|
||||
import { Api, ClientProvider } from "bknd/client";
|
||||
|
||||
declare module "@remix-run/server-runtime" {
|
||||
export interface AppLoadContext {
|
||||
api: Api;
|
||||
}
|
||||
}
|
||||
import { withApi } from "bknd/adapter/remix";
|
||||
import { type Api, ClientProvider } from "bknd/client";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -26,20 +21,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const api = new Api({
|
||||
host: new URL(args.request.url).origin,
|
||||
headers: args.request.headers
|
||||
});
|
||||
declare module "@remix-run/server-runtime" {
|
||||
export interface AppLoadContext {
|
||||
api: Api;
|
||||
}
|
||||
}
|
||||
|
||||
// add api to the context
|
||||
args.context.api = api;
|
||||
|
||||
await api.verifyAuth();
|
||||
export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
|
||||
return {
|
||||
user: api.getAuthState()?.user
|
||||
user: api.getUser()
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { type MetaFunction, useLoaderData } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useAuth } from "bknd/client";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }];
|
||||
};
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const api = args.context.api;
|
||||
await api.verifyAuth();
|
||||
export const loader = async ({ context: { api } }: LoaderFunctionArgs) => {
|
||||
const { data } = await api.data.readMany("todos");
|
||||
return { data, user: api.getUser() };
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const { data, user } = useLoaderData<typeof loader>();
|
||||
const auth = useAuth();
|
||||
console.log("auth", auth);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { App } from "bknd";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { serve } from "bknd/adapter/remix";
|
||||
import { registerLocalMediaAdapter, serve } from "bknd/adapter/remix";
|
||||
import { boolean, em, entity, text } from "bknd/data";
|
||||
import { secureRandomString } from "bknd/utils";
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type BkndContextProps = {
|
||||
};
|
||||
|
||||
const BkndContextContext = createContext<BkndGlobalContextProps>({} as any);
|
||||
BkndContextContext.displayName = "BkndContext";
|
||||
|
||||
export const BkndContext = ({
|
||||
children,
|
||||
|
||||
Reference in New Issue
Block a user