diff --git a/README.md b/README.md index b60a344..7f47c83 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -![bknd](docs/_assets/poster.png) +[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd +"View this project on NPM") +[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd) + +![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) bknd simplifies app development by providing fully functional backend for data management, authentication, workflows and media. Since it's lightweight and built on Web Standards, it can diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts new file mode 100644 index 0000000..dbbe35d --- /dev/null +++ b/app/__test__/api/DataApi.spec.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Guard } from "../../src/auth"; +import { parse } from "../../src/core/utils"; +import { DataApi } from "../../src/data/api/DataApi"; +import { DataController } from "../../src/data/api/DataController"; +import { dataConfigSchema } from "../../src/data/data-schema"; +import * as proto from "../../src/data/prototype"; +import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const dataConfig = parse(dataConfigSchema, {}); +describe("DataApi", () => { + it("should switch to post for long url reads", async () => { + const api = new DataApi(); + + const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); + expect(get.request.method).toBe("GET"); + expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`); + + const post = api.readMany("a".repeat(1000), { select: ["id", "name"] }); + expect(post.request.method).toBe("POST"); + expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`); + }); + + it("returns result", async () => { + const schema = proto.em({ + posts: proto.entity("posts", { title: proto.text() }) + }); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }]; + await em.mutator("posts").insertMany(payload); + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + { + const res = (await app.request("/posts")) as Response; + const { data } = await res.json(); + expect(data.length).toEqual(3); + } + + // @ts-ignore tests + const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }); + // @ts-ignore protected + api.fetcher = app.request as typeof fetch; + { + const req = api.readMany("posts", { select: ["title"] }); + expect(req.request.method).toBe("GET"); + const res = await req; + expect(res.data).toEqual(payload); + } + + { + const req = api.readMany("posts", { + select: ["title"], + limit: 100000, + offset: 0, + sort: "id" + }); + expect(req.request.method).toBe("POST"); + const res = await req; + expect(res.data).toEqual(payload); + } + }); +}); diff --git a/app/__test__/api/ModuleApi.spec.ts b/app/__test__/api/ModuleApi.spec.ts index caa42d0..5fb6976 100644 --- a/app/__test__/api/ModuleApi.spec.ts +++ b/app/__test__/api/ModuleApi.spec.ts @@ -28,6 +28,8 @@ 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 res = await api.get("/endpoint"); @@ -40,6 +42,8 @@ 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 promise = api.get("/endpoint"); diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts new file mode 100644 index 0000000..0d025b9 --- /dev/null +++ b/app/__test__/app/repro.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { createApp, registries } from "../../src"; +import * as proto from "../../src/data/prototype"; +import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; + +describe("repros", async () => { + /** + * steps: + * 1. enable media + * 2. create 'test' entity + * 3. add media to 'test' + * + * There was an issue that AppData had old configs because of system entity "media" + */ + test("registers media entity correctly to relate to it", async () => { + registries.media.register("local", StorageLocalAdapter); + const app = createApp(); + await app.build(); + + { + // 1. enable media + const [, config] = await app.module.media.schema().patch("", { + enabled: true, + adapter: { + type: "local", + config: { + path: "./" + } + } + }); + + expect(config.enabled).toBe(true); + } + + { + // 2. create 'test' entity + await app.module.data.schema().patch( + "entities.test", + proto + .entity("test", { + content: proto.text() + }) + .toJSON() + ); + expect(app.em.entities.map((e) => e.name)).toContain("test"); + } + + { + await app.module.data.schema().patch("entities.test.fields.files", { + type: "media", + config: { + required: false, + fillable: ["update"], + hidden: false, + mime_types: [], + virtual: true, + entity: "test" + } + }); + + expect( + app.module.data.schema().patch("relations.000", { + type: "poly", + source: "test", + target: "media", + config: { mappedBy: "files" } + }) + ).resolves.toBeDefined(); + } + + expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); + }); +}); diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 3327449..e332439 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { Event, EventManager, NoParamEvent } from "../../src/core/events"; +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events"; +import { disableConsoleLog, enableConsoleLog } from "../helper"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); class SpecialEvent extends Event<{ foo: string }> { - static slug = "special-event"; + static override slug = "special-event"; isBar() { return this.params.foo === "bar"; @@ -10,37 +14,139 @@ class SpecialEvent extends Event<{ foo: string }> { } class InformationalEvent extends NoParamEvent { - static slug = "informational-event"; + static override slug = "informational-event"; +} + +class ReturnEvent extends Event<{ foo: string }, string> { + static override slug = "return-event"; + + override validate(value: string) { + if (typeof value !== "string") { + throw new InvalidEventReturn("string", typeof value); + } + + return this.clone({ + foo: [this.params.foo, value].join("-") + }); + } } describe("EventManager", async () => { - test("test", async () => { + test("executes", async () => { + const call = mock(() => null); + const delayed = mock(() => null); + const emgr = new EventManager(); emgr.registerEvents([SpecialEvent, InformationalEvent]); + expect(emgr.eventExists("special-event")).toBe(true); + expect(emgr.eventExists("informational-event")).toBe(true); + expect(emgr.eventExists("unknown-event")).toBe(false); + emgr.onEvent( SpecialEvent, async (event, name) => { - console.log("Event: ", name, event.params.foo, event.isBar()); - console.log("wait..."); - - await new Promise((resolve) => setTimeout(resolve, 100)); - console.log("done waiting"); + expect(name).toBe("special-event"); + expect(event.isBar()).toBe(true); + call(); + await new Promise((resolve) => setTimeout(resolve, 50)); + delayed(); }, "sync" ); + // don't allow unknown + expect(() => emgr.on("unknown", () => void 0)).toThrow(); + emgr.onEvent(InformationalEvent, async (event, name) => { - console.log("Event: ", name, event.params); + call(); + expect(name).toBe("informational-event"); }); await emgr.emit(new SpecialEvent({ foo: "bar" })); - console.log("done"); + await emgr.emit(new InformationalEvent()); // expect construct signatures to not cause ts errors new SpecialEvent({ foo: "bar" }); new InformationalEvent(); - expect(true).toBe(true); + expect(call).toHaveBeenCalledTimes(2); + expect(delayed).toHaveBeenCalled(); + }); + + test("custom async executor", async () => { + const call = mock(() => null); + const asyncExecutor = (p: Promise[]) => { + call(); + return Promise.all(p); + }; + const emgr = new EventManager( + { InformationalEvent }, + { + asyncExecutor + } + ); + + emgr.onEvent(InformationalEvent, async () => {}); + await emgr.emit(new InformationalEvent()); + expect(call).toHaveBeenCalled(); + }); + + test("piping", async () => { + const onInvalidReturn = mock(() => null); + const asyncEventCallback = mock(() => null); + const emgr = new EventManager( + { ReturnEvent, InformationalEvent }, + { + onInvalidReturn + } + ); + + // @ts-expect-error InformationalEvent has no return value + emgr.onEvent(InformationalEvent, async () => { + asyncEventCallback(); + return 1; + }); + + emgr.onEvent(ReturnEvent, async () => "1", "sync"); + emgr.onEvent(ReturnEvent, async () => "0", "sync"); + + // @ts-expect-error must be string + emgr.onEvent(ReturnEvent, async () => 0, "sync"); + + // return is not required + emgr.onEvent(ReturnEvent, async () => {}, "sync"); + + // was "async", will not return + const e1 = await emgr.emit(new InformationalEvent()); + expect(e1.returned).toBe(false); + + const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); + expect(e2.returned).toBe(true); + expect(e2.params.foo).toBe("bar-1-0"); + expect(onInvalidReturn).toHaveBeenCalled(); + expect(asyncEventCallback).toHaveBeenCalled(); + }); + + test("once", async () => { + const call = mock(() => null); + const emgr = new EventManager({ InformationalEvent }); + + emgr.onEvent( + InformationalEvent, + async (event, slug) => { + expect(event).toBeInstanceOf(InformationalEvent); + expect(slug).toBe("informational-event"); + call(); + }, + { mode: "sync", once: true } + ); + + expect(emgr.getListeners().length).toBe(1); + await emgr.emit(new InformationalEvent()); + expect(emgr.getListeners().length).toBe(0); + await emgr.emit(new InformationalEvent()); + expect(emgr.getListeners().length).toBe(0); + expect(call).toHaveBeenCalledTimes(1); }); }); diff --git a/app/__test__/data/data-query-impl.spec.ts b/app/__test__/data/data-query-impl.spec.ts index a2fcdff..c03b1fe 100644 --- a/app/__test__/data/data-query-impl.spec.ts +++ b/app/__test__/data/data-query-impl.spec.ts @@ -1,8 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { Value } from "../../src/core/utils"; -import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; +import { Value, _jsonp } from "../../src/core/utils"; +import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; +import type { RepoQueryIn } from "../../src/data/server/data-query-impl"; import { getDummyConnection } from "./helper"; +const decode = (input: RepoQueryIn, expected: RepoQuery) => { + const result = Value.Decode(querySchema, input); + expect(result).toEqual(expected); +}; + describe("data-query-impl", () => { function qb() { const c = getDummyConnection(); @@ -88,21 +94,47 @@ describe("data-query-impl", () => { expect(keys).toEqual(expectedKeys); } }); + + test("with", () => { + decode({ with: ["posts"] }, { with: { posts: {} } }); + decode({ with: { posts: {} } }, { with: { posts: {} } }); + decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } }); + decode( + { + with: { + posts: { + with: { + images: { + select: ["id"] + } + } + } + } + }, + { + with: { + posts: { + with: { + images: { + select: ["id"] + } + } + } + } + } + ); + }); }); describe("data-query-impl: Typebox", () => { test("sort", async () => { - const decode = (input: any, expected: any) => { - const result = Value.Decode(querySchema, input); - expect(result.sort).toEqual(expected); - }; - const _dflt = { by: "id", dir: "asc" }; + const _dflt = { sort: { by: "id", dir: "asc" } }; decode({ sort: "" }, _dflt); - decode({ sort: "name" }, { by: "name", dir: "asc" }); - decode({ sort: "-name" }, { by: "name", dir: "desc" }); - decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" }); + decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } }); + decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } }); + decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } }); decode({ sort: "-1name" }, _dflt); - decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" }); + decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } }); }); }); diff --git a/app/__test__/data/relations.test.ts b/app/__test__/data/relations.test.ts index ea56388..19eab85 100644 --- a/app/__test__/data/relations.test.ts +++ b/app/__test__/data/relations.test.ts @@ -106,7 +106,6 @@ describe("Relations", async () => { expect(postAuthorRel?.other(posts).entity).toBe(users); const kysely = em.connection.kysely; - const jsonFrom = (e) => e; /** * Relation Helper */ @@ -119,14 +118,11 @@ describe("Relations", async () => { - select: users.* - cardinality: 1 */ - const selectPostsFromUsers = postAuthorRel.buildWith( - users, - kysely.selectFrom(users.name), - jsonFrom, - "posts" - ); + const selectPostsFromUsers = kysely + .selectFrom(users.name) + .select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts")); expect(selectPostsFromUsers.compile().sql).toBe( - 'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"' + 'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"' ); expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField); const userObj = { id: 1, username: "test" }; @@ -141,15 +137,12 @@ describe("Relations", async () => { - select: posts.* - cardinality: */ - const selectUsersFromPosts = postAuthorRel.buildWith( - posts, - kysely.selectFrom(posts.name), - jsonFrom, - "author" - ); + const selectUsersFromPosts = kysely + .selectFrom(posts.name) + .select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author")); expect(selectUsersFromPosts.compile().sql).toBe( - 'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"' + 'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"' ); expect(postAuthorRel.getField()).toBeInstanceOf(RelationField); const postObj = { id: 1, title: "test" }; @@ -315,20 +308,16 @@ describe("Relations", async () => { - select: users.* - cardinality: 1 */ - const selectCategoriesFromPosts = postCategoriesRel.buildWith( - posts, - kysely.selectFrom(posts.name), - jsonFrom - ); + const selectCategoriesFromPosts = kysely + .selectFrom(posts.name) + .select((eb) => postCategoriesRel.buildWith(posts)(eb).as("categories")); expect(selectCategoriesFromPosts.compile().sql).toBe( 'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"' ); - const selectPostsFromCategories = postCategoriesRel.buildWith( - categories, - kysely.selectFrom(categories.name), - jsonFrom - ); + const selectPostsFromCategories = kysely + .selectFrom(categories.name) + .select((eb) => postCategoriesRel.buildWith(categories)(eb).as("posts")); expect(selectPostsFromCategories.compile().sql).toBe( 'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"' ); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 5552543..6493d52 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; +import type { EventManager } from "../../../src/core/events"; import { Entity, EntityManager, @@ -10,6 +11,7 @@ import { RelationMutator, TextField } from "../../../src/data"; +import * as proto from "../../../src/data/prototype"; import { getDummyConnection } from "../helper"; const { dummyConnection, afterAllCleanup } = getDummyConnection(); @@ -83,14 +85,12 @@ describe("[data] Mutator (ManyToOne)", async () => { // persisting reference should ... expect( - postRelMutator.persistReference(relations[0], "users", { + postRelMutator.persistReference(relations[0]!, "users", { $set: { id: userData.data.id } }) ).resolves.toEqual(["users_id", userData.data.id]); // @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users - process.exit(0); - const userRelMutator = new RelationMutator(users, em); expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]); }); @@ -99,7 +99,7 @@ describe("[data] Mutator (ManyToOne)", async () => { expect( em.mutator(posts).insertOne({ title: "post1", - users_id: 1 // user does not exist yet + users_id: 100 // user does not exist yet }) ).rejects.toThrow(); }); @@ -299,4 +299,71 @@ describe("[data] Mutator (Events)", async () => { expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); }); + + test("insertOne event return is respected", async () => { + const posts = proto.entity("posts", { + title: proto.text(), + views: proto.number() + }); + + const conn = getDummyConnection(); + const em = new EntityManager([posts], conn.dummyConnection); + await em.schema().sync({ force: true }); + + const emgr = em.emgr as EventManager; + + emgr.onEvent( + // @ts-ignore + EntityManager.Events.MutatorInsertBefore, + async (event) => { + return { + ...event.params.data, + views: 2 + }; + }, + "sync" + ); + + const mutator = em.mutator("posts"); + const result = await mutator.insertOne({ title: "test", views: 1 }); + expect(result.data).toEqual({ + id: 1, + title: "test", + views: 2 + }); + }); + + test("updateOne event return is respected", async () => { + const posts = proto.entity("posts", { + title: proto.text(), + views: proto.number() + }); + + const conn = getDummyConnection(); + const em = new EntityManager([posts], conn.dummyConnection); + await em.schema().sync({ force: true }); + + const emgr = em.emgr as EventManager; + + emgr.onEvent( + // @ts-ignore + EntityManager.Events.MutatorUpdateBefore, + async (event) => { + return { + ...event.params.data, + views: event.params.data.views + 1 + }; + }, + "sync" + ); + + const mutator = em.mutator("posts"); + const created = await mutator.insertOne({ title: "test", views: 1 }); + const result = await mutator.updateOne(created.data.id, { views: 2 }); + expect(result.data).toEqual({ + id: 1, + title: "test", + views: 3 + }); + }); }); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index 0ce8da1..d873389 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -1,7 +1,6 @@ import { afterAll, describe, expect, test } from "bun:test"; -// @ts-ignore -import { Perf } from "@bknd/core/utils"; import type { Kysely, Transaction } from "kysely"; +import { Perf } from "../../../src/core/utils"; import { Entity, EntityManager, @@ -24,7 +23,7 @@ async function sleep(ms: number) { } describe("[Repository]", async () => { - test("bulk", async () => { + test.skip("bulk", async () => { //const connection = dummyConnection; //const connection = getLocalLibsqlConnection(); const credentials = null as any; // @todo: determine what to do here diff --git a/app/__test__/data/specs/WithBuilder.spec.ts b/app/__test__/data/specs/WithBuilder.spec.ts index 9141b62..7b64198 100644 --- a/app/__test__/data/specs/WithBuilder.spec.ts +++ b/app/__test__/data/specs/WithBuilder.spec.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; +import { _jsonp } from "../../../src/core/utils"; import { Entity, EntityManager, @@ -8,19 +9,56 @@ import { TextField, WithBuilder } from "../../../src/data"; +import * as proto from "../../../src/data/prototype"; +import { compileQb, prettyPrintQb, schemaToEm } from "../../helper"; import { getDummyConnection } from "../helper"; -const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterAll(afterAllCleanup); +const { dummyConnection } = getDummyConnection(); describe("[data] WithBuilder", async () => { + test("validate withs", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", {}), + users: proto.entity("users", {}), + media: proto.entity("media", {}) + }, + ({ relation }, { posts, users, media }) => { + relation(posts).manyToOne(users); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + } + ); + const em = schemaToEm(schema); + + expect(WithBuilder.validateWiths(em, "posts", undefined)).toBe(0); + expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0); + expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1); + expect( + WithBuilder.validateWiths(em, "posts", { + users: { + with: { avatar: {} } + } + }) + ).toBe(2); + expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow(); + expect(() => + WithBuilder.validateWiths(em, "posts", { + users: { + with: { glibberish: {} } + } + }) + ).toThrow(); + }); + test("missing relation", async () => { const users = new Entity("users", [new TextField("username")]); const em = new EntityManager([users], dummyConnection); expect(() => - WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"]) - ).toThrow('Relation "posts" not found'); + WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, { + posts: {} + }) + ).toThrow('Relation "users<>posts" not found'); }); test("addClause: ManyToOne", async () => { @@ -29,36 +67,39 @@ describe("[data] WithBuilder", async () => { const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })]; const em = new EntityManager([users, posts], dummyConnection, relations); - const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [ - "posts" - ]); + const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, { + posts: {} + }); const res = qb.compile(); expect(res.sql).toBe( - 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"' + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) as agg) as "posts" from "users"' ); - expect(res.parameters).toEqual([5]); + expect(res.parameters).toEqual([10, 0]); const qb2 = WithBuilder.addClause( em, em.connection.kysely.selectFrom("posts"), posts, // @todo: try with "users", it gives output! - ["author"] + { + author: {} + } ); const res2 = qb2.compile(); expect(res2.sql).toBe( - 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"' + 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"' ); - expect(res2.parameters).toEqual([1]); + expect(res2.parameters).toEqual([1, 0]); }); test("test with empty join", async () => { + const em = new EntityManager([], dummyConnection); const qb = { qb: 1 } as any; - expect(WithBuilder.addClause(null as any, qb, null as any, [])).toBe(qb); + expect(WithBuilder.addClause(em, qb, null as any, {})).toBe(qb); }); test("test manytomany", async () => { @@ -89,7 +130,7 @@ describe("[data] WithBuilder", async () => { //console.log((await em.repository().findMany("posts_categories")).result); - const res = await em.repository(posts).findMany({ with: ["categories"] }); + const res = await em.repository(posts).findMany({ with: { categories: {} } }); expect(res.data).toEqual([ { @@ -107,7 +148,7 @@ describe("[data] WithBuilder", async () => { } ]); - const res2 = await em.repository(categories).findMany({ with: ["posts"] }); + const res2 = await em.repository(categories).findMany({ with: { posts: {} } }); //console.log(res2.sql, res2.data); @@ -121,8 +162,8 @@ describe("[data] WithBuilder", async () => { id: 2, label: "beauty", posts: [ - { id: 2, title: "beauty post" }, - { id: 1, title: "fashion post" } + { id: 1, title: "fashion post" }, + { id: 2, title: "beauty post" } ] }, { @@ -150,25 +191,25 @@ describe("[data] WithBuilder", async () => { em, em.connection.kysely.selectFrom("categories"), categories, - ["single"] + { single: {} } ); const res = qb.compile(); expect(res.sql).toBe( - 'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"' + 'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"' ); - expect(res.parameters).toEqual(["categories.single", 1]); + expect(res.parameters).toEqual(["categories.single", 1, 0]); const qb2 = WithBuilder.addClause( em, em.connection.kysely.selectFrom("categories"), categories, - ["multiple"] + { multiple: {} } ); const res2 = qb2.compile(); expect(res2.sql).toBe( - 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"' + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"' ); - expect(res2.parameters).toEqual(["categories.multiple", 5]); + expect(res2.parameters).toEqual(["categories.multiple", 10, 0]); }); /*test("test manytoone", async () => { @@ -192,4 +233,205 @@ describe("[data] WithBuilder", async () => { const res = await em.repository().findMany("posts", { join: ["author"] }); console.log(res.sql, res.parameters, res.result); });*/ + + describe("recursive", () => { + test("compiles with singles", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", {}), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, users, media }) => { + relation(posts).manyToOne(users); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + } + ); + const em = schemaToEm(schema); + + const qb = WithBuilder.addClause( + em, + em.connection.kysely.selectFrom("posts"), + schema.entities.posts, + { + users: { + limit: 5, // ignored + select: ["id", "username"], + sort: { by: "username", dir: "asc" }, + with: { + avatar: { + select: ["id", "path"], + limit: 2 // ignored + } + } + } + } + ); + + //prettyPrintQb(qb); + expect(qb.compile().sql).toBe( + 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"' + ); + expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]); + }); + + test("compiles with many", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", {}), + comments: proto.entity("comments", {}), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, comments, users, media }) => { + relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + relation(comments).manyToOne(posts).manyToOne(users); + } + ); + const em = schemaToEm(schema); + + const qb = WithBuilder.addClause( + em, + em.connection.kysely.selectFrom("posts"), + schema.entities.posts, + { + comments: { + limit: 12, + with: { + users: { + select: ["username"] + } + } + } + } + ); + + expect(qb.compile().sql).toBe( + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"' + ); + expect(qb.compile().parameters).toEqual([1, 0, 12, 0]); + }); + + test("returns correct result", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", { + title: proto.text() + }), + comments: proto.entity("comments", { + content: proto.text() + }), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, comments, users, media }) => { + relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + relation(comments).manyToOne(posts).manyToOne(users); + } + ); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + // add data + await em.mutator("users").insertMany([{ username: "user1" }, { username: "user2" }]); + await em.mutator("posts").insertMany([ + { title: "post1", users_id: 1 }, + { title: "post2", users_id: 1 }, + { title: "post3", users_id: 2 } + ]); + await em.mutator("comments").insertMany([ + { content: "comment1", posts_id: 1, users_id: 1 }, + { content: "comment1-1", posts_id: 1, users_id: 1 }, + { content: "comment2", posts_id: 1, users_id: 2 }, + { content: "comment3", posts_id: 2, users_id: 1 }, + { content: "comment4", posts_id: 2, users_id: 2 }, + { content: "comment5", posts_id: 3, users_id: 1 }, + { content: "comment6", posts_id: 3, users_id: 2 } + ]); + + const result = await em.repo("posts").findMany({ + select: ["title"], + with: { + comments: { + limit: 2, + select: ["content"], + with: { + users: { + select: ["username"] + } + } + } + } + }); + + expect(result.data).toEqual([ + { + title: "post1", + comments: [ + { + content: "comment1", + users: { + username: "user1" + } + }, + { + content: "comment1-1", + users: { + username: "user1" + } + } + ] + }, + { + title: "post2", + comments: [ + { + content: "comment3", + users: { + username: "user1" + } + }, + { + content: "comment4", + users: { + username: "user2" + } + } + ] + }, + { + title: "post3", + comments: [ + { + content: "comment5", + users: { + username: "user1" + } + }, + { + content: "comment6", + users: { + username: "user2" + } + } + ] + } + ]); + //console.log(_jsonp(result.data)); + }); + }); }); diff --git a/app/__test__/data/specs/fields/EnumField.spec.ts b/app/__test__/data/specs/fields/EnumField.spec.ts index d60f2e7..7cde4eb 100644 --- a/app/__test__/data/specs/fields/EnumField.spec.ts +++ b/app/__test__/data/specs/fields/EnumField.spec.ts @@ -13,10 +13,6 @@ describe("[data] EnumField", async () => { { options: options(["a", "b", "c"]) } ); - test("yields if no options", async () => { - expect(() => new EnumField("test", { options: options([]) })).toThrow(); - }); - test("yields if default value is not a valid option", async () => { expect( () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }) diff --git a/app/__test__/data/specs/fields/Field.spec.ts b/app/__test__/data/specs/fields/Field.spec.ts index 6fd8e04..77eb3fd 100644 --- a/app/__test__/data/specs/fields/Field.spec.ts +++ b/app/__test__/data/specs/fields/Field.spec.ts @@ -15,11 +15,9 @@ describe("[data] Field", async () => { runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); - test.only("default config", async () => { - const field = new FieldSpec("test"); + test("default config", async () => { const config = Default(baseFieldConfigSchema, {}); expect(stripMark(new FieldSpec("test").config)).toEqual(config); - console.log("config", new TextField("test", { required: true }).toJSON()); }); test("transformPersist (specific)", async () => { diff --git a/app/__test__/data/specs/fields/JsonField.spec.ts b/app/__test__/data/specs/fields/JsonField.spec.ts index f13968a..17fdaaa 100644 --- a/app/__test__/data/specs/fields/JsonField.spec.ts +++ b/app/__test__/data/specs/fields/JsonField.spec.ts @@ -32,7 +32,7 @@ describe("[data] JsonField", async () => { }); test("getValue", async () => { - expect(field.getValue({ test: 1 }, "form")).toBe('{"test":1}'); + expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}'); expect(field.getValue("string", "form")).toBe('"string"'); expect(field.getValue(1, "form")).toBe("1"); diff --git a/app/__test__/data/specs/relations/EntityRelation.spec.ts b/app/__test__/data/specs/relations/EntityRelation.spec.ts index 989b4f9..92c50e3 100644 --- a/app/__test__/data/specs/relations/EntityRelation.spec.ts +++ b/app/__test__/data/specs/relations/EntityRelation.spec.ts @@ -70,9 +70,9 @@ describe("[data] EntityRelation", async () => { it("required", async () => { const relation1 = new TestEntityRelation(); - expect(relation1.config.required).toBe(false); + expect(relation1.required).toBe(false); const relation2 = new TestEntityRelation({ required: true }); - expect(relation2.config.required).toBe(true); + expect(relation2.required).toBe(true); }); }); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index e11da33..f07cd34 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -1,7 +1,9 @@ import { unlink } from "node:fs/promises"; -import type { SqliteDatabase } from "kysely"; +import type { SelectQueryBuilder, SqliteDatabase } from "kysely"; import Database from "libsql"; -import { SqliteLocalConnection } from "../src/data"; +import { format as sqlFormat } from "sql-formatter"; +import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data"; +import type { em as protoEm } from "../src/data/prototype"; export function getDummyDatabase(memory: boolean = true): { dummyDb: SqliteDatabase; @@ -51,3 +53,18 @@ export function enableConsoleLog() { console[severity as ConsoleSeverity] = fn; }); } + +export function compileQb(qb: SelectQueryBuilder) { + const { sql, parameters } = qb.compile(); + return { sql, parameters }; +} + +export function prettyPrintQb(qb: SelectQueryBuilder) { + const { sql, parameters } = qb.compile(); + console.log("$", sqlFormat(sql), "\n[params]", parameters); +} + +export function schemaToEm(s: ReturnType, conn?: Connection): EntityManager { + const connection = conn ? conn : getDummyConnection().dummyConnection; + return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices); +} diff --git a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts b/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts index 1294275..e2457b2 100644 --- a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts +++ b/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts @@ -3,7 +3,7 @@ import { randomString } from "../../../src/core/utils"; import { StorageCloudinaryAdapter } from "../../../src/media"; import { config } from "dotenv"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` }); +const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); const { CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts index 29746d1..a7c6d79 100644 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts @@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => { test("puts an object", async () => { objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, await file.arrayBuffer())).toBeString(); + expect(await adapter.putObject(filename, file)).toBeString(); }); test("lists objects", async () => { diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts index d6274dc..7ea77b1 100644 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ b/app/__test__/media/adapters/StorageS3Adapter.spec.ts @@ -3,14 +3,14 @@ import { randomString } from "../../../src/core/utils"; import { StorageS3Adapter } from "../../../src/media"; import { config } from "dotenv"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` }); +const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = dotenvOutput.parsed!; // @todo: mock r2/s3 responses for faster tests -const ALL_TESTS = process.env.ALL_TESTS; +const ALL_TESTS = !!process.env.ALL_TESTS; -describe("Storage", async () => { +describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { console.log("ALL_TESTS", process.env.ALL_TESTS); const versions = [ [ diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 225c9d6..f0ecc86 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createApp } from "../../src"; import { AuthController } from "../../src/auth/api/AuthController"; -import { em, entity, text } from "../../src/data"; +import { em, entity, make, text } from "../../src/data"; import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { disableConsoleLog, enableConsoleLog } from "../helper"; import { makeCtx, moduleTestSuite } from "./module-test-suite"; @@ -125,6 +125,40 @@ describe("AppAuth", () => { const fields = e.fields.map((f) => f.name); expect(e.type).toBe("system"); expect(fields).toContain("additional"); - expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]); + expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]); + }); + + test("ensure user field configs is always correct", async () => { + const app = createApp({ + initialConfig: { + auth: { + enabled: true + }, + data: em({ + users: entity("users", { + strategy: text({ + fillable: true, + hidden: false + }), + strategy_value: text({ + fillable: true, + hidden: false + }) + }) + }).toJSON() + } + }); + await app.build(); + + const users = app.em.entity("users"); + const props = ["hidden", "fillable", "required"]; + + for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) { + const authField = make(name, _authFieldProto as any); + const field = users.field(name)!; + for (const prop of props) { + expect(field.config[prop]).toBe(authField.config[prop]); + } + } }); }); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index 19fa73b..b5ce17f 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -39,6 +39,7 @@ describe("AppMedia", () => { expect(fields).toContain("additional"); expect(fields).toEqual([ "id", + "additional", "path", "folder", "mime_type", @@ -48,8 +49,7 @@ describe("AppMedia", () => { "modified_at", "reference", "entity_id", - "metadata", - "additional" + "metadata" ]); }); }); diff --git a/app/__test__/modules/Module.spec.ts b/app/__test__/modules/Module.spec.ts index 572c5a1..5c20ca5 100644 --- a/app/__test__/modules/Module.spec.ts +++ b/app/__test__/modules/Module.spec.ts @@ -157,8 +157,7 @@ describe("Module", async () => { entities: [ { name: "u", - // ensured properties must come first - fields: ["id", "important", "name"], + fields: ["id", "name", "important"], // ensured type must be present type: "system" }, diff --git a/app/build.ts b/app/build.ts index db8dae3..e931021 100644 --- a/app/build.ts +++ b/app/build.ts @@ -15,7 +15,7 @@ if (clean) { let types_running = false; function buildTypes() { - if (types_running) return; + if (types_running || !types) return; types_running = true; Bun.spawn(["bun", "build:types"], { @@ -76,12 +76,7 @@ await tsup.build({ minify, sourcemap, watch, - entry: [ - "src/ui/index.ts", - "src/ui/client/index.ts", - "src/ui/elements/index.ts", - "src/ui/main.css" - ], + entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"], outDir: "dist/ui", external: [ "bun:test", @@ -89,21 +84,68 @@ await tsup.build({ "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", + "use-sync-external-store", + /codemirror/, + "@xyflow/react", + "@mantine/core" + ], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + bundle: true, + treeshake: true, + loader: { + ".svg": "dataurl" + }, + esbuildOptions: (options) => { + options.logLevel = "silent"; + }, + onSuccess: async () => { + delayTypes(); + } +}); + +/** + * Building UI Elements + * - tailwind-merge is mocked, no exclude + * - ui/client is external, and after built replaced with "bknd/client" + */ +await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/ui/elements/index.ts"], + outDir: "dist/ui/elements", + external: [ + "ui/client", + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", "use-sync-external-store" ], metafile: true, platform: "browser", format: ["esm"], - splitting: true, + splitting: false, + bundle: true, treeshake: true, loader: { ".svg": "dataurl" }, esbuildOptions: (options) => { - options.logLevel = "silent"; - options.chunkNames = "chunks/[name]-[hash]"; + options.alias = { + // not important for elements, mock to reduce bundle + "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts" + }; }, onSuccess: async () => { + // manually replace ui/client with bknd/client + const path = "./dist/ui/elements/index.js"; + const bundle = await Bun.file(path).text(); + await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client")); + delayTypes(); } }); diff --git a/app/bunfig.toml b/app/bunfig.toml index 82e1cd0..6f4fe9a 100644 --- a/app/bunfig.toml +++ b/app/bunfig.toml @@ -1,2 +1,5 @@ [install] -registry = "http://localhost:4873" \ No newline at end of file +#registry = "http://localhost:4873" + +[test] +coverageSkipTestFiles = true \ No newline at end of file diff --git a/app/package.json b/app/package.json index 8baeefd..e157b58 100644 --- a/app/package.json +++ b/app/package.json @@ -3,10 +3,20 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.5.0", + "version": "0.6.0", + "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": { + "type": "git", + "url": "https://github.com/bknd-io/bknd.git" + }, + "bugs": { + "url": "https://github.com/bknd-io/bknd/issues" + }, "scripts": { "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", + "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", @@ -17,7 +27,8 @@ "build:types": "tsc --emitDeclarationOnly && tsc-alias", "updater": "bun x npm-check-updates -ui", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run test && bun run build:all" + "prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./", + "postpublish": "rm -f README.md" }, "license": "FSL-1.1-MIT", "dependencies": { @@ -34,34 +45,32 @@ "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "swr": "^2.2.5", - "json-schema-form-react": "^0.0.2" - }, - "devDependencies": { - "@aws-sdk/client-s3": "^3.613.0", + "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" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.613.0", "@dagrejs/dagre": "^1.1.4", - "@hello-pangea/dnd": "^17.0.0", "@hono/typebox-validator": "^0.2.6", "@hono/vite-dev-server": "^0.17.0", "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", "@libsql/kysely-libsql": "^0.4.1", - "@mantine/core": "^7.13.4", - "@mantine/hooks": "^7.13.4", - "@mantine/modals": "^7.13.4", - "@mantine/notifications": "^7.13.5", "@radix-ui/react-scroll-area": "^1.2.0", "@rjsf/core": "^5.22.2", "@tabler/icons-react": "3.18.0", "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@uiw/react-codemirror": "^4.23.6", "@vitejs/plugin-react": "^4.3.3", - "@xyflow/react": "^12.3.2", "autoprefixer": "^10.4.20", + "clsx": "^2.1.1", "esbuild-postcss": "^0.0.4", "jotai": "^2.10.1", "open": "^10.1.0", @@ -72,6 +81,7 @@ "react-hook-form": "^7.53.1", "react-icons": "5.2.1", "react-json-view-lite": "^2.0.1", + "sql-formatter": "^15.4.9", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", "tailwindcss-animate": "^1.0.7", @@ -168,7 +178,8 @@ "import": "./dist/adapter/astro/index.js", "require": "./dist/adapter/astro/index.cjs" }, - "./dist/styles.css": "./dist/ui/main.css", + "./dist/main.css": "./dist/ui/main.css", + "./dist/styles.css": "./dist/ui/styles.css", "./dist/manifest.json": "./dist/static/.vite/manifest.json" }, "publishConfig": { @@ -182,5 +193,21 @@ "!dist/**/*.map", "!dist/metafile*", "!dist/**/metafile*" + ], + "keywords": [ + "backend", + "database", + "authentication", + "media", + "workflows", + "api", + "jwt", + "serverless", + "cloudflare", + "nextjs", + "remix", + "astro", + "bun", + "node" ] } diff --git a/app/src/Api.ts b/app/src/Api.ts index 5e288fe..835ff14 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -128,15 +128,17 @@ export class Api { }; } - async getVerifiedAuthState(force?: boolean): Promise { - if (force === true || !this.verified) { - await this.verifyAuth(); - } - + async getVerifiedAuthState(): Promise { + await this.verifyAuth(); return this.getAuthState(); } async verifyAuth() { + if (!this.token) { + this.markAuthVerified(false); + return; + } + try { const res = await this.auth.me(); if (!res.ok || !res.body.user) { diff --git a/app/src/App.ts b/app/src/App.ts index 2df3e84..b98fc67 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,8 +1,5 @@ import type { CreateUserPayload } from "auth/AppAuth"; -import { auth } from "auth/middlewares"; -import { config } from "core"; import { Event } from "core/events"; -import { patternMatch } from "core/utils"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { type InitialModuleConfigs, diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index dfbe1f3..ca5b919 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,11 +1,16 @@ -import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; +import { + type AuthAction, + AuthPermissions, + Authenticator, + type ProfileExchange, + Role, + type Strategy +} from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { auth } from "auth/middlewares"; import { type DB, Exception, type PrimaryFieldType } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; -import { type Entity, EntityIndex, type EntityManager } from "data"; -import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; -import type { Hono } from "hono"; +import type { Entity, EntityManager } from "data"; +import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; @@ -79,8 +84,8 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); - //this.ctx.server.use(controller.getMiddleware); this.ctx.server.route(this.config.basepath, this._controller.getController()); + this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); } get controller(): AuthController { @@ -219,10 +224,23 @@ export class AppAuth extends Module { } private toggleStrategyValueVisibility(visible: boolean) { - const field = this.getUsersEntity().field("strategy_value")!; + const toggle = (name: string, visible: boolean) => { + const field = this.getUsersEntity().field(name)!; + + if (visible) { + field.config.hidden = false; + field.config.fillable = true; + } else { + // reset to normal + const template = AppAuth.usersFields.strategy_value.config; + field.config.hidden = template.hidden; + field.config.fillable = template.fillable; + } + }; + + toggle("strategy_value", visible); + toggle("strategy", visible); - field.config.hidden = !visible; - field.config.fillable = visible; // @todo: think about a PasswordField that automatically hashes on save? } @@ -237,7 +255,10 @@ export class AppAuth extends Module { static usersFields = { email: text().required(), - strategy: text({ fillable: ["create"], hidden: ["form"] }).required(), + strategy: text({ + fillable: ["create"], + hidden: ["update", "form"] + }).required(), strategy_value: text({ fillable: ["create"], hidden: ["read", "table", "update", "form"] @@ -260,14 +281,12 @@ export class AppAuth extends Module { try { const roles = Object.keys(this.config.roles ?? {}); - const field = make("role", enumm({ enum: roles })); - users.__replaceField("role", field); + this.replaceEntityField(users, "role", enumm({ enum: roles })); } catch (e) {} try { const strategies = Object.keys(this.config.strategies ?? {}); - const field = make("strategy", enumm({ enum: strategies })); - users.__replaceField("strategy", field); + this.replaceEntityField(users, "strategy", enumm({ enum: strategies })); } catch (e) {} } diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index 7b43d6d..d02a258 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -1,4 +1,5 @@ -import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema"; +import type { AuthActionResponse } from "auth/api/AuthController"; +import type { AppAuthSchema } from "auth/auth-schema"; import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; @@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi { }; } - async loginWithPassword(input: any) { - const res = await this.post(["password", "login"], input); + async login(strategy: string, input: any) { + const res = await this.post([strategy, "login"], input); if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; } - async registerWithPassword(input: any) { - const res = await this.post(["password", "register"], input); + async register(strategy: string, input: any) { + const res = await this.post([strategy, "register"], input); if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; } + async actionSchema(strategy: string, action: string) { + return this.get([strategy, "actions", action, "schema.json"]); + } + + async action(strategy: string, action: string, input: any) { + return this.post([strategy, "actions", action], input); + } + + /** + * @deprecated use login("password", ...) instead + * @param input + */ + async loginWithPassword(input: any) { + return this.login("password", input); + } + + /** + * @deprecated use register("password", ...) instead + * @param input + */ + async registerWithPassword(input: any) { + return this.register("password", input); + } + me() { return this.get<{ user: SafeUser | null }>(["me"]); } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 553c477..82b50e1 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,5 +1,16 @@ -import type { AppAuth } from "auth"; +import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; +import { TypeInvalidError, parse } from "core/utils"; +import { DataPermissions } from "data"; +import type { Hono } from "hono"; import { Controller } from "modules/Controller"; +import type { ServerEnv } from "modules/Module"; + +export type AuthActionResponse = { + success: boolean; + action: string; + data?: SafeUser; + errors?: any; +}; export class AuthController extends Controller { constructor(private auth: AppAuth) { @@ -10,6 +21,68 @@ export class AuthController extends Controller { return this.auth.ctx.guard; } + private registerStrategyActions(strategy: Strategy, mainHono: Hono) { + const actions = strategy.getActions?.(); + if (!actions) { + return; + } + + const { auth, permission } = this.middlewares; + const hono = this.create().use(auth()); + + const name = strategy.getName(); + const { create, change } = actions; + const em = this.auth.em; + + if (create) { + hono.post( + "/create", + permission([AuthPermissions.createUser, DataPermissions.entityCreate]), + async (c) => { + try { + const body = await this.auth.authenticator.getBody(c); + const valid = parse(create.schema, body, { + skipMark: true + }); + const processed = (await create.preprocess?.(valid)) ?? valid; + + // @todo: check processed for "role" and check permissions + const mutator = em.mutator(this.auth.config.entity_name as "users"); + mutator.__unstable_toggleSystemEntityCreation(false); + const { data: created } = await mutator.insertOne({ + ...processed, + strategy: name + }); + mutator.__unstable_toggleSystemEntityCreation(true); + + return c.json({ + success: true, + action: "create", + strategy: name, + data: created as unknown as SafeUser + } as AuthActionResponse); + } catch (e) { + if (e instanceof TypeInvalidError) { + return c.json( + { + success: false, + errors: e.errors + }, + 400 + ); + } + throw e; + } + } + ); + hono.get("create/schema.json", async (c) => { + return c.json(create.schema); + }); + } + + mainHono.route(`/${name}/actions`, hono); + } + override getController() { const { auth } = this.middlewares; const hono = this.create(); @@ -18,11 +91,12 @@ export class AuthController extends Controller { for (const [name, strategy] of Object.entries(strategies)) { //console.log("registering", name, "at", `/${name}`); hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); + this.registerStrategyActions(strategy, hono); } hono.get("/me", auth(), async (c) => { if (this.auth.authenticator.isUserLoggedIn()) { - return c.json({ user: await this.auth.authenticator.getUser() }); + return c.json({ user: this.auth.authenticator.getUser() }); } return c.json({ user: null }, 403); diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts new file mode 100644 index 0000000..ed71992 --- /dev/null +++ b/app/src/auth/auth-permissions.ts @@ -0,0 +1,4 @@ +import { Permission } from "core"; + +export const createUser = new Permission("auth.user.create"); +//export const updateUser = new Permission("auth.user.update"); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 0dc479d..7b81ed7 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,6 +1,14 @@ -import { Exception } from "core"; +import { type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; -import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils"; +import { + type Static, + StringEnum, + type TObject, + Type, + parse, + runtimeSupports, + transformObject +} from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; @@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module"; type Input = any; // workaround export type JWTPayload = Parameters[0]; +export const strategyActions = ["create", "change"] as const; +export type StrategyActionName = (typeof strategyActions)[number]; +export type StrategyAction = { + schema: S; + preprocess: (input: unknown) => Promise>; +}; +export type StrategyActions = Partial>; + // @todo: add schema to interface to ensure proper inference export interface Strategy { getController: (auth: Authenticator) => Hono; @@ -17,6 +33,7 @@ export interface Strategy { getMode: () => "form" | "external"; getName: () => string; toJSON: (secrets?: boolean) => any; + getActions?: () => StrategyActions; } export type User = { @@ -274,6 +291,14 @@ export class Authenticator = Record< return c.req.header("Content-Type") === "application/json"; } + async getBody(c: Context) { + if (this.isJsonRequest(c)) { + return await c.req.json(); + } else { + return Object.fromEntries((await c.req.formData()).entries()); + } + } + private getSuccessPath(c: Context) { const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); @@ -338,3 +363,13 @@ export class Authenticator = Record< }; } } + +export function createStrategyAction( + schema: S, + preprocess: (input: Static) => Promise> +) { + return { + schema, + preprocess + } as StrategyAction; +} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index ef940d7..d8f8a23 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -2,6 +2,7 @@ import type { Authenticator, Strategy } from "auth"; import { type Static, StringEnum, Type, parse } from "core/utils"; import { hash } from "core/utils"; import { type Context, Hono } from "hono"; +import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator"; type LoginSchema = { username: string; password: string } | { email: string; password: string }; type RegisterSchema = { email: string; password: string; [key: string]: any }; @@ -54,17 +55,9 @@ export class PasswordStrategy implements Strategy { getController(authenticator: Authenticator): Hono { const hono = new Hono(); - async function getBody(c: Context) { - if (authenticator.isJsonRequest(c)) { - return await c.req.json(); - } else { - return Object.fromEntries((await c.req.formData()).entries()); - } - } - return hono .post("/login", async (c) => { - const body = await getBody(c); + const body = await authenticator.getBody(c); try { const payload = await this.login(body); @@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy { } }) .post("/register", async (c) => { - const body = await getBody(c); + const body = await authenticator.getBody(c); const payload = await this.register(body); const data = await authenticator.resolve("register", this, payload.password, payload); @@ -85,6 +78,27 @@ export class PasswordStrategy implements Strategy { }); } + getActions(): StrategyActions { + return { + create: createStrategyAction( + Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$" + }), + password: Type.String({ + minLength: 8 // @todo: this should be configurable + }) + }), + async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password) + }; + } + ) + }; + } + getSchema() { return schema; } diff --git a/app/src/auth/index.ts b/app/src/auth/index.ts index fbb47fb..11c3367 100644 --- a/app/src/auth/index.ts +++ b/app/src/auth/index.ts @@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth"; export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard"; export { Role } from "./authorize/Role"; + +export * as AuthPermissions from "./auth-permissions"; diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 50fd2d4..f36c94b 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -26,25 +26,28 @@ export const auth = (options?: { skip?: (string | RegExp)[]; }) => createMiddleware(async (c, next) => { - // make sure to only register once - if (c.get("auth_registered")) { - throw new Error(`auth middleware already registered for ${getPath(c)}`); - } - c.set("auth_registered", true); - const app = c.get("app"); - const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; const guard = app?.modules.ctx().guard; const authenticator = app?.module.auth.authenticator; - if (!skipped) { - const resolved = c.get("auth_resolved"); - if (!resolved) { - if (!app.module.auth.enabled) { - guard?.setUserContext(undefined); - } else { - guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); - c.set("auth_resolved", true); + let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; + + // make sure to only register once + if (c.get("auth_registered")) { + skipped = true; + console.warn(`auth middleware already registered for ${getPath(c)}`); + } else { + c.set("auth_registered", true); + + if (!skipped) { + const resolved = c.get("auth_resolved"); + if (!resolved) { + if (!app?.module.auth.enabled) { + guard?.setUserContext(undefined); + } else { + guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); + c.set("auth_resolved", true); + } } } } diff --git a/app/src/core/config.ts b/app/src/core/config.ts index a99d549..2f2cf06 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -5,8 +5,13 @@ import type { Generated } from "kysely"; export type PrimaryFieldType = number | Generated; -// biome-ignore lint/suspicious/noEmptyInterface: -export interface DB {} +export interface DB { + // make sure to make unknown as "any" + [key: string]: { + id: PrimaryFieldType; + [key: string]: any; + }; +} export const config = { server: { diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts index 247c7a5..8073c12 100644 --- a/app/src/core/events/Event.ts +++ b/app/src/core/events/Event.ts @@ -1,17 +1,38 @@ -export abstract class Event { +export type EventClass = { + new (params: any): Event; + slug: string; +}; + +export abstract class Event { + _returning!: Returning; + /** * Unique event slug * Must be static, because registering events is done by class */ static slug: string = "untitled-event"; params: Params; + returned: boolean = false; + + validate(value: Returning): Event | void { + throw new EventReturnedWithoutValidation(this as any, value); + } + + protected clone = Event>( + this: This, + params: Params + ): This { + const cloned = new (this.constructor as any)(params); + cloned.returned = true; + return cloned as This; + } constructor(params: Params) { this.params = params; } } -// @todo: current workaround: potentially there is none and that's the way +// @todo: current workaround: potentially there is "none" and that's the way export class NoParamEvent extends Event { static override slug: string = "noparam-event"; @@ -19,3 +40,19 @@ export class NoParamEvent extends Event { super(null); } } + +export class InvalidEventReturn extends Error { + constructor(expected: string, given: string) { + super(`Expected "${expected}", got "${given}"`); + } +} + +export class EventReturnedWithoutValidation extends Error { + constructor( + event: EventClass, + public data: any + ) { + // @ts-expect-error slug is static + super(`Event "${event.constructor.slug}" returned without validation`); + } +} diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 951fce8..fc677ed 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager"; export const ListenerModes = ["sync", "async"] as const; export type ListenerMode = (typeof ListenerModes)[number]; -export type ListenerHandler = ( +export type ListenerHandler> = ( event: E, - slug: string, -) => Promise | void; + slug: string +) => E extends Event ? R | Promise : never; export class EventListener { mode: ListenerMode = "async"; event: EventClass; handler: ListenerHandler; + once: boolean = false; constructor(event: EventClass, handler: ListenerHandler, mode: ListenerMode = "async") { this.event = event; diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 9233666..73764ea 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,14 +1,19 @@ -import type { Event } from "./Event"; +import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; +export type RegisterListenerConfig = + | ListenerMode + | { + mode?: ListenerMode; + once?: boolean; + }; + export interface EmitsEvents { emgr: EventManager; } -export type EventClass = { - new (params: any): Event; - slug: string; -}; +// for compatibility, moved it to Event.ts +export type { EventClass }; export class EventManager< RegisteredEvents extends Record = Record @@ -17,16 +22,20 @@ export class EventManager< protected listeners: EventListener[] = []; enabled: boolean = true; - constructor(events?: RegisteredEvents, listeners?: EventListener[]) { + constructor( + events?: RegisteredEvents, + private options?: { + listeners?: EventListener[]; + onError?: (event: Event, e: unknown) => void; + onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void; + asyncExecutor?: typeof Promise.all; + } + ) { if (events) { this.registerEvents(events); } - if (listeners) { - for (const listener of listeners) { - this.addListener(listener); - } - } + options?.listeners?.forEach((l) => this.addListener(l)); } enable() { @@ -82,9 +91,11 @@ export class EventManager< return !!this.events.find((e) => slug === e.slug); } - protected throwIfEventNotRegistered(event: EventClass) { - if (!this.eventExists(event)) { - throw new Error(`Event "${event.slug}" not registered`); + protected throwIfEventNotRegistered(event: EventClass | Event | string) { + if (!this.eventExists(event as any)) { + // @ts-expect-error + const name = event.constructor?.slug ?? event.slug ?? event; + throw new Error(`Event "${name}" not registered`); } } @@ -117,55 +128,108 @@ export class EventManager< return this; } + protected createEventListener( + _event: EventClass | string, + handler: ListenerHandler, + _config: RegisterListenerConfig = "async" + ) { + const event = + typeof _event === "string" ? this.events.find((e) => e.slug === _event)! : _event; + const config = typeof _config === "string" ? { mode: _config } : _config; + const listener = new EventListener(event, handler, config.mode); + if (config.once) { + listener.once = true; + } + this.addListener(listener as any); + } + onEvent>( event: ActualEvent, handler: ListenerHandler, - mode: ListenerMode = "async" + config?: RegisterListenerConfig ) { - this.throwIfEventNotRegistered(event); - - const listener = new EventListener(event, handler, mode); - this.addListener(listener as any); + this.createEventListener(event, handler, config); } on( slug: string, handler: ListenerHandler>, - mode: ListenerMode = "async" + config?: RegisterListenerConfig ) { - const event = this.events.find((e) => e.slug === slug); - if (!event) { - throw new Error(`Event "${slug}" not registered`); - } - - this.onEvent(event, handler, mode); + this.createEventListener(slug, handler, config); } - onAny(handler: ListenerHandler>, mode: ListenerMode = "async") { - this.events.forEach((event) => this.onEvent(event, handler, mode)); + onAny(handler: ListenerHandler>, config?: RegisterListenerConfig) { + this.events.forEach((event) => this.onEvent(event, handler, config)); } - async emit(event: Event) { + protected executeAsyncs(promises: (() => Promise)[]) { + const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e)); + executor(promises.map((p) => p())).then(() => void 0); + } + + async emit>(event: Actual): Promise { // @ts-expect-error slug is static const slug = event.constructor.slug; if (!this.enabled) { console.log("EventManager disabled, not emitting", slug); - return; + return event; } if (!this.eventExists(event)) { throw new Error(`Event "${slug}" not registered`); } - const listeners = this.listeners.filter((listener) => listener.event.slug === slug); - //console.log("---!-- emitting", slug, listeners.length); + const syncs: EventListener[] = []; + const asyncs: (() => Promise)[] = []; + + this.listeners = this.listeners.filter((listener) => { + // if no match, keep and ignore + if (listener.event.slug !== slug) return true; - for (const listener of listeners) { if (listener.mode === "sync") { - await listener.handler(event, listener.event.slug); + syncs.push(listener); } else { - listener.handler(event, listener.event.slug); + asyncs.push(async () => await listener.handler(event, listener.event.slug)); + } + // Remove if `once` is true, otherwise keep + return !listener.once; + }); + + // execute asyncs + this.executeAsyncs(asyncs); + + // execute syncs + let _event: Actual = event; + for (const listener of syncs) { + try { + const return_value = (await listener.handler(_event, listener.event.slug)) as any; + + if (typeof return_value !== "undefined") { + const newEvent = _event.validate(return_value); + // @ts-expect-error slug is static + if (newEvent && newEvent.constructor.slug === slug) { + if (!newEvent.returned) { + throw new Error( + // @ts-expect-error slug is static + `Returned event ${newEvent.constructor.slug} must be marked as returned.` + ); + } + _event = newEvent as Actual; + } + } + } catch (e) { + if (e instanceof InvalidEventReturn) { + this.options?.onInvalidReturn?.(_event, e); + console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); + } else if (this.options?.onError) { + this.options.onError(_event, e); + } else { + throw e; + } } } + + return _event; } } diff --git a/app/src/core/events/index.ts b/app/src/core/events/index.ts index b823edf..1edb065 100644 --- a/app/src/core/events/index.ts +++ b/app/src/core/events/index.ts @@ -1,8 +1,8 @@ -export { Event, NoParamEvent } from "./Event"; +export { Event, NoParamEvent, InvalidEventReturn } from "./Event"; export { EventListener, ListenerModes, type ListenerMode, - type ListenerHandler, + type ListenerHandler } from "./EventListener"; export { EventManager, type EmitsEvents, type EventClass } from "./EventManager"; diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index aad5b14..7c2e926 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -130,7 +130,10 @@ export class SchemaObject { //console.log("overwritePaths", this.options?.overwritePaths); if (this.options?.overwritePaths) { - const keys = getFullPathKeys(value).map((k) => path + "." + k); + const keys = getFullPathKeys(value).map((k) => { + // only prepend path if given + return path.length > 0 ? path + "." + k : k; + }); const overwritePaths = keys.filter((k) => { return this.options?.overwritePaths?.some((p) => { if (typeof p === "string") { diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index 07a4c3b..e30979e 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -49,7 +49,7 @@ type LiteralExpressionCondition = { [key: string]: Primitive | ExpressionCondition; }; -const OperandOr = "$or"; +const OperandOr = "$or" as const; type OperandCondition = { [OperandOr]?: LiteralExpressionCondition | ExpressionCondition; }; diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index 85809e2..c2239e4 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -12,3 +12,4 @@ export * from "./uuid"; export { FromSchema } from "./typebox/from-schema"; export * from "./test"; export * from "./runtime"; +export * from "./numbers"; diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts new file mode 100644 index 0000000..1435f68 --- /dev/null +++ b/app/src/core/utils/numbers.ts @@ -0,0 +1,5 @@ +export function clampNumber(value: number, min: number, max: number): number { + const lower = Math.min(min, max); + const upper = Math.max(min, max); + return Math.max(lower, Math.min(value, upper)); +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index 2e08d7a..a793e33 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -115,6 +115,7 @@ export function parse( } else if (options?.onError) { options.onError(Errors(schema, data)); } else { + //console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2)); throw new TypeInvalidError(schema, data); } diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index df90b57..210c834 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -69,18 +69,9 @@ export class AppData extends Module { } override getOverwritePaths() { - return [ - /^entities\..*\.config$/, - /^entities\..*\.fields\..*\.config$/ - ///^entities\..*\.fields\..*\.config\.schema$/ - ]; + return [/^entities\..*\.config$/, /^entities\..*\.fields\..*\.config$/]; } - /*registerController(server: AppServer) { - console.log("adding data controller to", this.basepath); - server.add(this.basepath, new DataController(this.em)); - }*/ - override toJSON(secrets?: boolean): AppDataConfig { return { ...this.config, diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 47144c2..e444092 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,15 +1,17 @@ import type { DB } from "core"; -import type { EntityData, RepoQuery, RepositoryResponse } from "data"; +import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; export type DataApiOptions = BaseModuleApiOptions & { - defaultQuery?: Partial; + queryLengthLimit: number; + defaultQuery: Partial; }; export class DataApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/data", + queryLengthLimit: 1000, defaultQuery: { limit: 10 } @@ -19,26 +21,32 @@ export class DataApi extends ModuleApi { readOne( entity: E, id: PrimaryFieldType, - query: Partial> = {} + query: Omit = {} ) { return this.get, "meta" | "data">>([entity as any, id], query); } readMany( entity: E, - query: Partial = {} + query: RepoQueryIn = {} ) { - return this.get, "meta" | "data">>( - [entity as any], - query ?? this.options.defaultQuery - ); + type T = Pick, "meta" | "data">; + + const input = query ?? this.options.defaultQuery; + const req = this.get([entity as any], input); + + if (req.request.url.length <= this.options.queryLengthLimit) { + return req; + } + + return this.post([entity as any, "query"], input); } readManyByReference< E extends keyof DB | string, R extends keyof DB | string, Data = R extends keyof DB ? DB[R] : EntityData - >(entity: E, id: PrimaryFieldType, reference: R, query: Partial = {}) { + >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) { return this.get, "meta" | "data">>( [entity as any, id, reference], query ?? this.options.defaultQuery diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 497ffa9..131f3d6 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -70,7 +70,7 @@ export class DataController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth()); + const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); const definedEntities = this.em.entities.map((e) => e.name); const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) @@ -85,8 +85,6 @@ export class DataController extends Controller { return func; } - hono.use("*", permission(SystemPermissions.accessApi)); - // info hono.get( "/", @@ -283,7 +281,7 @@ export class DataController extends Controller { return c.notFound(); } const options = (await c.req.valid("json")) as RepoQuery; - console.log("options", options); + //console.log("options", options); const result = await this.em.repository(entity).findMany(options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); diff --git a/app/src/data/connection/SqliteLocalConnection.ts b/app/src/data/connection/SqliteLocalConnection.ts index 0b1a8c8..b3bfab3 100644 --- a/app/src/data/connection/SqliteLocalConnection.ts +++ b/app/src/data/connection/SqliteLocalConnection.ts @@ -1,6 +1,5 @@ -import type { DatabaseIntrospector, SqliteDatabase } from "kysely"; +import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely"; import { Kysely, SqliteDialect } from "kysely"; -import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin"; import { SqliteConnection } from "./SqliteConnection"; import { SqliteIntrospector } from "./SqliteIntrospector"; @@ -14,7 +13,7 @@ class CustomSqliteDialect extends SqliteDialect { export class SqliteLocalConnection extends SqliteConnection { constructor(private database: SqliteDatabase) { - const plugins = [new DeserializeJsonValuesPlugin()]; + const plugins = [new ParseJSONResultsPlugin()]; const kysely = new Kysely({ dialect: new CustomSqliteDialect({ database }), plugins diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index a87d609..3365190 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -98,8 +98,8 @@ export class Entity< getDefaultSort() { return { - by: this.config.sort_field, - dir: this.config.sort_dir + by: this.config.sort_field ?? "id", + dir: this.config.sort_dir ?? "asc" }; } @@ -192,14 +192,41 @@ export class Entity< this.data = data; } - isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean { + // @todo: add tests + isValidData( + data: EntityData, + context: TActionContext, + options?: { + explain?: boolean; + ignoreUnknown?: boolean; + } + ): boolean { + if (typeof data !== "object") { + if (options?.explain) { + throw new Error(`Entity "${this.name}" data must be an object`); + } + } + const fields = this.getFillableFields(context, false); - //const fields = this.fields; - //console.log("data", data); + + if (options?.ignoreUnknown !== true) { + const field_names = fields.map((f) => f.name); + const given_keys = Object.keys(data); + const unknown_keys = given_keys.filter((key) => !field_names.includes(key)); + + if (unknown_keys.length > 0) { + if (options?.explain) { + throw new Error( + `Entity "${this.name}" data must only contain known keys, unknown: "${unknown_keys}"` + ); + } + } + } + for (const field of fields) { if (!field.isValid(data[field.name], context)) { console.log("Entity.isValidData:invalid", context, field.name, data[field.name]); - if (explain) { + if (options?.explain) { throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`); } diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index f8dfd7b..6018029 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -111,15 +111,18 @@ export class EntityManager { // caused issues because this.entity() was using a reference (for when initial config was given) } - entity(e: Entity | keyof TBD | string): Entity { + entity( + e: Entity | keyof TBD | string, + silent?: Silent + ): Silent extends true ? Entity | undefined : Entity { // make sure to always retrieve by name const entity = this.entities.find((entity) => e instanceof Entity ? entity.name === e.name : entity.name === e ); if (!entity) { - // @ts-ignore - throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); + if (silent === true) return undefined as any; + throw new EntityNotDefinedException(e instanceof Entity ? e.name : (e as string)); } return entity; diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index d9bff38..15760bc 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -132,14 +132,17 @@ export class Mutator< throw new Error(`Creation of system entity "${entity.name}" is disabled`); } - // @todo: establish the original order from "data" + const result = await this.emgr.emit( + new Mutator.Events.MutatorInsertBefore({ entity, data: data as any }) + ); + + // if listener returned, take what's returned + const _data = result.returned ? result.params.data : data; const validatedData = { ...entity.getDefaultObject(), - ...(await this.getValidatedData(data, "create")) + ...(await this.getValidatedData(_data, "create")) }; - await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData })); - // check if required fields are present const required = entity.getRequiredFields(); for (const field of required) { @@ -169,16 +172,17 @@ export class Mutator< throw new Error("ID must be provided for update"); } - const validatedData = await this.getValidatedData(data, "update"); - - await this.emgr.emit( + const result = await this.emgr.emit( new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, - data: validatedData as any + data }) ); + const _data = result.returned ? result.params.data : data; + const validatedData = await this.getValidatedData(_data, "update"); + const query = this.conn .updateTable(entity.name) .set(validatedData as any) diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index a6dc576..5234bc4 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -65,7 +65,7 @@ export class Repository): RepoQuery { + getValidOptions(options?: Partial): RepoQuery { const entity = this.entity; // @todo: if not cloned deep, it will keep references and error if multiple requests come in const validated = { @@ -103,17 +103,10 @@ export class Repository 0) { - for (const entry of options.with) { - const related = this.em.relationOf(entity.name, entry); - if (!related) { - throw new InvalidSearchParamsException( - `WITH: "${entry}" is not a relation of "${entity.name}"` - ); - } - - validated.with.push(entry); - } + if (options.with) { + const depth = WithBuilder.validateWiths(this.em, entity.name, options.with); + // @todo: determine allowed depth + validated.with = options.with; } if (options.join && options.join.length > 0) { @@ -235,43 +228,79 @@ export class Repository, - exclude_options: (keyof RepoQuery)[] = [] - ): { qb: RepositoryQB; options: RepoQuery } { + config?: { + validate?: boolean; + ignore?: (keyof RepoQuery)[]; + alias?: string; + defaults?: Pick; + } + ) { const entity = this.entity; - const options = this.getValidOptions(_options); + let qb = _qb ?? (this.conn.selectFrom(entity.name) as RepositoryQB); - const alias = entity.name; + const options = config?.validate !== false ? this.getValidOptions(_options) : _options; + if (!options) return qb; + + const alias = config?.alias ?? entity.name; const aliased = (field: string) => `${alias}.${field}`; - let qb = this.conn - .selectFrom(entity.name) - .select(entity.getAliasedSelectFrom(options.select, alias)); + const ignore = config?.ignore ?? []; + const defaults = { + limit: 10, + offset: 0, + ...config?.defaults + }; - //console.log("build query options", options); - if (!exclude_options.includes("with") && options.with) { + /*console.log("build query options", { + entity: entity.name, + options, + config + });*/ + + if (!ignore.includes("select") && options.select) { + qb = qb.select(entity.getAliasedSelectFrom(options.select, alias)); + } + + if (!ignore.includes("with") && options.with) { qb = WithBuilder.addClause(this.em, qb, entity, options.with); } - if (!exclude_options.includes("join") && options.join) { + if (!ignore.includes("join") && options.join) { qb = JoinBuilder.addClause(this.em, qb, entity, options.join); } // add where if present - if (!exclude_options.includes("where") && options.where) { + if (!ignore.includes("where") && options.where) { qb = WhereBuilder.addClause(qb, options.where); } - if (!exclude_options.includes("limit")) qb = qb.limit(options.limit); - if (!exclude_options.includes("offset")) qb = qb.offset(options.offset); + if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit); + if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset); // sorting - if (!exclude_options.includes("sort")) { - qb = qb.orderBy(aliased(options.sort.by), options.sort.dir); + if (!ignore.includes("sort")) { + qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc"); } - //console.log("options", { _options, options, exclude_options }); - return { qb, options }; + return qb as RepositoryQB; + } + + private buildQuery( + _options?: Partial, + ignore: (keyof RepoQuery)[] = [] + ): { qb: RepositoryQB; options: RepoQuery } { + const entity = this.entity; + const options = this.getValidOptions(_options); + + return { + qb: this.addOptionsToQueryBuilder(undefined, options, { + ignore, + alias: entity.name + }), + options + }; } async findId( diff --git a/app/src/data/entities/query/WhereBuilder.ts b/app/src/data/entities/query/WhereBuilder.ts index 5168d0e..3473497 100644 --- a/app/src/data/entities/query/WhereBuilder.ts +++ b/app/src/data/entities/query/WhereBuilder.ts @@ -30,7 +30,7 @@ function key(e: unknown): string { return e as string; } -const expressions: TExpression[] = [ +const expressions = [ exp( "$eq", (v: Primitive) => isPrimitive(v), diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 260dc86..ce4f14c 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -1,42 +1,82 @@ +import { isObject } from "core/utils"; +import type { KyselyJsonFrom, RepoQuery } from "data"; +import { InvalidSearchParamsException } from "data/errors"; import type { Entity, EntityManager, RepositoryQB } from "../../entities"; export class WithBuilder { - private static buildClause( + static addClause( em: EntityManager, qb: RepositoryQB, entity: Entity, - withString: string + withs: RepoQuery["with"] ) { - const relation = em.relationOf(entity.name, withString); - if (!relation) { - throw new Error(`Relation "${withString}" not found`); + if (!withs || !isObject(withs)) { + console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); + return qb; } - const cardinality = relation.ref(withString).cardinality; - //console.log("with--builder", { entity: entity.name, withString, cardinality }); - const fns = em.connection.fn; - const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom; - - if (!jsonFrom) { - throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom"); - } - - try { - return relation.buildWith(entity, qb, jsonFrom, withString); - } catch (e) { - throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`); - } - } - - static addClause(em: EntityManager, qb: RepositoryQB, entity: Entity, withs: string[]) { - if (withs.length === 0) return qb; - let newQb = qb; - for (const entry of withs) { - newQb = WithBuilder.buildClause(em, newQb, entity, entry); + + for (const [ref, query] of Object.entries(withs)) { + const relation = em.relationOf(entity.name, ref); + if (!relation) { + throw new Error(`Relation "${entity.name}<>${ref}" not found`); + } + const cardinality = relation.ref(ref).cardinality; + const jsonFrom: KyselyJsonFrom = + cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom; + if (!jsonFrom) { + throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom"); + } + + const other = relation.other(entity); + newQb = newQb.select((eb) => { + let subQuery = relation.buildWith(entity, ref)(eb); + if (query) { + subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { + ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( + Boolean + ) as any + }); + } + + if (query.with) { + subQuery = WithBuilder.addClause(em, subQuery, other.entity, query.with as any); + } + + return jsonFrom(subQuery).as(other.reference); + }); } return newQb; } + + static validateWiths(em: EntityManager, entity: string, withs: RepoQuery["with"]) { + let depth = 0; + if (!withs || !isObject(withs)) { + withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); + return depth; + } + + const child_depths: number[] = []; + for (const [ref, query] of Object.entries(withs)) { + const related = em.relationOf(entity, ref); + if (!related) { + throw new InvalidSearchParamsException( + `WITH: "${ref}" is not a relation of "${entity}"` + ); + } + depth++; + + if ("with" in query) { + child_depths.push(WithBuilder.validateWiths(em, ref, query.with as any)); + } + } + if (child_depths.length > 0) { + depth += Math.max(...child_depths); + } + + return depth; + } } diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index 01311d8..3ea038c 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -1,20 +1,48 @@ import type { PrimaryFieldType } from "core"; -import { Event } from "core/events"; +import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; import type { RepoQuery } from "../server/data-query-impl"; -export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> { +export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> { static override slug = "mutator-insert-before"; + + override validate(data: EntityData) { + const { entity } = this.params; + if (!entity.isValidData(data, "create")) { + throw new InvalidEventReturn("EntityData", "invalid"); + } + + return this.clone({ + entity, + data + }); + } } export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> { static override slug = "mutator-insert-after"; } -export class MutatorUpdateBefore extends Event<{ - entity: Entity; - entityId: PrimaryFieldType; - data: EntityData; -}> { +export class MutatorUpdateBefore extends Event< + { + entity: Entity; + entityId: PrimaryFieldType; + data: EntityData; + }, + EntityData +> { static override slug = "mutator-update-before"; + + override validate(data: EntityData) { + const { entity, ...rest } = this.params; + if (!entity.isValidData(data, "update")) { + throw new InvalidEventReturn("EntityData", "invalid"); + } + + return this.clone({ + ...rest, + entity, + data + }); + } } export class MutatorUpdateAfter extends Event<{ entity: Entity; diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index 5260d61..f412f5e 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import type { EntityManager } from "../entities"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; +// @todo: contexts need to be reworked +// e.g. "table" is irrelevant, because if read is not given, it fails + export const ActionContext = ["create", "read", "update", "delete"] as const; export type TActionContext = (typeof ActionContext)[number]; @@ -157,8 +160,12 @@ export abstract class Field< return this.config.virtual ?? false; } - getLabel(): string { - return this.config.label ?? snakeToPascalWithSpaces(this.name); + getLabel(options?: { fallback?: boolean }): string | undefined { + return this.config.label + ? this.config.label + : options?.fallback !== false + ? snakeToPascalWithSpaces(this.name) + : undefined; } getDescription(): string | undefined { diff --git a/app/src/data/index.ts b/app/src/data/index.ts index 3a287e6..db70e28 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -8,6 +8,7 @@ export * from "./prototype"; export { type RepoQuery, + type RepoQueryIn, defaultQuerySchema, querySchema, whereSchema diff --git a/app/src/data/relations/EntityRelation.ts b/app/src/data/relations/EntityRelation.ts index e7d680b..07611d0 100644 --- a/app/src/data/relations/EntityRelation.ts +++ b/app/src/data/relations/EntityRelation.ts @@ -1,5 +1,5 @@ import { type Static, Type, parse } from "core/utils"; -import type { SelectQueryBuilder } from "kysely"; +import type { ExpressionBuilder, SelectQueryBuilder } from "kysely"; import type { Entity, EntityData, EntityManager } from "../entities"; import { type EntityRelationAnchor, @@ -67,10 +67,8 @@ export abstract class EntityRelation< */ abstract buildWith( entity: Entity, - qb: KyselyQueryBuilder, - jsonFrom: KyselyJsonFrom, reference: string - ): KyselyQueryBuilder; + ): (eb: ExpressionBuilder) => KyselyQueryBuilder; abstract buildJoin( entity: Entity, diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts index 25dbdca..b4e89f1 100644 --- a/app/src/data/relations/ManyToManyRelation.ts +++ b/app/src/data/relations/ManyToManyRelation.ts @@ -1,4 +1,5 @@ import { type Static, Type } from "core/utils"; +import type { ExpressionBuilder } from "kysely"; import { Entity, type EntityManager } from "../entities"; import { type Field, PrimaryField, VirtualField } from "../fields"; import type { RepoQuery } from "../server/data-query-impl"; @@ -123,7 +124,7 @@ export class ManyToManyRelation extends EntityRelation !(f instanceof RelationField || f instanceof PrimaryField) ); - return qb.select((eb) => { + return (eb: ExpressionBuilder) => + eb + .selectFrom(other.entity.name) + .select((eb2) => { + const select: any[] = other.entity.getSelect(other.entity.name); + if (additionalFields.length > 0) { + const conn = this.connectionEntity.name; + select.push( + jsonBuildObject( + Object.fromEntries( + additionalFields.map((f) => [f.name, eb2.ref(`${conn}.${f.name}`)]) + ) + ).as(this.connectionTableMappedName) + ); + } + + return select; + }) + .whereRef(entityRef, "=", otherRef) + .innerJoin(...join) + .limit(limit); + + /*return qb.select((eb) => { const select: any[] = other.entity.getSelect(other.entity.name); // @todo: also add to find by references if (additionalFields.length > 0) { @@ -160,7 +183,7 @@ export class ManyToManyRelation extends EntityRelation) { diff --git a/app/src/data/relations/ManyToOneRelation.ts b/app/src/data/relations/ManyToOneRelation.ts index 57bb993..de53ad1 100644 --- a/app/src/data/relations/ManyToOneRelation.ts +++ b/app/src/data/relations/ManyToOneRelation.ts @@ -1,6 +1,7 @@ import type { PrimaryFieldType } from "core"; import { snakeToPascalWithSpaces } from "core/utils"; import { type Static, Type } from "core/utils"; +import type { ExpressionBuilder } from "kysely"; import type { Entity, EntityManager } from "../entities"; import type { RepoQuery } from "../server/data-query-impl"; import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; @@ -155,23 +156,14 @@ export class ManyToOneRelation extends EntityRelation - jsonFrom( - eb - .selectFrom(`${self.entity.name} as ${relationRef}`) - .select(self.entity.getSelect(relationRef)) - .whereRef(entityRef, "=", otherRef) - .limit(limit) - ).as(relationRef) - ); + return (eb: ExpressionBuilder) => + eb + .selectFrom(`${self.entity.name} as ${relationRef}`) + .whereRef(entityRef, "=", otherRef) + .$if(self.cardinality === 1, (qb) => qb.limit(1)); } /** diff --git a/app/src/data/relations/PolymorphicRelation.ts b/app/src/data/relations/PolymorphicRelation.ts index f7a359d..cf77108 100644 --- a/app/src/data/relations/PolymorphicRelation.ts +++ b/app/src/data/relations/PolymorphicRelation.ts @@ -1,4 +1,5 @@ import { type Static, Type } from "core/utils"; +import type { ExpressionBuilder } from "kysely"; import type { Entity, EntityManager } from "../entities"; import { NumberField, TextField } from "../fields"; import type { RepoQuery } from "../server/data-query-impl"; @@ -87,20 +88,15 @@ export class PolymorphicRelation extends EntityRelation - jsonFrom( - eb - .selectFrom(other.entity.name) - .select(other.entity.getSelect(other.entity.name)) - .where(whereLhs, "=", reference) - .whereRef(entityRef, "=", otherRef) - .limit(limit) - ).as(other.reference) - ); + return (eb: ExpressionBuilder) => + eb + .selectFrom(other.entity.name) + .where(whereLhs, "=", reference) + .whereRef(entityRef, "=", otherRef) + .$if(other.cardinality === 1, (qb) => qb.limit(1)); } override isListableFor(entity: Entity): boolean { diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 5bf18d9..8abf02e 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -1,3 +1,4 @@ +import type { TThis } from "@sinclair/typebox"; import { type SchemaOptions, type Static, @@ -6,7 +7,7 @@ import { Type, Value } from "core/utils"; -import { WhereBuilder } from "../entities"; +import { WhereBuilder, type WhereQuery } from "../entities"; const NumberOrString = (options: SchemaOptions = {}) => Type.Transform(Type.Union([Type.Number(), Type.String()], options)) @@ -14,10 +15,8 @@ const NumberOrString = (options: SchemaOptions = {}) => .Encode(String); const limit = NumberOrString({ default: 10 }); - const offset = NumberOrString({ default: 0 }); -// @todo: allow "id" and "-id" const sort_default = { by: "id", dir: "asc" }; const sort = Type.Transform( Type.Union( @@ -27,20 +26,20 @@ const sort = Type.Transform( } ) ) - .Decode((value) => { + .Decode((value): { by: string; dir: "asc" | "desc" } => { if (typeof value === "string") { if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) { const dir = value[0] === "-" ? "desc" : "asc"; - return { by: dir === "desc" ? value.slice(1) : value, dir }; + return { by: dir === "desc" ? value.slice(1) : value, dir } as any; } else if (/^{.*}$/.test(value)) { - return JSON.parse(value); + return JSON.parse(value) as any; } - return sort_default; + return sort_default as any; } - return value; + return value as any; }) - .Encode(JSON.stringify); + .Encode((value) => value); const stringArray = Type.Transform( Type.Union([Type.String(), Type.Array(Type.String())], { default: [] }) @@ -64,21 +63,63 @@ export const whereSchema = Type.Transform( }) .Encode(JSON.stringify); -export const querySchema = Type.Object( - { - limit: Type.Optional(limit), - offset: Type.Optional(offset), - sort: Type.Optional(sort), - select: Type.Optional(stringArray), - with: Type.Optional(stringArray), - join: Type.Optional(stringArray), - where: Type.Optional(whereSchema) - }, - { - additionalProperties: false +export type RepoWithSchema = Record< + string, + Omit & { + with?: unknown; } +>; + +export const withSchema = (Self: TSelf) => + Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)])) + .Decode((value) => { + let _value = typeof value === "string" ? [value] : value; + + 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); + } + + return _value as RepoWithSchema; + }) + .Encode((value) => value); + +export const querySchema = Type.Recursive( + (Self) => + Type.Partial( + Type.Object( + { + limit: limit, + offset: offset, + sort: sort, + select: stringArray, + with: withSchema(Self), + join: stringArray, + where: whereSchema + }, + { + // @todo: determine if unknown is allowed, it's ignore anyway + additionalProperties: false + } + ) + ), + { $id: "query-schema" } ); -export type RepoQueryIn = Static; +export type RepoQueryIn = { + limit?: number; + offset?: number; + sort?: string | { by: string; dir: "asc" | "desc" }; + select?: string[]; + with?: string[] | Record; + join?: string[]; + where?: WhereQuery; +}; export type RepoQuery = Required>; export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; diff --git a/app/src/flows/AppFlows.ts b/app/src/flows/AppFlows.ts index 34026e8..4f75cbf 100644 --- a/app/src/flows/AppFlows.ts +++ b/app/src/flows/AppFlows.ts @@ -12,6 +12,18 @@ export type { TAppFlowTaskSchema } from "./flows-schema"; export class AppFlows extends Module { private flows: Record = {}; + getSchema() { + return flowsConfigSchema; + } + + private getFlowInfo(flow: Flow) { + return { + ...flow.toJSON(), + tasks: flow.tasks.length, + connections: flow.connections + }; + } + override async build() { //console.log("building flows", this.config); const flows = transformObject(this.config.flows, (flowConfig, name) => { @@ -67,15 +79,10 @@ export class AppFlows extends Module { this.setBuilt(); } - getSchema() { - return flowsConfigSchema; - } - - private getFlowInfo(flow: Flow) { + override toJSON() { return { - ...flow.toJSON(), - tasks: flow.tasks.length, - connections: flow.connections + ...this.config, + flows: transformObject(this.flows, (flow) => flow.toJSON()) }; } } diff --git a/app/src/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index 4fc2c2a..d073d15 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -62,7 +62,7 @@ export const flowSchema = Type.Object( { trigger: Type.Union(Object.values(triggerSchemaObject)), tasks: Type.Optional(StringRecord(Type.Union(Object.values(taskSchemaObject)))), - connections: Type.Optional(StringRecord(connectionSchema, { default: {} })), + connections: Type.Optional(StringRecord(connectionSchema)), start_task: Type.Optional(Type.String()), responding_task: Type.Optional(Type.String()) }, diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index 2890b7f..43356b6 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -162,8 +162,8 @@ export class Flow { trigger: this.trigger.toJSON(), tasks: Object.fromEntries(this.tasks.map((t) => [t.name, t.toJSON()])), connections: Object.fromEntries(this.connections.map((c) => [c.id, c.toJSON()])), - start_task: this.startTask.name, - responding_task: this.respondingTask ? this.respondingTask.name : null + start_task: this.startTask?.name, + responding_task: this.respondingTask?.name }; } diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 1a4e579..fb9e102 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -1,4 +1,4 @@ -import { uuid } from "core/utils"; +import { objectCleanEmpty, uuid } from "core/utils"; import { get } from "lodash-es"; import type { Task, TaskResult } from "./Task"; @@ -34,14 +34,14 @@ export class TaskConnection { } toJSON() { - return { + return objectCleanEmpty({ source: this.source.name, target: this.target.name, config: { ...this.config, condition: this.config.condition?.toJSON() } - }; + }); } } diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index c759479..564b008 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -53,6 +53,8 @@ export class AppMedia extends Module { index(media).on(["path"], true).on(["reference"]); }) ); + + this.setBuilt(); } catch (e) { console.error(e); throw new Error( diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 121c2fc..722f94d 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -1,5 +1,5 @@ import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi"; -import type { FileWithPath } from "ui/modules/media/components/dropzone/file-selector"; +import type { FileWithPath } from "ui/elements/media/file-selector"; export type MediaApiOptions = BaseModuleApiOptions & {}; diff --git a/app/src/media/index.ts b/app/src/media/index.ts index 7a1cdc7..71bcc80 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,8 @@ import type { TObject, TString } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -export { MIME_TYPES } from "./storage/mime-types"; +//export { MIME_TYPES } from "./storage/mime-types"; +export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, type StorageAdapter, @@ -19,7 +20,7 @@ import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/Stora export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; -export { type FileUploadedEventData } from "./storage/events"; +export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; type ClassThatImplements = Constructor & { prototype: T }; diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts index 7a02cf8..a560c88 100644 --- a/app/src/media/utils/index.ts +++ b/app/src/media/utils/index.ts @@ -10,7 +10,7 @@ export function getExtension(filename: string): string | undefined { export function getRandomizedFilename(file: File, length?: number): string; export function getRandomizedFilename(file: string, length?: number): string; export function getRandomizedFilename(file: File | string, length = 16): string { - const filename = file instanceof File ? file.name : file; + const filename = typeof file === "string" ? file : file.name; if (typeof filename !== "string") { console.error("Couldn't extract filename from", file); diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 838e964..0d5b8bf 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -3,9 +3,18 @@ import type { Guard } from "auth"; import { SchemaObject } from "core"; import type { EventManager } from "core/events"; import type { Static, TSchema } from "core/utils"; -import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data"; +import { + type Connection, + type EntityIndex, + type EntityManager, + type Field, + FieldPrototype, + make, + type em as prototypeEm +} from "data"; import { Entity } from "data"; import type { Hono } from "hono"; +import { isEqual } from "lodash-es"; export type ServerEnv = { Variables: { @@ -146,28 +155,33 @@ export abstract class Module 0) { + this.ctx.flags.sync_required = true; + } } } // replace entity (mainly to keep the ensured type) this.ctx.em.__replaceEntity( - new Entity(entity.name, entity.fields, instance.config, entity.type) + new Entity(instance.name, instance.fields, instance.config, entity.type) ); } @@ -184,4 +198,35 @@ export abstract class Module = { + id?: number; version: number; type: "config" | "diff" | "backup"; json: Json; @@ -236,10 +237,10 @@ export class ModuleManager { private async fetch(): Promise { this.logger.context("fetch").log("fetching"); + const startTime = performance.now(); // disabling console log, because the table might not exist yet - return await withDisabledConsole(async () => { - const startTime = performance.now(); + const result = await withDisabledConsole(async () => { const { data: result } = await this.repo().findOne( { type: "config" }, { @@ -251,9 +252,16 @@ export class ModuleManager { throw BkndError.with("no config"); } - this.logger.log("took", performance.now() - startTime, "ms", result.version).clear(); - return result as ConfigTable; + return result as unknown as ConfigTable; }, ["log", "error", "warn"]); + + this.logger + .log("took", performance.now() - startTime, "ms", { + version: result.version, + id: result.id + }) + .clear(); + return result; } async save() { @@ -329,6 +337,9 @@ export class ModuleManager { } } + // re-apply configs to all modules (important for system entities) + this.setConfigs(configs); + // @todo: cleanup old versions? this.logger.clear(); @@ -387,6 +398,7 @@ export class ModuleManager { } private setConfigs(configs: ModuleConfigs): void { + this.logger.log("setting configs"); objectEach(configs, (config, key) => { try { // setting "noEmit" to true, to not force listeners to update diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 8fd50cc..be2e548 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -44,6 +44,11 @@ export class SystemController extends Controller { hono.use(permission(SystemPermissions.configRead)); + hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => { + // @ts-expect-error "fetch" is private + return c.json(await this.app.modules.fetch()); + }); + hono.get( "/:module?", tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })), diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 4f5293f..1dc51e6 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,7 +1,10 @@ +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"; @@ -10,6 +13,7 @@ type BkndContext = { schema: ModuleSchemas; config: ModuleConfigs; permissions: string[]; + hasSecrets: boolean; requireSecrets: () => Promise; actions: ReturnType; app: AppReduced; @@ -32,7 +36,9 @@ export function BkndProvider({ const [schema, setSchema] = useState>(); const [fetched, setFetched] = useState(false); + const [error, setError] = useState(); const errorShown = useRef(); + const [local_version, set_local_version] = useState(0); const api = useApi(); async function reloadSchema() { @@ -49,15 +55,11 @@ export function BkndProvider({ if (!res.ok) { if (errorShown.current) return; errorShown.current = true; - /*notifications.show({ - title: "Failed to fetch schema", - // @ts-ignore - message: body.error, - color: "red", - position: "top-right", - autoClose: false, - withCloseButton: true - });*/ + + setError(true); + return; + } else if (error) { + setError(false); } const schema = res.ok @@ -80,6 +82,7 @@ export function BkndProvider({ setSchema(schema); setWithSecrets(_includeSecrets); setFetched(true); + set_local_version((v) => v + 1); }); } @@ -96,9 +99,24 @@ export function BkndProvider({ if (!fetched || !schema) return fallback; const app = new AppReduced(schema?.config as any); const actions = getSchemaActions({ api, setSchema, reloadSchema }); + const hasSecrets = withSecrets && !error; return ( - + + {error && ( + + + You attempted to load system configuration with secrets without having proper + permission. + + + + + )} + {children} ); diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 4fd6719..f456f94 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,5 +1,5 @@ import { Api, type ApiOptions, type TApiUser } from "Api"; -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext } from "react"; const ClientContext = createContext<{ baseUrl: string; api: Api }>({ baseUrl: undefined @@ -12,7 +12,6 @@ export type ClientProviderProps = { }; export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => { - //const [actualBaseUrl, setActualBaseUrl] = useState(null); const winCtx = useBkndWindowContext(); const _ctx_baseUrl = useBaseUrl(); let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? ""; @@ -31,6 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) console.error("error .....", e); } + console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); return ( diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index eab68d4..c04f8a8 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -1,5 +1,5 @@ import type { Api } from "Api"; -import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; +import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; import { useApi } from "ui/client"; @@ -27,12 +27,19 @@ export const useApiQuery = < }; }; -export const useInvalidate = () => { +export const useInvalidate = (options?: { exact?: boolean }) => { const mutate = useSWRConfig().mutate; const api = useApi(); - return async (arg?: string | ((api: Api) => FetchPromise)) => { - if (!arg) return async () => mutate(""); - return mutate(typeof arg === "string" ? arg : arg(api).key()); + return async (arg?: string | ((api: Api) => FetchPromise | ModuleApi)) => { + let key = ""; + if (typeof arg === "string") { + key = arg; + } else if (typeof arg === "function") { + key = arg(api).key(); + } + + if (options?.exact) return mutate(key); + return mutate((k) => typeof k === "string" && k.startsWith(key)); }; }; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 5f4da99..483febf 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,6 +1,7 @@ import type { DB, PrimaryFieldType } from "core"; -import { encodeSearch, objectTransform } from "core/utils"; -import type { EntityData, RepoQuery } from "data"; +import { objectTransform } from "core/utils/objects"; +import { encodeSearch } from "core/utils/reqres"; +import type { EntityData, RepoQueryIn } from "data"; import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, mutate } from "swr"; import { type Api, useApi } from "ui/client"; @@ -22,15 +23,6 @@ export class UseEntityApiError extends Error { } } -function Test() { - const { read } = useEntity("users"); - async () => { - const data = await read(); - }; - - return null; -} - export const useEntity = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, @@ -49,7 +41,7 @@ export const useEntity = < } return res; }, - read: async (query: Partial = {}) => { + read: async (query: RepoQueryIn = {}) => { const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); if (!res.ok) { throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`); @@ -88,7 +80,7 @@ export function makeKey( api: ModuleApi, entity: string, id?: PrimaryFieldType, - query?: Partial + query?: RepoQueryIn ) { return ( "/" + @@ -105,11 +97,11 @@ export const useEntityQuery = < >( entity: Entity, id?: Id, - query?: Partial, + query?: RepoQueryIn, options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean } ) => { const api = useApi().data; - const key = makeKey(api, entity, id, query); + const key = makeKey(api, entity as string, id, query); const { read, ...actions } = useEntity(entity, id); const fetcher = () => read(query); @@ -121,7 +113,7 @@ export const useEntityQuery = < }); const mutateAll = async () => { - const entityKey = makeKey(api, entity); + const entityKey = makeKey(api, entity as string); return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { revalidate: true }); @@ -167,7 +159,7 @@ export async function mutateEntityCache< return prev; } - const entityKey = makeKey(api, entity); + const entityKey = makeKey(api, entity as string); return mutate( (key) => typeof key === "string" && key.startsWith(entityKey), diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index fd2ec84..b24f2ce 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,6 @@ -import { Api, type AuthState } from "Api"; +import type { AuthState } from "Api"; import type { AuthResponse } from "auth"; -import type { AppAuthSchema } from "auth/auth-schema"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useApi, useInvalidate } from "ui/client"; type LoginData = { @@ -73,23 +72,3 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => { verify }; }; - -type AuthStrategyData = Pick; -export const useAuthStrategies = (options?: { baseUrl?: string }): Partial & { - loading: boolean; -} => { - const [data, setData] = useState(); - const api = useApi(options?.baseUrl); - - useEffect(() => { - (async () => { - const res = await api.auth.strategies(); - //console.log("res", res); - if (res.res.ok) { - setData(res.body); - } - })(); - }, [options?.baseUrl]); - - return { strategies: data?.strategies, basepath: data?.basepath, loading: !data }; -}; diff --git a/app/src/ui/client/schema/data/use-bknd-data.ts b/app/src/ui/client/schema/data/use-bknd-data.ts index 36db148..7ab5d9b 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -12,6 +12,7 @@ import { } from "data/data-schema"; import { useBknd } from "ui/client/bknd"; import type { TSchemaActions } from "ui/client/schema/actions"; +import { bkndModals } from "ui/modals"; export function useBkndData() { const { config, app, schema, actions: bkndActions } = useBknd(); @@ -62,7 +63,13 @@ export function useBkndData() { } }; const $data = { - entity: (name: string) => entities[name] + entity: (name: string) => entities[name], + modals, + system: (name: string) => ({ + any: entities[name]?.type === "system", + users: name === config.auth.entity_name, + media: name === config.media.entity_name + }) }; return { @@ -75,6 +82,35 @@ export function useBkndData() { }; } +const modals = { + createAny: () => bkndModals.open(bkndModals.ids.dataCreate, {}), + createEntity: () => + bkndModals.open(bkndModals.ids.dataCreate, { + initialPath: ["entities", "entity"], + initialState: { action: "entity" } + }), + createRelation: (entity?: string) => + bkndModals.open(bkndModals.ids.dataCreate, { + initialPath: ["entities", "relation"], + initialState: { + action: "relation", + relations: { + create: [{ source: entity, type: "n:1" } as any] + } + } + }), + createMedia: (entity?: string) => + bkndModals.open(bkndModals.ids.dataCreate, { + initialPath: ["entities", "template-media"], + initialState: { + action: "template-media", + initial: { + entity + } + } + }) +}; + function entityFieldActions(bkndActions: TSchemaActions, entityName: string) { return { add: async (name: string, field: TAppDataField) => { diff --git a/app/src/ui/components/buttons/Button.tsx b/app/src/ui/components/buttons/Button.tsx index c9df2b6..a9a55e2 100644 --- a/app/src/ui/components/buttons/Button.tsx +++ b/app/src/ui/components/buttons/Button.tsx @@ -4,15 +4,15 @@ import { twMerge } from "tailwind-merge"; import { Link } from "ui/components/wouter/Link"; const sizes = { - small: "px-2 py-1.5 rounded-md gap-1.5 text-sm", - default: "px-3 py-2.5 rounded-md gap-2.5", - large: "px-4 py-3 rounded-md gap-3 text-lg" + small: "px-2 py-1.5 rounded-md gap-1 text-sm", + default: "px-3 py-2.5 rounded-md gap-1.5", + large: "px-4 py-3 rounded-md gap-2.5 text-lg" }; const iconSizes = { - small: 15, - default: 18, - large: 22 + small: 12, + default: 16, + large: 20 }; const styles = { diff --git a/app/src/ui/components/buttons/IconButton.tsx b/app/src/ui/components/buttons/IconButton.tsx index 30d0263..145b2f8 100644 --- a/app/src/ui/components/buttons/IconButton.tsx +++ b/app/src/ui/components/buttons/IconButton.tsx @@ -10,9 +10,9 @@ export type IconType = const styles = { xs: { className: "p-0.5", size: 13 }, - sm: { className: "p-0.5", size: 16 }, - md: { className: "p-1", size: 20 }, - lg: { className: "p-1.5", size: 24 } + sm: { className: "p-0.5", size: 15 }, + md: { className: "p-1", size: 18 }, + lg: { className: "p-1.5", size: 22 } } as const; interface IconButtonProps extends ComponentPropsWithoutRef<"button"> { diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 55d119b..745f249 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,8 +1,22 @@ import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; - import { useBknd } from "ui/client/bknd"; -export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { +import { json } from "@codemirror/lang-json"; +import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; + +export type CodeEditorProps = ReactCodeMirrorProps & { + _extensions?: Partial<{ + json: boolean; + liquid: LiquidCompletionConfig; + }>; +}; + +export default function CodeEditor({ + editable, + basicSetup, + _extensions = {}, + ...props +}: CodeEditorProps) { const b = useBknd(); const theme = b.app.getAdminConfig().color_scheme; const _basicSetup: Partial = !editable @@ -13,11 +27,24 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode } : basicSetup; + const extensions = Object.entries(_extensions ?? {}) + .map(([ext, config]: any) => { + switch (ext) { + case "json": + return json(); + case "liquid": + return liquid(config); + } + return undefined; + }) + .filter(Boolean) as any; + return ( ); diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx index 8317380..a8b4235 100644 --- a/app/src/ui/components/code/JsonEditor.tsx +++ b/app/src/ui/components/code/JsonEditor.tsx @@ -1,10 +1,9 @@ -import { json } from "@codemirror/lang-json"; -import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { Suspense, lazy } from "react"; import { twMerge } from "tailwind-merge"; +import type { CodeEditorProps } from "./CodeEditor"; const CodeEditor = lazy(() => import("./CodeEditor")); -export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) { +export function JsonEditor({ editable, className, ...props }: CodeEditorProps) { return ( diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx index 7f145b1..be05818 100644 --- a/app/src/ui/components/code/LiquidJsEditor.tsx +++ b/app/src/ui/components/code/LiquidJsEditor.tsx @@ -1,7 +1,7 @@ -import { liquid } from "@codemirror/lang-liquid"; -import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { Suspense, lazy } from "react"; import { twMerge } from "tailwind-merge"; + +import type { CodeEditorProps } from "./CodeEditor"; const CodeEditor = lazy(() => import("./CodeEditor")); const filters = [ @@ -106,7 +106,7 @@ const tags = [ { label: "when" } ]; -export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) { +export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) { return ( diff --git a/app/src/ui/components/display/Alert.tsx b/app/src/ui/components/display/Alert.tsx index ba3c4cd..ee5aad1 100644 --- a/app/src/ui/components/display/Alert.tsx +++ b/app/src/ui/components/display/Alert.tsx @@ -6,16 +6,27 @@ export type AlertProps = ComponentPropsWithoutRef<"div"> & { visible?: boolean; title?: string; message?: ReactNode | string; + children?: ReactNode; }; -const Base: React.FC = ({ visible = true, title, message, className, ...props }) => +const Base: React.FC = ({ + visible = true, + title, + message, + className, + children, + ...props +}) => visible ? (
{title && {title}:} - {message} + {message || children}
) : null; diff --git a/app/src/ui/components/display/Empty.tsx b/app/src/ui/components/display/Empty.tsx index 717b781..9f1291c 100644 --- a/app/src/ui/components/display/Empty.tsx +++ b/app/src/ui/components/display/Empty.tsx @@ -1,33 +1,33 @@ -import { Button } from "../buttons/Button"; +import { twMerge } from "tailwind-merge"; +import { Button, type ButtonProps } from "../buttons/Button"; export type EmptyProps = { Icon?: any; title?: string; description?: string; - buttonText?: string; - buttonOnClick?: () => void; + primary?: ButtonProps; + secondary?: ButtonProps; + className?: string; }; export const Empty: React.FC = ({ Icon = undefined, title = undefined, description = "Check back later my friend.", - buttonText, - buttonOnClick + primary, + secondary, + className }) => ( -
+
{Icon && }
{title &&

{title}

}

{description}

- {buttonText && ( -
- -
- )} +
+ {secondary &&
); diff --git a/app/src/ui/components/display/Message.tsx b/app/src/ui/components/display/Message.tsx index 34069dd..bc262d2 100644 --- a/app/src/ui/components/display/Message.tsx +++ b/app/src/ui/components/display/Message.tsx @@ -1,7 +1,24 @@ +import { IconLockAccessOff } from "@tabler/icons-react"; import { Empty, type EmptyProps } from "./Empty"; const NotFound = (props: Partial) => ; +const NotAllowed = (props: Partial) => ; +const MissingPermission = ({ + what, + ...props +}: Partial & { + what?: string; +}) => ( + +); export const Message = { - NotFound + NotFound, + NotAllowed, + MissingPermission }; diff --git a/app/src/ui/components/form/Formy/BooleanInputMantine.tsx b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx new file mode 100644 index 0000000..3f3c77a --- /dev/null +++ b/app/src/ui/components/form/Formy/BooleanInputMantine.tsx @@ -0,0 +1,29 @@ +import { Switch } from "@mantine/core"; +import { forwardRef, useEffect, useState } from "react"; + +export const BooleanInputMantine = forwardRef>( + (props, ref) => { + const [checked, setChecked] = useState(Boolean(props.value)); + + useEffect(() => { + setChecked(Boolean(props.value)); + }, [props.value]); + + function handleCheck(e) { + setChecked(e.target.checked); + props.onChange?.(e.target.checked); + } + + return ( +
+ +
+ ); + } +); diff --git a/app/src/ui/components/form/Formy.tsx b/app/src/ui/components/form/Formy/components.tsx similarity index 92% rename from app/src/ui/components/form/Formy.tsx rename to app/src/ui/components/form/Formy/components.tsx index af2eb49..d725238 100644 --- a/app/src/ui/components/form/Formy.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -1,11 +1,10 @@ -import { Switch } from "@mantine/core"; import { getBrowser } from "core/utils"; import type { Field } from "data"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; -import { useEvent } from "../../hooks/use-event"; -import { IconButton } from "../buttons/IconButton"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { useEvent } from "ui/hooks/use-event"; export const Group: React.FC & { error?: boolean }> = ({ error, @@ -131,17 +130,6 @@ export const BooleanInput = forwardRef - -
- ); - /*return (
- );*/ + ); } ); diff --git a/app/src/ui/components/form/Formy/index.ts b/app/src/ui/components/form/Formy/index.ts new file mode 100644 index 0000000..04555f6 --- /dev/null +++ b/app/src/ui/components/form/Formy/index.ts @@ -0,0 +1,17 @@ +import { BooleanInputMantine } from "./BooleanInputMantine"; +import { DateInput, Input, Textarea } from "./components"; + +export const formElementFactory = (element: string, props: any) => { + switch (element) { + case "date": + return DateInput; + case "boolean": + return BooleanInputMantine; + case "textarea": + return Textarea; + default: + return Input; + } +}; + +export * from "./components"; diff --git a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx index d722dde..8b79f70 100644 --- a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx +++ b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx @@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & { schema: RJSFSchema | Schema; uiSchema?: any; direction?: "horizontal" | "vertical"; - onChange?: (value: any) => void; + onChange?: (value: any, isValid: () => boolean) => void; }; export type JsonSchemaFormRef = { formData: () => any; validateForm: () => boolean; + silentValidate: () => boolean; cancel: () => void; }; @@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef const handleChange = ({ formData }: any, e) => { const clean = JSON.parse(JSON.stringify(formData)); //console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); - onChange?.(clean); setValue(clean); + onChange?.(clean, () => isValid(clean)); }; + const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0; + useImperativeHandle( ref, () => ({ formData: () => value, validateForm: () => formRef.current!.validateForm(), + silentValidate: () => isValid(value), cancel: () => formRef.current!.reset() }), [value] diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index f6616e9..3fc49b4 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -1,7 +1,14 @@ import { useClickOutside } from "@mantine/hooks"; -import { Fragment, type ReactElement, cloneElement, useState } from "react"; +import { clampNumber } from "core/utils"; +import { + type ComponentPropsWithoutRef, + Fragment, + 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 DropdownItem = | (() => JSX.Element) @@ -14,26 +21,33 @@ export type DropdownItem = [key: string]: any; }; +export type DropdownClickableChild = ReactElement<{ onClick: () => void }>; export type DropdownProps = { className?: string; + openEvent?: "onClick" | "onContextMenu"; defaultOpen?: boolean; + title?: string | ReactElement; + dropdownWrapperProps?: Omit, "style">; position?: "bottom-start" | "bottom-end" | "top-start" | "top-end"; hideOnEmpty?: boolean; items: (DropdownItem | undefined | boolean)[]; itemsClassName?: string; - children: ReactElement<{ onClick: () => void }>; + children: DropdownClickableChild; onClickItem?: (item: DropdownItem) => void; renderItem?: ( item: DropdownItem, props: { key: number; onClick: () => void } - ) => ReactElement<{ onClick: () => void }>; + ) => DropdownClickableChild; }; export function Dropdown({ children, defaultOpen = false, - position = "bottom-start", + openEvent = "onClick", + position: initialPosition = "bottom-start", + dropdownWrapperProps, items, + title, hideOnEmpty = true, onClickItem, renderItem, @@ -41,19 +55,58 @@ export function Dropdown({ className }: DropdownProps) { const [open, setOpen] = useState(defaultOpen); + const [position, setPosition] = useState(initialPosition); const clickoutsideRef = useClickOutside(() => setOpen(false)); const menuItems = items.filter(Boolean) as DropdownItem[]; + const [_offset, _setOffset] = useState(0); const toggle = useEvent((delay: number = 50) => setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0) ); + const onClickHandler = openEvent === "onClick" ? toggle : undefined; + const onContextMenuHandler = useEvent((e) => { + if (openEvent !== "onContextMenu") return; + e.preventDefault(); + + if (open) { + toggle(0); + setTimeout(() => { + setPosition(initialPosition); + _setOffset(0); + }, 10); + return; + } + + // minimal popper impl, get pos and boundaries + const x = e.clientX - e.currentTarget.getBoundingClientRect().left; + const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {}; + + // only if boundaries gien + if (left > 0 && right > 0) { + const safe = clampNumber(x, left, right); + // if pos less than half, go left + if (x < (left + right) / 2) { + setPosition("bottom-start"); + _setOffset(safe); + } else { + setPosition("bottom-end"); + _setOffset(right - safe); + } + } else { + setPosition(initialPosition); + _setOffset(0); + } + + toggle(); + }); + const offset = 4; const dropdownStyle = { - "bottom-start": { top: "100%", left: 0, marginTop: offset }, - "bottom-end": { right: 0, top: "100%", marginTop: offset }, + "bottom-start": { top: "100%", left: _offset, marginTop: offset }, + "bottom-end": { right: _offset, top: "100%", marginTop: offset }, "top-start": { bottom: "100%", marginBottom: offset }, - "top-end": { bottom: "100%", right: 0, marginBottom: offset } + "top-end": { bottom: "100%", right: _offset, marginBottom: offset } }[position]; const internalOnClickItem = useEvent((item) => { @@ -94,13 +147,25 @@ export function Dropdown({ )); return ( -
- {cloneElement(children as any, { onClick: toggle })} +
+ {cloneElement(children as any, { onClick: onClickHandler })} {open && (
+ {title && ( +
{title}
+ )} {menuItems.map((item, i) => itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }) )} diff --git a/app/src/ui/components/steps/Steps.tsx b/app/src/ui/components/steps/Steps.tsx index 0dc02f2..d0ccc47 100644 --- a/app/src/ui/components/steps/Steps.tsx +++ b/app/src/ui/components/steps/Steps.tsx @@ -10,6 +10,7 @@ import { export type TStepsProps = { children: any; initialPath?: string[]; + initialState?: any; lastBack?: () => void; [key: string]: any; }; @@ -19,13 +20,14 @@ type TStepContext = { stepBack: () => void; close: () => void; state: T; + path: string[]; setState: Dispatch>; }; const StepContext = createContext(undefined as any); -export function Steps({ children, initialPath = [], lastBack }: TStepsProps) { - const [state, setState] = useState({}); +export function Steps({ children, initialPath = [], initialState = {}, lastBack }: TStepsProps) { + const [state, setState] = useState(initialState); const [path, setPath] = useState(initialPath); const steps: any[] = Children.toArray(children).filter( (child: any) => child.props.disabled !== true @@ -46,7 +48,7 @@ export function Steps({ children, initialPath = [], lastBack }: TStepsProps) { const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0]; return ( - + {current} ); diff --git a/app/src/ui/modules/auth/AuthForm.tsx b/app/src/ui/elements/auth/AuthForm.tsx similarity index 94% rename from app/src/ui/modules/auth/AuthForm.tsx rename to app/src/ui/elements/auth/AuthForm.tsx index fa864ef..88ce0ca 100644 --- a/app/src/ui/modules/auth/AuthForm.tsx +++ b/app/src/ui/elements/auth/AuthForm.tsx @@ -1,13 +1,13 @@ 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 { transform } from "lodash-es"; import type { ComponentPropsWithoutRef } from "react"; -import { twMerge } from "tailwind-merge"; import { Button } from "ui/components/buttons/Button"; -import { Group, Input, Label } from "ui/components/form/Formy"; -import { SocialLink } from "ui/modules/auth/SocialLink"; +import { Group, Input, Label } from "ui/components/form/Formy/components"; +import { SocialLink } from "./SocialLink"; export type LoginFormProps = Omit, "onSubmit" | "action"> & { className?: string; @@ -86,7 +86,7 @@ export function AuthForm({ schema={schema} validator={validator} validationMode="change" - className={twMerge("flex flex-col gap-3 w-full", className)} + className={clsx("flex flex-col gap-3 w-full", className)} > {({ errors, submitting }) => ( <> diff --git a/app/src/ui/modules/auth/AuthScreen.tsx b/app/src/ui/elements/auth/AuthScreen.tsx similarity index 70% rename from app/src/ui/modules/auth/AuthScreen.tsx rename to app/src/ui/elements/auth/AuthScreen.tsx index 3ac60e1..340a89e 100644 --- a/app/src/ui/modules/auth/AuthScreen.tsx +++ b/app/src/ui/elements/auth/AuthScreen.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from "react"; -import { useAuthStrategies } from "ui/client/schema/auth/use-auth"; -import { Logo } from "ui/components/display/Logo"; -import { Link } from "ui/components/wouter/Link"; -import { AuthForm } from "ui/modules/auth/AuthForm"; +import { useAuthStrategies } from "../hooks/use-auth"; +import { AuthForm } from "./AuthForm"; export type AuthScreenProps = { method?: "POST" | "GET"; @@ -18,13 +16,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
{!loading && (
- {typeof logo !== "undefined" ? ( - logo - ) : ( - - - - )} + {logo ? logo : null} {typeof intro !== "undefined" ? ( intro ) : ( diff --git a/app/src/ui/modules/auth/SocialLink.tsx b/app/src/ui/elements/auth/SocialLink.tsx similarity index 100% rename from app/src/ui/modules/auth/SocialLink.tsx rename to app/src/ui/elements/auth/SocialLink.tsx diff --git a/app/src/ui/elements/auth/index.ts b/app/src/ui/elements/auth/index.ts new file mode 100644 index 0000000..b73224a --- /dev/null +++ b/app/src/ui/elements/auth/index.ts @@ -0,0 +1,9 @@ +import { AuthForm } from "./AuthForm"; +import { AuthScreen } from "./AuthScreen"; +import { SocialLink } from "./SocialLink"; + +export const Auth = { + Screen: AuthScreen, + Form: AuthForm, + SocialLink: SocialLink +}; diff --git a/app/src/ui/elements/hooks/use-auth.ts b/app/src/ui/elements/hooks/use-auth.ts new file mode 100644 index 0000000..5907cf6 --- /dev/null +++ b/app/src/ui/elements/hooks/use-auth.ts @@ -0,0 +1,23 @@ +import type { AppAuthSchema } from "auth/auth-schema"; +import { useEffect, useState } from "react"; +import { useApi } from "ui/client"; + +type AuthStrategyData = Pick; +export const useAuthStrategies = (options?: { baseUrl?: string }): Partial & { + loading: boolean; +} => { + const [data, setData] = useState(); + const api = useApi(options?.baseUrl); + + useEffect(() => { + (async () => { + const res = await api.auth.strategies(); + //console.log("res", res); + if (res.res.ok) { + setData(res.body); + } + })(); + }, [options?.baseUrl]); + + return { strategies: data?.strategies, basepath: data?.basepath, loading: !data }; +}; diff --git a/app/src/ui/elements/index.ts b/app/src/ui/elements/index.ts index 83a292b..c2d2109 100644 --- a/app/src/ui/elements/index.ts +++ b/app/src/ui/elements/index.ts @@ -1,2 +1,2 @@ -export { Auth } from "ui/modules/auth/index"; +export { Auth } from "./auth"; export * from "./media"; diff --git a/app/src/ui/elements/media.ts b/app/src/ui/elements/media.ts deleted file mode 100644 index 5ed6e11..0000000 --- a/app/src/ui/elements/media.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone"; -import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer"; - -export const Media = { - Dropzone: DropzoneContainer, - Preview: PreviewWrapperMemoized -}; - -export type { - PreviewComponentProps, - FileState, - DropzoneProps, - DropzoneRenderProps -} from "ui/modules/media/components/dropzone/Dropzone"; -export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer"; diff --git a/app/src/ui/modules/media/components/dropzone/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx similarity index 100% rename from app/src/ui/modules/media/components/dropzone/Dropzone.tsx rename to app/src/ui/elements/media/Dropzone.tsx diff --git a/app/src/ui/modules/media/components/dropzone/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx similarity index 89% rename from app/src/ui/modules/media/components/dropzone/DropzoneContainer.tsx rename to app/src/ui/elements/media/DropzoneContainer.tsx index 96c65c2..b9b3a12 100644 --- a/app/src/ui/modules/media/components/dropzone/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -1,16 +1,11 @@ -import type { RepoQuery } from "data"; +import type { RepoQuery, RepoQueryIn } from "data"; import type { MediaFieldSchema } from "media/AppMedia"; import type { TAppMediaConfig } from "media/media-schema"; import { useId } from "react"; import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client"; import { useEvent } from "ui/hooks/use-event"; -import { - Dropzone, - type DropzoneProps, - type DropzoneRenderProps, - type FileState -} from "ui/modules/media/components/dropzone/Dropzone"; -import { mediaItemsToFileStates } from "ui/modules/media/helper"; +import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone"; +import { mediaItemsToFileStates } from "./helper"; export type DropzoneContainerProps = { children?: (props: DropzoneRenderProps) => JSX.Element; @@ -20,7 +15,7 @@ export type DropzoneContainerProps = { id: number; field: string; }; - query?: Partial; + query?: RepoQueryIn; } & Partial> & Partial; diff --git a/app/src/ui/modules/media/components/dropzone/file-selector.ts b/app/src/ui/elements/media/file-selector.ts similarity index 97% rename from app/src/ui/modules/media/components/dropzone/file-selector.ts rename to app/src/ui/elements/media/file-selector.ts index 3695dd2..d718893 100644 --- a/app/src/ui/modules/media/components/dropzone/file-selector.ts +++ b/app/src/ui/elements/media/file-selector.ts @@ -4,7 +4,7 @@ * MIT License (2020 Roland Groza) */ -import { MIME_TYPES } from "media"; +import { guess } from "media/storage/mime-types-tiny"; const FILES_TO_IGNORE = [ // Thumbnail cache files for macOS and Windows @@ -47,10 +47,8 @@ function withMimeType(file: FileWithPath) { console.log("withMimeType", name, hasExtension); if (hasExtension && !file.type) { - const ext = name.split(".").pop()!.toLowerCase(); - const type = MIME_TYPES.get(ext); - - console.log("withMimeType:in", ext, type); + const type = guess(name); + console.log("guessed", type); if (type) { Object.defineProperty(file, "type", { diff --git a/app/src/ui/modules/media/helper.ts b/app/src/ui/elements/media/helper.ts similarity index 92% rename from app/src/ui/modules/media/helper.ts rename to app/src/ui/elements/media/helper.ts index 78f6253..fa4bde2 100644 --- a/app/src/ui/modules/media/helper.ts +++ b/app/src/ui/elements/media/helper.ts @@ -1,5 +1,5 @@ import type { MediaFieldSchema } from "media/AppMedia"; -import type { FileState } from "./components/dropzone/Dropzone"; +import type { FileState } from "./Dropzone"; export function mediaItemToFileState( item: MediaFieldSchema, diff --git a/app/src/ui/elements/media/index.ts b/app/src/ui/elements/media/index.ts new file mode 100644 index 0000000..142d2a7 --- /dev/null +++ b/app/src/ui/elements/media/index.ts @@ -0,0 +1,15 @@ +import { PreviewWrapperMemoized } from "./Dropzone"; +import { DropzoneContainer } from "./DropzoneContainer"; + +export const Media = { + Dropzone: DropzoneContainer, + Preview: PreviewWrapperMemoized +}; + +export type { + PreviewComponentProps, + FileState, + DropzoneProps, + DropzoneRenderProps +} from "./Dropzone"; +export type { DropzoneContainerProps } from "./DropzoneContainer"; diff --git a/app/src/ui/modules/media/components/dropzone/use-dropzone.ts b/app/src/ui/elements/media/use-dropzone.ts similarity index 100% rename from app/src/ui/modules/media/components/dropzone/use-dropzone.ts rename to app/src/ui/elements/media/use-dropzone.ts diff --git a/app/src/ui/elements/mocks/tailwind-merge.ts b/app/src/ui/elements/mocks/tailwind-merge.ts new file mode 100644 index 0000000..8db229a --- /dev/null +++ b/app/src/ui/elements/mocks/tailwind-merge.ts @@ -0,0 +1,3 @@ +export function twMerge(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} diff --git a/app/src/ui/hooks/use-effect.ts b/app/src/ui/hooks/use-effect.ts new file mode 100644 index 0000000..539bc7b --- /dev/null +++ b/app/src/ui/hooks/use-effect.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from "react"; + +export function useEffectOnce(effect: () => void | (() => void | undefined), deps: any[]): void { + const hasRunRef = useRef(false); + const savedDepsRef = useRef(deps); + + useEffect(() => { + const depsChanged = !hasRunRef.current || !areDepsEqual(savedDepsRef.current, deps); + + if (depsChanged) { + hasRunRef.current = true; + savedDepsRef.current = deps; + return effect(); + } + }, [deps]); +} + +function areDepsEqual(prevDeps: any[] | undefined, nextDeps: any[]): boolean { + if (prevDeps && prevDeps.length === 0 && nextDeps.length === 0) { + return true; + } + + if (!prevDeps && nextDeps.length === 0) { + return true; + } + + if (!prevDeps || !nextDeps || prevDeps.length !== nextDeps.length) { + return false; + } + + return prevDeps.every((dep, index) => Object.is(dep, nextDeps[index])); +} diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index e61e1d5..e10839a 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -191,7 +191,7 @@ export const SidebarLink = ({ className={twMerge( "flex flex-row px-4 py-2.5 items-center gap-2", !disabled && - "cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 link", + "cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 focus:bg-primary/5 link", disabled && "opacity-50 cursor-not-allowed pointer-events-none", className )} diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 44818fe..37f404a 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -1,6 +1,6 @@ import type { PrimaryFieldType } from "core"; import { encodeSearch } from "core/utils"; -import { useLocation } from "wouter"; +import { useLocation, useRouter } from "wouter"; import { useBknd } from "../client/BkndProvider"; export const routes = { @@ -55,6 +55,7 @@ export function withAbsolute(url: string) { export function useNavigate() { const [location, navigate] = useLocation(); + const router = useRouter(); const { app } = useBknd(); const basepath = app.getAdminConfig().basepath; return [ @@ -69,6 +70,7 @@ export function useNavigate() { transition?: boolean; } | { reload: true } + | { target: string } ) => { const wrap = (fn: () => void) => { fn(); @@ -81,9 +83,15 @@ export function useNavigate() { }; wrap(() => { - if (options && "reload" in options) { - window.location.href = url; - return; + if (options) { + if ("reload" in options) { + window.location.href = url; + return; + } else if ("target" in options) { + const _url = window.location.origin + basepath + router.base + url; + window.open(_url, options.target); + return; + } } const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url; diff --git a/app/src/ui/main.css b/app/src/ui/main.css index 6541e5a..9968b67 100644 --- a/app/src/ui/main.css +++ b/app/src/ui/main.css @@ -1,211 +1,74 @@ -@import "./components/form/json-schema/styles.css"; -@import "@xyflow/react/dist/style.css"; -@import "@mantine/core/styles.css"; -@import "@mantine/notifications/styles.css"; - @tailwind base; @tailwind components; @tailwind utilities; -html.fixed, -html.fixed body { - top: 0; - left: 0; - height: 100%; - width: 100%; - position: fixed; - overflow: hidden; - overscroll-behavior-x: contain; - 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; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overscroll-behavior-y: none; -} - #bknd-admin { - @apply bg-background text-primary overflow-hidden h-dvh w-dvw; + @apply bg-background text-primary overflow-hidden h-dvh w-dvw; - ::selection { - @apply bg-muted; - } + ::selection { + @apply bg-muted; + } - input { - &::selection { - @apply bg-primary/15; - } - } + input { + &::selection { + @apply bg-primary/15; + } + } } body, #bknd-admin { - @apply flex flex-1 flex-col h-dvh w-dvw; + @apply flex flex-1 flex-col h-dvh w-dvw; } @layer components { - .link { - @apply transition-colors active:translate-y-px; - } + .link { + @apply transition-colors active:translate-y-px; + } - .img-responsive { - @apply max-h-full w-auto; - } + .img-responsive { + @apply max-h-full w-auto; + } - /** - * debug classes - */ - .bordered-red { - @apply border-2 border-red-500; - } + /** + * debug classes + */ + .bordered-red { + @apply border-2 border-red-500; + } - .bordered-green { - @apply border-2 border-green-500; - } + .bordered-green { + @apply border-2 border-green-500; + } - .bordered-blue { - @apply border-2 border-blue-500; - } + .bordered-blue { + @apply border-2 border-blue-500; + } - .bordered-violet { - @apply border-2 border-violet-500; - } + .bordered-violet { + @apply border-2 border-violet-500; + } - .bordered-yellow { - @apply border-2 border-yellow-500; - } -} - -@layer utilities { -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -.app-scrollbar::-webkit-scrollbar { - display: none; -} -/* Hide scrollbar for IE, Edge and Firefox */ -.app-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -div[data-radix-scroll-area-viewport] > div:first-child { - display: block !important; - min-width: 100% !important; - max-width: 100%; -} - -/* hide calendar icon on inputs */ -input[type="datetime-local"]::-webkit-calendar-picker-indicator, -input[type="date"]::-webkit-calendar-picker-indicator { - display: none; -} - -/* cm */ -.cm-editor { - display: flex; - flex: 1; -} - -.animate-fade-in { - animation: fadeInAnimation 200ms ease; -} -@keyframes fadeInAnimation { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -input[readonly]::placeholder, -input[disabled]::placeholder { - opacity: 0.1; -} - -.react-flow__pane, -.react-flow__renderer, -.react-flow__node, -.react-flow__edge { - cursor: inherit !important; - .drag-handle { - cursor: grab; - } -} -.react-flow .react-flow__edge path, -.react-flow__connectionline path { - stroke-width: 2; -} - -.mantine-TextInput-wrapper input { - font-family: inherit; - line-height: 1; -} - -.cm-editor { - background: transparent; -} -.cm-editor.cm-focused { - outline: none; -} - -.flex-animate { - transition: flex-grow 0.2s ease, background-color 0.2s ease; -} -.flex-initial { - flex: 0 1 auto; -} -.flex-open { - flex: 1 1 0; + .bordered-yellow { + @apply border-2 border-yellow-500; + } } #bknd-admin, .bknd-admin { - /* Chrome, Edge, and Safari */ - & *::-webkit-scrollbar { - @apply w-1; - &:horizontal { - @apply h-px; - } - } + /* Chrome, Edge, and Safari */ + & *::-webkit-scrollbar { + @apply w-1; + &:horizontal { + @apply h-px; + } + } - & *::-webkit-scrollbar-track { - @apply bg-transparent w-1; - } + & *::-webkit-scrollbar-track { + @apply bg-transparent w-1; + } - & *::-webkit-scrollbar-thumb { - @apply bg-primary/25; - } + & *::-webkit-scrollbar-thumb { + @apply bg-primary/25; + } } diff --git a/app/src/ui/main.tsx b/app/src/ui/main.tsx index 623e6af..74a358d 100644 --- a/app/src/ui/main.tsx +++ b/app/src/ui/main.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; import Admin from "./Admin"; import "./main.css"; +import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/app/src/ui/modals/debug/OverlayModal.tsx b/app/src/ui/modals/debug/OverlayModal.tsx new file mode 100644 index 0000000..2dca9a3 --- /dev/null +++ b/app/src/ui/modals/debug/OverlayModal.tsx @@ -0,0 +1,22 @@ +import type { ContextModalProps } from "@mantine/modals"; +import type { ReactNode } from "react"; + +export function OverlayModal({ + context, + id, + innerProps: { content } +}: ContextModalProps<{ content?: ReactNode }>) { + return content; +} + +OverlayModal.defaultTitle = undefined; +OverlayModal.modalProps = { + withCloseButton: false, + classNames: { + size: "md", + root: "bknd-admin", + content: "text-center justify-center", + title: "font-bold !text-md", + body: "py-3 px-5 gap-4 flex flex-col" + } +}; diff --git a/app/src/ui/modals/debug/SchemaFormModal.tsx b/app/src/ui/modals/debug/SchemaFormModal.tsx index 72c1c89..fd9c304 100644 --- a/app/src/ui/modals/debug/SchemaFormModal.tsx +++ b/app/src/ui/modals/debug/SchemaFormModal.tsx @@ -7,21 +7,31 @@ import { } from "ui/components/form/json-schema"; import type { ContextModalProps } from "@mantine/modals"; +import { Alert } from "ui/components/display/Alert"; type Props = JsonSchemaFormProps & { - onSubmit?: (data: any) => void | Promise; + autoCloseAfterSubmit?: boolean; + onSubmit?: ( + data: any, + context: { + close: () => void; + } + ) => void | Promise; }; export function SchemaFormModal({ context, id, - innerProps: { schema, uiSchema, onSubmit } + innerProps: { schema, uiSchema, onSubmit, autoCloseAfterSubmit } }: ContextModalProps) { const [valid, setValid] = useState(false); const formRef = useRef(null); + const [submitting, setSubmitting] = useState(false); + const was_submitted = useRef(false); + const [error, setError] = useState(); - function handleChange(data) { - const valid = formRef.current?.validateForm() ?? false; + function handleChange(data, isValid) { + const valid = isValid(); console.log("Data changed", data, valid); setValid(valid); } @@ -30,29 +40,45 @@ export function SchemaFormModal({ context.closeModal(id); } - async function handleClickAdd() { - await onSubmit?.(formRef.current?.formData()); - handleClose(); + async function handleSubmit() { + was_submitted.current = true; + if (!formRef.current?.validateForm()) { + return; + } + + setSubmitting(true); + await onSubmit?.(formRef.current?.formData(), { + close: handleClose, + setError + }); + setSubmitting(false); + + if (autoCloseAfterSubmit !== false) { + handleClose(); + } } return ( -
- -
- - + <> + {error && } +
+ +
+ + +
-
+ ); } @@ -63,7 +89,7 @@ SchemaFormModal.modalProps = { root: "bknd-admin", header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px", content: "rounded-lg select-none", - title: "font-bold !text-md", + title: "!font-bold !text-md", body: "!p-0" } }; diff --git a/app/src/ui/modals/index.tsx b/app/src/ui/modals/index.tsx index 7a69560..9869158 100644 --- a/app/src/ui/modals/index.tsx +++ b/app/src/ui/modals/index.tsx @@ -1,7 +1,8 @@ import type { ModalProps } from "@mantine/core"; -import { ModalsProvider, modals as mantineModals } from "@mantine/modals"; -import { transformObject } from "core/utils"; +import { modals as $modals, ModalsProvider, closeModal, openContextModal } from "@mantine/modals"; import type { ComponentProps } from "react"; +import { OverlayModal } from "ui/modals/debug/OverlayModal"; +import { CreateModal } from "ui/modules/data/components/schema/create-modal/CreateModal"; import { DebugModal } from "./debug/DebugModal"; import { SchemaFormModal } from "./debug/SchemaFormModal"; import { TestModal } from "./debug/TestModal"; @@ -9,7 +10,9 @@ import { TestModal } from "./debug/TestModal"; const modals = { test: TestModal, debug: DebugModal, - form: SchemaFormModal + form: SchemaFormModal, + overlay: OverlayModal, + dataCreate: CreateModal }; declare module "@mantine/modals" { @@ -33,25 +36,29 @@ function open( ) { const title = _title ?? modals[modal].defaultTitle ?? undefined; const cmpModalProps = modals[modal].modalProps ?? {}; - return mantineModals.openContextModal({ + const props = { title, ...modalProps, ...cmpModalProps, modal, innerProps - }); + }; + openContextModal(props); + return { + close: () => close(modal), + closeAll: $modals.closeAll + }; } function close(modal: Modal) { - return mantineModals.close(modal); + return closeModal(modal); } export const bkndModals = { - ids: transformObject(modals, (key) => key) as unknown as Record< - keyof typeof modals, - keyof typeof modals - >, + ids: Object.fromEntries(Object.keys(modals).map((key) => [key, key])) as { + [K in keyof typeof modals]: K; + }, open, close, - closeAll: mantineModals.closeAll + closeAll: $modals.closeAll }; diff --git a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts new file mode 100644 index 0000000..ce08b6b --- /dev/null +++ b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts @@ -0,0 +1,53 @@ +import { useApi, useInvalidate } from "ui/client"; +import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; +import { routes, useNavigate } from "ui/lib/routes"; +import { bkndModals } from "ui/modals"; + +export function useCreateUserModal() { + const api = useApi(); + const { config } = useBkndAuth(); + const invalidate = useInvalidate(); + const [navigate] = useNavigate(); + + const open = async () => { + const loading = bkndModals.open("overlay", { + content: "Loading..." + }); + + const schema = await api.auth.actionSchema("password", "create"); + loading.closeAll(); // currently can't close by id... + + bkndModals.open( + "form", + { + schema, + uiSchema: { + password: { + "ui:widget": "password" + } + }, + autoCloseAfterSubmit: false, + onSubmit: async (data, ctx) => { + console.log("submitted:", data, ctx); + const res = await api.auth.action("password", "create", data); + console.log(res); + if (res.ok) { + // invalidate all data + invalidate(); + navigate(routes.data.entity.edit(config.entity_name, res.data.id)); + ctx.close(); + } else if ("error" in res) { + ctx.setError(res.error); + } else { + ctx.setError("Unknown error"); + } + } + }, + { + title: "Create User" + } + ); + }; + + return { open }; +} diff --git a/app/src/ui/modules/auth/index.ts b/app/src/ui/modules/auth/index.ts deleted file mode 100644 index f3940d7..0000000 --- a/app/src/ui/modules/auth/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AuthForm } from "ui/modules/auth/AuthForm"; -import { AuthScreen } from "ui/modules/auth/AuthScreen"; -import { SocialLink } from "ui/modules/auth/SocialLink"; - -export const Auth = { - Screen: AuthScreen, - Form: AuthForm, - SocialLink: SocialLink -}; diff --git a/app/src/ui/modules/data/components/EntityTable2.tsx b/app/src/ui/modules/data/components/EntityTable2.tsx index 05cbcb4..b9f516c 100644 --- a/app/src/ui/modules/data/components/EntityTable2.tsx +++ b/app/src/ui/modules/data/components/EntityTable2.tsx @@ -34,7 +34,11 @@ export function EntityTable2({ entity, select, ...props }: EntityTableProps) { const field = getField(property)!; _value = field.getValue(value, "table"); } catch (e) { - console.warn("Couldn't render value", { value, property, entity, select, ...props }, e); + console.warn( + "Couldn't render value", + { value, property, entity, select, columns, ...props }, + e + ); } return ; diff --git a/app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx b/app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx index cc893e6..aeb7822 100644 --- a/app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx +++ b/app/src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx @@ -2,7 +2,6 @@ import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/reac import type { AppDataConfig, TAppDataEntity } from "data/data-schema"; import { useBknd } from "ui/client/BkndProvider"; import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; -import { useTheme } from "ui/client/use-theme"; import { Canvas } from "ui/components/canvas/Canvas"; import { layoutWithDagre } from "ui/components/canvas/layouts"; import { Panels } from "ui/components/canvas/panels"; diff --git a/app/src/ui/modules/data/components/fields-specs.ts b/app/src/ui/modules/data/components/fields-specs.ts index e182113..79517fe 100644 --- a/app/src/ui/modules/data/components/fields-specs.ts +++ b/app/src/ui/modules/data/components/fields-specs.ts @@ -10,7 +10,7 @@ import { TbToggleLeft } from "react-icons/tb"; -type TFieldSpec = { +export type TFieldSpec = { type: string; label: string; icon: any; diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index 861bc24..1d3788c 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -9,6 +9,7 @@ import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; import * as Formy from "ui/components/form/Formy"; import { Popover } from "ui/components/overlay/Popover"; +import { Link } from "ui/components/wouter/Link"; import { routes } from "ui/lib/routes"; import { useLocation } from "wouter"; import { EntityTable } from "../EntityTable"; @@ -82,7 +83,9 @@ export function EntityRelationalFormField({ return ( - {field.getLabel()} + + {field.getLabel({ fallback: false }) ?? entity.label} +
- + + + ) : (
- Select -
diff --git a/app/src/ui/modules/data/components/schema/create-modal/CreateModal.tsx b/app/src/ui/modules/data/components/schema/create-modal/CreateModal.tsx index 1863d83..e44d04e 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/CreateModal.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/CreateModal.tsx @@ -1,15 +1,9 @@ -import { type Static, StringEnum, StringIdentifier, Type, transformObject } from "core/utils"; -import { FieldClassMap } from "data"; +import type { ModalProps } from "@mantine/core"; +import type { ContextModalProps } from "@mantine/modals"; +import { type Static, StringEnum, StringIdentifier, Type } from "core/utils"; import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema"; -import { omit } from "lodash-es"; -import { forwardRef, useState } from "react"; -import { - Modal2, - type Modal2Ref, - ModalBody, - ModalFooter, - ModalTitle -} from "ui/components/modal/Modal2"; +import { useState } from "react"; +import { type Modal2Ref, ModalBody, ModalFooter, ModalTitle } from "ui/components/modal/Modal2"; import { Step, Steps, useStepContext } from "ui/components/steps/Steps"; import { StepCreate } from "ui/modules/data/components/schema/create-modal/step.create"; import { StepEntity } from "./step.entity"; @@ -45,6 +39,7 @@ export type TFieldCreate = Static; const createModalSchema = Type.Object( { action: schemaAction, + initial: Type.Optional(Type.Any()), entities: Type.Optional( Type.Object({ create: Type.Optional(Type.Array(entitySchema)) @@ -67,48 +62,59 @@ const createModalSchema = Type.Object( ); export type TCreateModalSchema = Static; -export const CreateModal = forwardRef(function CreateModal(props, ref) { - const [path, setPath] = useState([]); +export function CreateModal({ + context, + id, + innerProps: { initialPath = [], initialState } +}: ContextModalProps<{ initialPath?: string[]; initialState?: TCreateModalSchema }>) { + const [path, setPath] = useState(initialPath); + console.log("...", initialPath, initialState); function close() { - // @ts-ignore - ref?.current?.close(); + context.closeModal(id); } return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - {/* Templates */} - {Templates.map(([Component, meta]) => ( - - - - - ))} - - + {/* Templates */} + {Templates.map(([Component, meta]) => ( + + + + + ))} + ); -}); +} +CreateModal.defaultTitle = undefined; +CreateModal.modalProps = { + withCloseButton: false, + size: "xl", + padding: 0, + classNames: { + root: "bknd-admin" + } +} satisfies Partial; export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema }; diff --git a/app/src/ui/modules/data/components/schema/create-modal/step.create.tsx b/app/src/ui/modules/data/components/schema/create-modal/step.create.tsx index 16cad49..b3996fc 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/step.create.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/step.create.tsx @@ -8,8 +8,8 @@ import { } from "@tabler/icons-react"; import { ucFirst } from "core/utils"; import { useEffect, useState } from "react"; -import { TbCirclesRelation, TbSettings } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; +import { useBknd } from "ui/client/bknd"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { IconButton, type IconType } from "ui/components/buttons/IconButton"; import { JsonViewer } from "ui/components/code/JsonViewer"; @@ -26,6 +26,7 @@ export function StepCreate() { const [states, setStates] = useState<(boolean | string)[]>([]); const [submitting, setSubmitting] = useState(false); const $data = useBkndData(); + const b = useBknd(); const items: ActionItem[] = []; if (state.entities?.create) { @@ -74,6 +75,10 @@ export function StepCreate() { try { const res = await item.run(); setStates((prev) => [...prev, res]); + if (res !== true) { + // make sure to break out + break; + } } catch (e) { setStates((prev) => [...prev, (e as any).message]); } @@ -90,7 +95,8 @@ export function StepCreate() { states.every((s) => s === true) ); if (items.length === states.length && states.every((s) => s === true)) { - close(); + b.actions.reload().then(close); + //close(); } else { setSubmitting(false); } @@ -105,10 +111,6 @@ export function StepCreate() { ))}
- {/*
{submitting ? "submitting" : "idle"}
-
- {states.length}/{items.length} -
*/} = ({ }) => { const [expanded, handlers] = useDisclosure(initialExpanded); const error = typeof state !== "undefined" && state !== true; + const done = state === true; return (
diff --git a/app/src/ui/modules/data/components/schema/create-modal/step.relation.tsx b/app/src/ui/modules/data/components/schema/create-modal/step.relation.tsx index 05066a1..30352fa 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/step.relation.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/step.relation.tsx @@ -1,6 +1,7 @@ import { typeboxResolver } from "@hookform/resolvers/typebox"; -import { Select, Switch, TextInput } from "@mantine/core"; +import { Switch, TextInput } from "@mantine/core"; import { TypeRegistry } from "@sinclair/typebox"; +import { IconDatabase } from "@tabler/icons-react"; import { type Static, StringEnum, @@ -9,12 +10,15 @@ import { registerCustomTypeboxKinds } from "core/utils"; import { ManyToOneRelation, type RelationType, RelationTypes } from "data"; -import { type ReactNode, useEffect } from "react"; +import { type ReactNode, startTransition, useEffect } from "react"; import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form"; +import { TbRefresh } from "react-icons/tb"; import { useBknd } from "ui/client/bknd"; +import { Button } from "ui/components/buttons/Button"; import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput"; import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; import { useStepContext } from "ui/components/steps/Steps"; +import { useEvent } from "ui/hooks/use-event"; import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal"; // @todo: check if this could become an issue @@ -63,7 +67,8 @@ type ComponentCtx = { export function StepRelation() { const { config } = useBknd(); const entities = config.data.entities; - const { nextStep, stepBack, state, setState } = useStepContext(); + const count = Object.keys(entities ?? {}).length; + const { nextStep, stepBack, state, path, setState } = useStepContext(); const { register, handleSubmit, @@ -76,7 +81,6 @@ export function StepRelation() { defaultValues: (state.relations?.create?.[0] ?? {}) as Static }); const data = watch(); - console.log("data", { data, schema }); function handleNext() { if (isValid) { @@ -93,8 +97,29 @@ export function StepRelation() { } } + const flip = useEvent(() => { + const { source, target } = data; + if (source && target) { + setValue("source", target); + setValue("target", source); + } else { + if (source) { + setValue("target", source); + setValue("source", null as any); + } else { + setValue("source", target); + setValue("target", null as any); + } + } + }); + return ( <> + {count < 2 && ( +
+ Not enough entities to create a relation. +
+ )}
@@ -109,14 +134,23 @@ export function StepRelation() { disabled: data.target === name }))} /> - setValue("config", {})} - label="Relation Type" - data={Relations.map((r) => ({ value: r.type, label: r.label }))} - allowDeselect={false} - /> +
+ setValue("config", {})} + label="Relation Type" + data={Relations.map((r) => ({ value: r.type, label: r.label }))} + allowDeselect={false} + /> + {data.type && ( +
+ +
+ )} +
@@ -198,6 +232,10 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp {source && target && config && ( <> +
+                     
+                     {source}.{config.mappedBy || target}_id {"→"} {target}
+                  

Many

{source}
will each have one reference to
{target}
.

@@ -211,7 +249,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp

{config.sourceCardinality ? (

-

{source}
should not have more than{" "} +
{target}
should not have more than{" "}
{config.sourceCardinality}
referencing entr {config.sourceCardinality === 1 ? "y" : "ies"} to
{source}
.

@@ -255,6 +293,10 @@ function OneToOne({ {source && target && ( <> +
+                     
+                     {source}.{mappedBy || target}_id {"↔"} {target}
+                  

A single entry of

{source}
will have a reference to{" "}
{target}
. @@ -313,6 +355,10 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com {source && target && ( <> +
+                     
+                     {source} {"→"} {table} {"←"} {target}
+                  

Many

{source}
will have many
{target}
.

@@ -355,6 +401,10 @@ function Polymorphic({ register, control, data: { type, source, target, config } {source && target && ( <> +
+                     
+                     {source} {"←"} {target}
+                  

{source}
will have many
{target}
.

diff --git a/app/src/ui/modules/data/components/schema/create-modal/step.select.tsx b/app/src/ui/modules/data/components/schema/create-modal/step.select.tsx index 30bb3a2..a26c62d 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/step.select.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/step.select.tsx @@ -12,7 +12,7 @@ import { import Templates from "./templates/register"; export function StepSelect() { - const { nextStep, stepBack, state, setState } = useStepContext(); + const { nextStep, stepBack, state, path, setState } = useStepContext(); const selected = state.action ?? null; function handleSelect(action: TSchemaAction) { @@ -74,6 +74,7 @@ export function StepSelect() { }} prev={{ onClick: stepBack }} prevLabel="Cancel" + debug={{ state, path }} /> ); diff --git a/app/src/ui/modules/data/components/schema/create-modal/templates/media/template.media.component.tsx b/app/src/ui/modules/data/components/schema/create-modal/templates/media/template.media.component.tsx index 3f5474b..4038880 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/templates/media/template.media.component.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/templates/media/template.media.component.tsx @@ -9,6 +9,7 @@ import { transformObject } from "core/utils"; import type { MediaFieldConfig } from "media/MediaField"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useBknd } from "ui/client/bknd"; import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput"; @@ -31,18 +32,19 @@ const schema = Type.Object({ type TCreateModalMediaSchema = Static; export function TemplateMediaComponent() { - const { stepBack, setState, state, nextStep } = useStepContext(); + const { stepBack, setState, state, path, nextStep } = useStepContext(); const { register, handleSubmit, - formState: { isValid }, - setValue, + formState: { isValid, errors }, watch, control } = useForm({ + mode: "onChange", resolver: typeboxResolver(schema), - defaultValues: Default(schema, {}) as TCreateModalMediaSchema + defaultValues: Default(schema, state.initial ?? {}) as TCreateModalMediaSchema }); + const [forbidden, setForbidden] = useState(false); const { config } = useBknd(); const media_enabled = config.media.enabled ?? false; @@ -51,13 +53,16 @@ export function TemplateMediaComponent() { name !== media_entity ? entity : undefined ); const data = watch(); + const forbidden_field_names = Object.keys(config.data.entities?.[data.entity]?.fields ?? {}); + + useEffect(() => { + setForbidden(forbidden_field_names.includes(data.name)); + }, [forbidden_field_names, data.name]); async function handleCreate() { - if (isValid) { - console.log("data", data); + if (isValid && !forbidden) { const { field, relation } = convert(media_entity, data); - console.log("state", { field, relation }); setState((prev) => ({ ...prev, fields: { create: [field] }, @@ -120,6 +125,13 @@ export function TemplateMediaComponent() { data.entity ? data.entity : "the entity" }.`} {...register("name")} + error={ + errors.name?.message + ? errors.name?.message + : forbidden + ? `Property "${data.name}" already exists on entity ${data.entity}` + : undefined + } />
{/*

step template media

@@ -129,12 +141,12 @@ export function TemplateMediaComponent() { diff --git a/app/src/ui/modules/data/hooks/useEntityForm.tsx b/app/src/ui/modules/data/hooks/useEntityForm.tsx index 45432d7..ebef8c7 100644 --- a/app/src/ui/modules/data/hooks/useEntityForm.tsx +++ b/app/src/ui/modules/data/hooks/useEntityForm.tsx @@ -29,7 +29,11 @@ export function useEntityForm({ onSubmitAsync: async ({ value }): Promise => { try { //console.log("validating", value, entity.isValidData(value, action)); - entity.isValidData(value, action, true); + entity.isValidData(value, action, { + explain: true, + // unknown will later be removed in getChangeSet + ignoreUnknown: true + }); return undefined; } catch (e) { //console.log("---validation error", e); diff --git a/app/src/ui/routes/auth/auth.login.tsx b/app/src/ui/routes/auth/auth.login.tsx index b9bf183..659624e 100644 --- a/app/src/ui/routes/auth/auth.login.tsx +++ b/app/src/ui/routes/auth/auth.login.tsx @@ -1,7 +1,18 @@ +import { Logo } from "ui/components/display/Logo"; +import { Link } from "ui/components/wouter/Link"; +import { Auth } from "ui/elements"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; -import { AuthScreen } from "ui/modules/auth/AuthScreen"; export function AuthLogin() { useBrowserTitle(["Login"]); - return ; + return ( + + + + } + /> + ); } diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index 6500bc3..738e568 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -1,10 +1,10 @@ -import { notifications } from "@mantine/notifications"; import { useRef } from "react"; import { TbDots } from "react-icons/tb"; import { useBknd } from "ui/client/bknd"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; +import { Message } from "ui/components/display/Message"; import { Dropdown } from "ui/components/overlay/Dropdown"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; @@ -12,7 +12,11 @@ import { routes, useNavigate } from "ui/lib/routes"; import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form"; export function AuthRolesEdit(props) { - useBknd({ withSecrets: true }); + const { hasSecrets } = useBknd({ withSecrets: true }); + if (!hasSecrets) { + return ; + } + return ; } @@ -28,14 +32,9 @@ function AuthRolesEditInternal({ params }) { if (!formRef.current?.isValid()) return; const data = formRef.current?.getData(); const success = await actions.roles.patch(roleName, data); - - /*notifications.show({ - id: `role-${roleName}-update`, - position: "top-right", - title: success ? "Update success" : "Update failed", - message: success ? "Role updated successfully" : "Failed to update role", - color: !success ? "red" : undefined - });*/ + if (success) { + navigate(routes.auth.roles.list()); + } } async function handleDelete() { diff --git a/app/src/ui/routes/auth/auth.roles.tsx b/app/src/ui/routes/auth/auth.roles.tsx index c240fe0..616856a 100644 --- a/app/src/ui/routes/auth/auth.roles.tsx +++ b/app/src/ui/routes/auth/auth.roles.tsx @@ -90,9 +90,16 @@ const renderValue = ({ value, property }) => { } if (property === "permissions") { + const max = 3; + let permissions = value || []; + const count = permissions.length; + if (count > max) { + permissions = [...permissions.slice(0, max), `+${count - max}`]; + } + return (
- {[...(value || [])].map((p, i) => ( + {permissions.map((p, i) => ( = { regular: [], generated: [], @@ -22,7 +31,7 @@ export function DataRoot({ children }) { const [navigate] = useNavigate(); const context = window.location.href.match(/\/schema/) ? "schema" : "data"; - for (const entity of entities) { + for (const entity of Object.values(entities)) { entityList[entity.getType()].push(entity); } @@ -52,14 +61,19 @@ export function DataRoot({ children }) { + <> + + + + + } > Entities @@ -70,7 +84,7 @@ export function DataRoot({ children }) {
*/} - + { - if (entities.length === 0) return null; + context, + suggestCreate = false +}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => { + const { $data } = useBkndData(); + if (entities.length === 0) { + return suggestCreate ? ( + $data.modals.createEntity() + }} + /> + ) : null; + } return ( ); }; +const EntityContextMenu = ({ + entity, + children, + enabled = true +}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => { + if (!enabled) return children; + const [navigate] = useNavigate(); + const { $data } = useBkndData(); + + // get href from children (single item) + const href = (children as any).props.href; + const separator = () =>
; + + return ( + navigate(href, { target: "_blank" }) + }, + separator, + !$data.system(entity.name).any && { + icon: IconPlus, + label: "Create new", + onClick: () => navigate(routes.data.entity.create(entity.name)) + }, + { + icon: IconDatabase, + label: "List entries", + onClick: () => navigate(routes.data.entity.list(entity.name)) + }, + separator, + { + icon: IconAlignJustified, + label: "Manage fields", + onClick: () => navigate(routes.data.schema.entity(entity.name)) + }, + { + icon: IconCirclesRelation, + label: "Add relation", + onClick: () => $data.modals.createRelation(entity.name) + }, + !$data.system(entity.name).media && { + icon: IconPhoto, + label: "Add media", + onClick: () => $data.modals.createMedia(entity.name) + }, + separator, + { + icon: IconSettings, + label: "Settings", + onClick: () => + navigate(routes.settings.path(["data", "entities", entity.name]), { + absolute: true + }) + } + ]} + openEvent="onContextMenu" + position="bottom-start" + > + {children} + + ); +}; + export function DataEmpty() { useBrowserTitle(["Data"]); const [navigate] = useNavigate(); + const { $data } = useBkndData(); function handleButtonClick() { - //navigate(routes.settings.path(["data", "entities"]), { absolute: true }); navigate(routes.data.schema.root()); } @@ -130,8 +230,14 @@ export function DataEmpty() { Icon={IconDatabase} title="No entity selected" description="Please select an entity from the left sidebar or create a new one to continue." - buttonText="Go to schema" - buttonOnClick={handleButtonClick} + secondary={{ + children: "Go to schema", + onClick: handleButtonClick + }} + primary={{ + children: "Create entity", + onClick: $data.modals.createEntity + }} /> ); } diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index a641187..a914c3d 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -6,6 +6,7 @@ import { useApiQuery, useEntityQuery } from "ui/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; +import { Message } from "ui/components/display/Message"; import { Dropdown } from "ui/components/overlay/Dropdown"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; @@ -18,7 +19,11 @@ import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; export function DataEntityUpdate({ params }) { const { $data, relations } = useBkndData(); - const entity = $data.entity(params.entity as string)!; + const entity = $data.entity(params.entity as string); + if (!entity) { + return ; + } + const entityId = Number.parseInt(params.id as string); const [error, setError] = useState(null); const [navigate] = useNavigate(); @@ -36,7 +41,9 @@ export function DataEntityUpdate({ params }) { with: local_relation_refs }, { - revalidateOnFocus: false + keepPreviousData: false, + revalidateOnFocus: false, + shouldRetryOnError: false } ); @@ -81,8 +88,15 @@ export function DataEntityUpdate({ params }) { onSubmitted }); - const makeKey = (key: string | number = "") => - `${params.entity.name}_${entityId}_${String(key)}`; + if (!data && !$q.isLoading) { + return ( + + ); + } + + const makeKey = (key: string | number = "") => `${entity.name}_${entityId}_${String(key)}`; const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting; @@ -234,7 +248,7 @@ function EntityDetailInner({ const other = relation.other(entity); const [navigate] = useNavigate(); - const search: Partial = { + const search = { select: other.entity.getSelect(undefined, "table"), limit: 10, offset: 0 diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 5b16b64..be37370 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -2,8 +2,9 @@ import { Type } from "core/utils"; import type { EntityData } from "data"; import { useState } from "react"; import { useEntityMutate } from "ui/client"; -import { useBknd } from "ui/client/BkndProvider"; +import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; +import { Message } from "ui/components/display/Message"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useSearch } from "ui/hooks/use-search"; import * as AppShell from "ui/layouts/AppShell/AppShell"; @@ -13,8 +14,14 @@ import { EntityForm } from "ui/modules/data/components/EntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; export function DataEntityCreate({ params }) { - const { app } = useBknd(); - const entity = app.entity(params.entity as string)!; + const { $data } = useBkndData(); + const entity = $data.entity(params.entity as string); + if (!entity) { + return ; + } else if (entity.type !== "regular") { + return ; + } + const [error, setError] = useState(null); useBrowserTitle(["Data", entity.label, "Create"]); @@ -43,7 +50,7 @@ export function DataEntityCreate({ params }) { const { Form, handleSubmit } = useEntityForm({ action: "create", - entity, + entity: entity, initialData: search.value, onSubmitted }); diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 831e5ff..d096731 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -1,7 +1,9 @@ import { Type } from "core/utils"; -import { querySchema } from "data"; +import { type Entity, querySchema } from "data"; +import { Fragment } from "react"; import { TbDots } from "react-icons/tb"; -import { useApiQuery } from "ui/client"; +import { useApi, useApiQuery } from "ui/client"; +import { useBknd } from "ui/client/bknd"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; @@ -11,6 +13,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useSearch } from "ui/hooks/use-search"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { routes, useNavigate } from "ui/lib/routes"; +import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; // @todo: migrate to Typebox @@ -29,23 +32,28 @@ const PER_PAGE_OPTIONS = [5, 10, 25]; export function DataEntityList({ params }) { const { $data } = useBkndData(); - const entity = $data.entity(params.entity as string)!; + const entity = $data.entity(params.entity as string); + if (!entity) { + return ; + } + useBrowserTitle(["Data", entity?.label ?? params.entity]); const [navigate] = useNavigate(); const search = useSearch(searchSchema, { - select: entity?.getSelect(undefined, "table") ?? [], - sort: entity?.getDefaultSort() + select: undefined, + sort: undefined }); const $q = useApiQuery( (api) => - api.data.readMany(entity.name, { + api.data.readMany(entity?.name as any, { select: search.value.select, limit: search.value.perPage, offset: (search.value.page - 1) * search.value.perPage, - sort: search.value.sort + sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}` }), { + enabled: !!entity, revalidateOnFocus: true, keepPreviousData: true } @@ -75,14 +83,10 @@ export function DataEntityList({ params }) { search.set("perPage", perPage); } - if (!entity) { - return ; - } - const isUpdating = $q.isLoading && $q.isValidating; return ( - <> + @@ -90,6 +94,14 @@ export function DataEntityList({ params }) { items={[ { label: "Settings", + onClick: () => navigate(routes.data.schema.entity(entity.name)) + }, + { + label: "Data Schema", + onClick: () => navigate(routes.data.schema.root()) + }, + { + label: "Advanced Settings", onClick: () => navigate(routes.settings.path(["data", "entities", entity.name]), { absolute: true @@ -100,14 +112,7 @@ export function DataEntityList({ params }) { > - + } > @@ -126,7 +131,7 @@ export function DataEntityList({ params }) {
- + + ); +} + +function EntityCreateButton({ entity }: { entity: Entity }) { + const b = useBknd(); + const createUserModal = useCreateUserModal(); + + const [navigate] = useNavigate(); + if (!entity) return null; + if (entity.type !== "regular") { + const system = { + users: b.app.config.auth.entity_name, + media: b.app.config.media.entity_name + }; + if (system.users === entity.name) { + return ( + + ); + } + + return null; + } + + return ( + ); } diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index 5d797c7..ba4d8c4 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -8,23 +8,32 @@ import { isDebug } from "core"; import type { Entity } from "data"; import { cloneDeep } from "lodash-es"; import { useRef, useState } from "react"; -import { TbDots } from "react-icons/tb"; +import { + TbCirclesRelation, + TbDatabasePlus, + TbDots, + TbPhoto, + TbPlus, + TbSitemap +} from "react-icons/tb"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Empty } from "ui/components/display/Empty"; +import { Message } from "ui/components/display/Message"; import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import { Dropdown } from "ui/components/overlay/Dropdown"; +import { Link } from "ui/components/wouter/Link"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { routes, useNavigate } from "ui/lib/routes"; +import { fieldSpecs } from "ui/modules/data/components/fields-specs"; import { extractSchema } from "../settings/utils/schema"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; export function DataSchemaEntity({ params }) { const { $data } = useBkndData(); const [value, setValue] = useState("fields"); - const fieldsRef = useRef(null); function toggle(value) { return () => setValue(value); @@ -32,6 +41,9 @@ export function DataSchemaEntity({ params }) { const [navigate] = useNavigate(); const entity = $data.entity(params.entity as string)!; + if (!entity) { + return ; + } return ( <> @@ -41,7 +53,14 @@ export function DataSchemaEntity({ params }) { + navigate(routes.data.root() + routes.data.entity.list(entity.name), { + absolute: true + }) + }, + { + label: "Advanced Settings", onClick: () => navigate(routes.settings.path(["data", "entities", entity.name]), { absolute: true @@ -52,14 +71,42 @@ export function DataSchemaEntity({ params }) { > + $data.modals.createRelation(entity.name) + }, + { + icon: TbPhoto, + label: "Add media", + onClick: () => $data.modals.createMedia(entity.name) + }, + () =>
, + { + icon: TbDatabasePlus, + label: "Create Entity", + onClick: () => $data.modals.createEntity() + } + ]} + position="bottom-end" + > + + } className="pl-3" > - +
+ + + + +
@@ -74,12 +121,11 @@ export function DataSchemaEntity({ params }) { - navigate(routes.settings.path(["data", "relations"]), { - absolute: true - }) - } + primary={{ + children: "Advanced Settings", + onClick: () => + navigate(routes.settings.path(["data", "relations"]), { absolute: true }) + }} /> - navigate(routes.settings.path(["data", "indices"]), { - absolute: true - }) - } + primary={{ + children: "Advanced Settings", + onClick: () => + navigate(routes.settings.path(["data", "indices"]), { + absolute: true + }) + }} />
@@ -111,7 +158,7 @@ const Fields = ({ }: { entity: Entity; open: boolean; toggle: () => void }) => { const [submitting, setSubmitting] = useState(false); const [updates, setUpdates] = useState(0); - const { actions } = useBkndData(); + const { actions, $data } = useBkndData(); const [res, setRes] = useState(); const ref = useRef(null); async function handleUpdate() { @@ -144,7 +191,27 @@ const Fields = ({ {submitting && (
)} - + ["relation", "media"].includes(f.type)) + .map((i) => ({ + ...i, + onClick: () => { + switch (i.type) { + case "relation": + $data.modals.createRelation(entity.name); + break; + case "media": + $data.modals.createMedia(entity.name); + break; + } + } + }))} + /> {isDebug() && (
diff --git a/app/src/ui/routes/data/data.schema.index.tsx b/app/src/ui/routes/data/data.schema.index.tsx index a9cbe18..8635650 100644 --- a/app/src/ui/routes/data/data.schema.index.tsx +++ b/app/src/ui/routes/data/data.schema.index.tsx @@ -1,10 +1,7 @@ -import { Suspense, lazy, useRef } from "react"; -import { - CreateModal, - type CreateModalRef -} from "ui/modules/data/components/schema/create-modal/CreateModal"; -import { Button } from "../../components/buttons/Button"; -import * as AppShell from "../../layouts/AppShell/AppShell"; +import { Suspense, lazy } from "react"; +import { useBkndData } from "ui/client/schema/data/use-bknd-data"; +import { Button } from "ui/components/buttons/Button"; +import * as AppShell from "ui/layouts/AppShell/AppShell"; const DataSchemaCanvas = lazy(() => import("ui/modules/data/components/canvas/DataSchemaCanvas").then((m) => ({ @@ -13,18 +10,12 @@ const DataSchemaCanvas = lazy(() => ); export function DataSchemaIndex() { - const createModalRef = useRef(null); - + const { $data } = useBkndData(); return ( <> - createModalRef.current?.open()} - > + } diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index bcc315d..7d64fc5 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -25,7 +25,7 @@ import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitc import { JsonSchemaForm } from "ui/components/form/json-schema"; import { type SortableItemProps, SortableList } from "ui/components/list/SortableList"; import { Popover } from "ui/components/overlay/Popover"; -import { fieldSpecs } from "ui/modules/data/components/fields-specs"; +import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs"; import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; const fieldsSchemaObject = originalFieldsSchemaObject; @@ -45,7 +45,6 @@ type TFieldsFormSchema = Static; const fieldTypes = Object.keys(fieldsSchemaObject); const defaultType = fieldTypes[0]; -const blank_field = { name: "", field: { type: defaultType, config: {} } } as TFieldSchema; const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"]; function specificFieldSchema(type: keyof typeof fieldsSchemaObject) { @@ -53,6 +52,13 @@ function specificFieldSchema(type: keyof typeof fieldsSchemaObject) { return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps); } +export type EntityFieldsFormProps = { + fields: TAppDataEntityFields; + onChange?: (formData: TAppDataEntityFields) => void; + sortable?: boolean; + additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; +}; + export type EntityFieldsFormRef = { getValues: () => TFieldsFormSchema; getData: () => TAppDataEntityFields; @@ -60,146 +66,156 @@ export type EntityFieldsFormRef = { reset: () => void; }; -export const EntityFieldsForm = forwardRef< - EntityFieldsFormRef, - { - fields: TAppDataEntityFields; - onChange?: (formData: TAppDataEntityFields) => void; - sortable?: boolean; - } ->(function EntityFieldsForm({ fields: _fields, sortable, ...props }, ref) { - const entityFields = Object.entries(_fields).map(([name, field]) => ({ - name, - field - })); +export const EntityFieldsForm = forwardRef( + function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) { + const entityFields = Object.entries(_fields).map(([name, field]) => ({ + name, + field + })); - const { - control, - formState: { isValid, errors }, - getValues, - watch, - register, - setValue, - setError, - reset - } = useForm({ - mode: "all", - resolver: typeboxResolver(schema), - defaultValues: { - fields: entityFields - } as TFieldsFormSchema - }); - const { fields, append, remove, move } = useFieldArray({ - control, - name: "fields" - }); + const { + control, + formState: { isValid, errors }, + getValues, + watch, + register, + setValue, + setError, + reset + } = useForm({ + mode: "all", + resolver: typeboxResolver(schema), + defaultValues: { + fields: entityFields + } as TFieldsFormSchema + }); + const { fields, append, remove, move } = useFieldArray({ + control, + name: "fields" + }); - function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields { - return Object.fromEntries( - formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)]) - ); - } - - useEffect(() => { - if (props?.onChange) { - console.log("----set"); - watch((data: any) => { - console.log("---calling"); - props?.onChange?.(toCleanValues(data)); - }); + function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields { + return Object.fromEntries( + formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)]) + ); } - }, []); - useImperativeHandle(ref, () => ({ - reset, - getValues: () => getValues(), - getData: () => { - return toCleanValues(getValues()); - }, - isValid: () => isValid - })); - - function handleAppend(_type: keyof typeof fieldsSchemaObject) { - const newField = { - name: "", - new: true, - field: { - type: _type, - config: {} + useEffect(() => { + if (props?.onChange) { + console.log("----set"); + watch((data: any) => { + console.log("---calling"); + props?.onChange?.(toCleanValues(data)); + }); } - }; - append(newField); - } + }, []); - const formProps = { - watch, - register, - setValue, - getValues, - control, - setError - }; - return ( - <> -
-
-
- {sortable ? ( - item.id} - disableIndices={[0]} - renderItem={({ dnd, ...props }, index) => ( - ({ + reset, + getValues: () => getValues(), + getData: () => { + return toCleanValues(getValues()); + }, + isValid: () => isValid + })); + + function handleAppend(_type: keyof typeof fieldsSchemaObject) { + const newField = { + name: "", + new: true, + field: { + type: _type, + config: {} + } + }; + append(newField); + } + + const formProps = { + watch, + register, + setValue, + getValues, + control, + setError + }; + return ( + <> +
+
+
+ {sortable ? ( + item.id} + disableIndices={[0]} + renderItem={({ dnd, ...props }, index) => ( + + )} + /> + ) : ( +
+ {fields.map((field, index) => ( + + ))} +
+ )} + + ( + { + handleAppend(type as any); + }} /> )} - /> - ) : ( -
- {fields.map((field, index) => ( - - ))} -
- )} - - ( - { - handleAppend(type as any); - toggle(); - }} - /> - )} - > - - + > + +
+
-
- - ); -}); + + ); + } +); -const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => { - const types = fieldSpecs.filter((s) => s.addable !== false); +const SelectType = ({ + onSelect, + additionalFieldTypes = [], + onSelected +}: { + onSelect: (type: string) => void; + additionalFieldTypes?: (TFieldSpec & { onClick?: () => void })[]; + onSelected?: () => void; +}) => { + const types: (TFieldSpec & { onClick?: () => void })[] = fieldSpecs.filter( + (s) => s.addable !== false + ); + + if (additionalFieldTypes) { + types.push(...additionalFieldTypes); + } return (
@@ -208,7 +224,14 @@ const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => { key={type.type} IconLeft={type.icon} variant="ghost" - onClick={() => onSelect(type.type)} + onClick={() => { + if (type.addable !== false) { + onSelect(type.type); + } else { + type.onClick?.(); + } + onSelected?.(); + }} > {type.label} diff --git a/app/src/ui/routes/flows_old/_flows.root.tsx b/app/src/ui/routes/flows_old/_flows.root.tsx index e7bb1c6..15d1e09 100644 --- a/app/src/ui/routes/flows_old/_flows.root.tsx +++ b/app/src/ui/routes/flows_old/_flows.root.tsx @@ -55,8 +55,10 @@ export function FlowsEmpty() { title="No flow selected" description="Please select a flow from the left sidebar or create a new one to continue." - buttonText="Create Flow" - buttonOnClick={() => navigate(app.getSettingsPath(["flows"]))} + primary={{ + children: "Create Flow", + onClick: () => navigate(app.getSettingsPath(["flows"])) + }} /> diff --git a/app/src/ui/routes/media/_media.root.tsx b/app/src/ui/routes/media/_media.root.tsx index 4b34188..959a636 100644 --- a/app/src/ui/routes/media/_media.root.tsx +++ b/app/src/ui/routes/media/_media.root.tsx @@ -20,8 +20,10 @@ export function MediaRoot({ children }) { Icon={IconPhoto} title="Media not enabled" description="Please enable media in the settings to continue." - buttonText="Manage Settings" - buttonOnClick={() => navigate(app.getSettingsPath(["media"]))} + primary={{ + children: "Manage Settings", + onClick: () => navigate(app.getSettingsPath(["media"])) + }} /> ); } diff --git a/app/src/ui/routes/root.tsx b/app/src/ui/routes/root.tsx index a0b2ed8..184c903 100644 --- a/app/src/ui/routes/root.tsx +++ b/app/src/ui/routes/root.tsx @@ -1,17 +1,18 @@ import { IconHome } from "@tabler/icons-react"; import { useEffect } from "react"; import { useAuth } from "ui/client"; +import { useEffectOnce } from "ui/hooks/use-effect"; import { Empty } from "../components/display/Empty"; import { useBrowserTitle } from "../hooks/use-browser-title"; import * as AppShell from "../layouts/AppShell/AppShell"; import { useNavigate } from "../lib/routes"; export const Root = ({ children }) => { - const { verify } = useAuth(); + const { verify, user } = useAuth(); - useEffect(() => { + useEffectOnce(() => { verify(); - }, []); + }, [user?.id]); return ( diff --git a/app/src/ui/routes/settings/components/Setting.tsx b/app/src/ui/routes/settings/components/Setting.tsx index 20a852b..b0c321b 100644 --- a/app/src/ui/routes/settings/components/Setting.tsx +++ b/app/src/ui/routes/settings/components/Setting.tsx @@ -149,8 +149,9 @@ export function Setting({ console.log("save:success", success); if (success) { if (options?.reloadOnSave) { - window.location.reload(); - //await actions.reload(); + //window.location.reload(); + await actions.reload(); + setSubmitting(false); } } else { setSubmitting(false); @@ -175,7 +176,10 @@ export function Setting({ goBack() + }} /> ); } diff --git a/app/src/ui/routes/settings/index.tsx b/app/src/ui/routes/settings/index.tsx index 89a17fe..026005b 100644 --- a/app/src/ui/routes/settings/index.tsx +++ b/app/src/ui/routes/settings/index.tsx @@ -2,11 +2,12 @@ import { IconSettings } from "@tabler/icons-react"; import { ucFirst } from "core/utils"; import { useBknd } from "ui/client/bknd"; import { Empty } from "ui/components/display/Empty"; +import { Message } from "ui/components/display/Message"; import { Link } from "ui/components/wouter/Link"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { Route, Switch } from "wouter"; -import { Setting } from "./components/Setting"; +import { Setting, type SettingProps } from "./components/Setting"; import { AuthSettings } from "./routes/auth.settings"; import { DataSettings } from "./routes/data.settings"; import { FlowsSettings } from "./routes/flows.settings"; @@ -44,7 +45,9 @@ function SettingsSidebar() { } export default function SettingsRoutes() { - useBknd({ withSecrets: true }); + const b = useBknd({ withSecrets: true }); + if (!b.hasSecrets) return ; + return ( <> @@ -117,13 +120,24 @@ const SettingRoutesRoutes = () => { - + ); }; -const FallbackRoutes = ({ module, schema, config, ...settingProps }) => { +const FallbackRoutes = ({ + module, + schema, + config, + ...settingProps +}: SettingProps & { module: string }) => { const { app } = useBknd(); const basepath = app.getAdminConfig(); const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/"); diff --git a/app/src/ui/styles.css b/app/src/ui/styles.css new file mode 100644 index 0000000..9899752 --- /dev/null +++ b/app/src/ui/styles.css @@ -0,0 +1,134 @@ +@import "./main.css"; +@import "./components/form/json-schema/styles.css"; +@import "@xyflow/react/dist/style.css"; +@import "@mantine/core/styles.css"; +@import "@mantine/notifications/styles.css"; + +html.fixed, +html.fixed body { + top: 0; + left: 0; + height: 100%; + width: 100%; + position: fixed; + overflow: hidden; + overscroll-behavior-x: contain; + 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; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overscroll-behavior-y: none; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.app-scrollbar::-webkit-scrollbar { + display: none; +} +/* Hide scrollbar for IE, Edge and Firefox */ +.app-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +div[data-radix-scroll-area-viewport] > div:first-child { + display: block !important; + min-width: 100% !important; + max-width: 100%; +} + +/* hide calendar icon on inputs */ +input[type="datetime-local"]::-webkit-calendar-picker-indicator, +input[type="date"]::-webkit-calendar-picker-indicator { + display: none; +} + +/* cm */ +.cm-editor { + display: flex; + flex: 1; +} + +.animate-fade-in { + animation: fadeInAnimation 200ms ease; +} +@keyframes fadeInAnimation { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +input[readonly]::placeholder, +input[disabled]::placeholder { + opacity: 0.1; +} + +.react-flow__pane, +.react-flow__renderer, +.react-flow__node, +.react-flow__edge { + cursor: inherit !important; + .drag-handle { + cursor: grab; + } +} +.react-flow .react-flow__edge path, +.react-flow__connectionline path { + stroke-width: 2; +} + +.mantine-TextInput-wrapper input { + font-family: inherit; + line-height: 1; +} + +.cm-editor { + background: transparent; +} +.cm-editor.cm-focused { + outline: none; +} + +.flex-animate { + transition: flex-grow 0.2s ease, background-color 0.2s ease; +} +.flex-initial { + flex: 0 1 auto; +} +.flex-open { + flex: 1 1 0; +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 5da4aeb..9c43d51 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,7 +7,7 @@ import { devServerConfig } from "./src/adapter/vite/dev-server-config"; // https://vitejs.dev/config/ export default defineConfig({ define: { - __isDev: "1" + __isDev: process.env.NODE_ENV === "production" ? "0" : "1" }, clearScreen: false, publicDir: "./src/ui/assets", diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 244b84d..2f40dc5 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -1,44 +1,43 @@ import { readFile } from "node:fs/promises"; import { serveStatic } from "@hono/node-server/serve-static"; -import { createClient } from "@libsql/client/node"; import { App, registries } from "./src"; -import { LibsqlConnection } from "./src/data"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; registries.media.register("local", StorageLocalAdapter); -const run_example: string | boolean = false; -//run_example = "ex-admin-rich"; +const example = import.meta.env.VITE_EXAMPLE; -const credentials = run_example +const credentials = example ? { - url: `file:.configs/${run_example}.db` - //url: ":memory:" + url: `file:.configs/${example}.db` } - : { - url: import.meta.env.VITE_DB_URL!, - authToken: import.meta.env.VITE_DB_TOKEN! - }; -if (!credentials.url) { - throw new Error("Missing VITE_DB_URL env variable. Add it to .env file"); -} - -const connection = new LibsqlConnection(createClient(credentials)); + : import.meta.env.VITE_DB_URL + ? { + url: import.meta.env.VITE_DB_URL!, + authToken: import.meta.env.VITE_DB_TOKEN! + } + : { + url: ":memory:" + }; let initialConfig: any = undefined; -if (run_example) { - const { version, ...config } = JSON.parse( - await readFile(`.configs/${run_example}.json`, "utf-8") - ); +if (example) { + const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8")); initialConfig = config; } let app: App; -const recreate = true; +const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1"; export default { async fetch(request: Request) { if (!app || recreate) { - app = App.create({ connection, initialConfig }); + app = App.create({ + connection: { + type: "libsql", + config: credentials + }, + initialConfig + }); app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { diff --git a/biome.json b/biome.json index 699da84..3274f11 100644 --- a/biome.json +++ b/biome.json @@ -19,6 +19,11 @@ "trailingCommas": "none" } }, + "css": { + "formatter": { + "indentWidth": 3 + } + }, "files": { "ignore": [ "**/node_modules/**", @@ -35,6 +40,7 @@ }, "linter": { "enabled": true, + "ignore": ["**/*.spec.ts"], "rules": { "recommended": true, "a11y": { @@ -47,6 +53,7 @@ "noSwitchDeclarations": "warn" }, "complexity": { + "noUselessFragments": "warn", "noStaticOnlyClass": "off", "noForEach": "off", "useLiteralKeys": "warn", @@ -74,4 +81,4 @@ } } } -} \ No newline at end of file +} diff --git a/bun.lockb b/bun.lockb index a37ebf5..19836ad 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/astro/src/pages/admin/[...admin].astro b/examples/astro/src/pages/admin/[...admin].astro index 30561b2..612b801 100644 --- a/examples/astro/src/pages/admin/[...admin].astro +++ b/examples/astro/src/pages/admin/[...admin].astro @@ -5,6 +5,7 @@ import "bknd/dist/styles.css"; import { getApi } from "bknd/adapter/astro"; const api = getApi(Astro, { mode: "dynamic" }); +await api.verifyAuth(); const user = api.getUser(); export const prerender = false; diff --git a/examples/astro/src/pages/ssr.astro b/examples/astro/src/pages/ssr.astro index eb3a8aa..726076d 100644 --- a/examples/astro/src/pages/ssr.astro +++ b/examples/astro/src/pages/ssr.astro @@ -3,6 +3,8 @@ 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 { data } = await api.data.readMany("todos"); const user = api.getUser(); diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index 5b419f1..eef795d 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -7,9 +7,9 @@ export const meta: MetaFunction = () => { export const loader = async (args: LoaderFunctionArgs) => { const api = args.context.api; - const user = (await api.getVerifiedAuthState(true)).user; + await api.verifyAuth(); const { data } = await api.data.readMany("todos"); - return { data, user }; + return { data, user: api.getUser() }; }; export default function Index() { diff --git a/tmp/event_manager_returning_test.patch b/tmp/event_manager_returning_test.patch deleted file mode 100644 index 1e194ef..0000000 --- a/tmp/event_manager_returning_test.patch +++ /dev/null @@ -1,150 +0,0 @@ -Subject: [PATCH] event manager returning test ---- -Index: app/__test__/core/EventManager.spec.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts ---- a/app/__test__/core/EventManager.spec.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/__test__/core/EventManager.spec.ts (date 1731498680965) -@@ -1,8 +1,8 @@ - import { describe, expect, test } from "bun:test"; --import { Event, EventManager, NoParamEvent } from "../../src/core/events"; -+import { Event, EventManager, type ListenerHandler, NoParamEvent } from "../../src/core/events"; - - class SpecialEvent extends Event<{ foo: string }> { -- static slug = "special-event"; -+ static override slug = "special-event"; - - isBar() { - return this.params.foo === "bar"; -@@ -10,7 +10,19 @@ - } - - class InformationalEvent extends NoParamEvent { -- static slug = "informational-event"; -+ static override slug = "informational-event"; -+} -+ -+class ReturnEvent extends Event<{ foo: string }, number> { -+ static override slug = "return-event"; -+ static override returning = true; -+ -+ override setValidatedReturn(value: number) { -+ if (typeof value !== "number") { -+ throw new Error("Invalid return value"); -+ } -+ this.params.foo = value.toString(); -+ } - } - - describe("EventManager", async () => { -@@ -43,4 +55,22 @@ - - expect(true).toBe(true); - }); -+ -+ test.only("piping", async () => { -+ const emgr = new EventManager(); -+ emgr.registerEvents([ReturnEvent, InformationalEvent]); -+ -+ type T = ListenerHandler; -+ -+ // @ts-expect-error InformationalEvent has no return value -+ emgr.onEvent(InformationalEvent, async (event, name) => { -+ console.log("Event: ", name, event.params); -+ return 1; -+ }); -+ -+ emgr.onEvent(ReturnEvent, async (event, name) => { -+ console.log("Event: ", name, event.params); -+ return 1; -+ }); -+ }); - }); -Index: app/src/core/events/EventManager.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts ---- a/app/src/core/events/EventManager.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/EventManager.ts (date 1731498680971) -@@ -6,7 +6,7 @@ - } - - export type EventClass = { -- new (params: any): Event; -+ new (params: any): Event; - slug: string; - }; - -@@ -137,6 +137,9 @@ - throw new Error(`Event "${slug}" not registered`); - } - -+ // @ts-expect-error returning is static -+ const returning = Boolean(event.constructor.returning); -+ - const listeners = this.listeners.filter((listener) => listener.event.slug === slug); - //console.log("---!-- emitting", slug, listeners.length); - -Index: app/src/core/events/EventListener.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts ---- a/app/src/core/events/EventListener.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/EventListener.ts (date 1731498680968) -@@ -4,10 +4,10 @@ - export const ListenerModes = ["sync", "async"] as const; - export type ListenerMode = (typeof ListenerModes)[number]; - --export type ListenerHandler = ( -+export type ListenerHandler> = ( - event: E, -- slug: string, --) => Promise | void; -+ slug: string -+) => E extends Event ? R | Promise : never; - - export class EventListener { - mode: ListenerMode = "async"; -Index: app/src/core/events/Event.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts ---- a/app/src/core/events/Event.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/Event.ts (date 1731498680973) -@@ -1,17 +1,25 @@ --export abstract class Event { -+export abstract class Event { - /** - * Unique event slug - * Must be static, because registering events is done by class - */ - static slug: string = "untitled-event"; - params: Params; -+ _returning!: Returning; -+ static returning: boolean = false; -+ -+ setValidatedReturn(value: Returning): void { -+ if (typeof value !== "undefined") { -+ throw new Error("Invalid event return value"); -+ } -+ } - - constructor(params: Params) { - this.params = params; - } - } - --// @todo: current workaround: potentially there is none and that's the way -+// @todo: current workaround: potentially there is "none" and that's the way - export class NoParamEvent extends Event { - static override slug: string = "noparam-event"; - diff --git a/tmp/lazy_codemirror.patch b/tmp/lazy_codemirror.patch deleted file mode 100644 index 425cac5..0000000 --- a/tmp/lazy_codemirror.patch +++ /dev/null @@ -1,125 +0,0 @@ -Subject: [PATCH] lazy codemirror ---- -Index: app/src/ui/components/code/LiquidJsEditor.tsx -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx ---- a/app/src/ui/components/code/LiquidJsEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c) -+++ b/app/src/ui/components/code/LiquidJsEditor.tsx (date 1736687726081) -@@ -1,7 +1,7 @@ --import { liquid } from "@codemirror/lang-liquid"; --import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; - import { Suspense, lazy } from "react"; - import { twMerge } from "tailwind-merge"; -+ -+import type { CodeEditorProps } from "./CodeEditor"; - const CodeEditor = lazy(() => import("./CodeEditor")); - - const filters = [ -@@ -106,7 +106,7 @@ - { label: "when" } - ]; - --export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) { -+export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) { - return ( - - - -Index: app/src/ui/components/code/CodeEditor.tsx -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx ---- a/app/src/ui/components/code/CodeEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c) -+++ b/app/src/ui/components/code/CodeEditor.tsx (date 1736687634668) -@@ -1,8 +1,22 @@ - import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; -- - import { useBknd } from "ui/client/bknd"; - --export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { -+import { json } from "@codemirror/lang-json"; -+import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; -+ -+export type CodeEditorProps = ReactCodeMirrorProps & { -+ _extensions?: Partial<{ -+ json: boolean; -+ liquid: LiquidCompletionConfig; -+ }>; -+}; -+ -+export default function CodeEditor({ -+ editable, -+ basicSetup, -+ _extensions = {}, -+ ...props -+}: CodeEditorProps) { - const b = useBknd(); - const theme = b.app.getAdminConfig().color_scheme; - const _basicSetup: Partial = !editable -@@ -13,11 +27,21 @@ - } - : basicSetup; - -+ const extensions = Object.entries(_extensions ?? {}).map(([ext, config]: any) => { -+ switch (ext) { -+ case "json": -+ return json(); -+ case "liquid": -+ return liquid(config); -+ } -+ }); -+ - return ( - - ); -Index: app/src/ui/components/code/JsonEditor.tsx -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx ---- a/app/src/ui/components/code/JsonEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c) -+++ b/app/src/ui/components/code/JsonEditor.tsx (date 1736687681965) -@@ -1,10 +1,9 @@ --import { json } from "@codemirror/lang-json"; --import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; - import { Suspense, lazy } from "react"; - import { twMerge } from "tailwind-merge"; -+import type { CodeEditorProps } from "./CodeEditor"; - const CodeEditor = lazy(() => import("./CodeEditor")); - --export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) { -+export function JsonEditor({ editable, className, ...props }: CodeEditorProps) { - return ( - - -