diff --git a/app/.gitignore b/app/.gitignore index 8415a0f..c7044f8 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,4 +1,5 @@ playwright-report test-results bknd.config.* -__test__/helper.d.ts \ No newline at end of file +__test__/helper.d.ts +.env.local \ No newline at end of file diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index ee1dcb3..b17962c 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -110,10 +110,11 @@ describe("MediaApi", () => { const api = new MediaApi({}, mockedBackend.request); const name = "image.png"; - const res = await api.getFileStream(name); - expect(isReadableStream(res)).toBe(true); + const res = await api.getFile(name); + const stream = await api.getFileStream(name); + expect(isReadableStream(stream)).toBe(true); - const blob = (await new Response(res).blob()) as File; + const blob = (await res.res.blob()) as File; expect(isFile(blob)).toBe(true); expect(blob.size).toBeGreaterThan(0); expect(blob.type).toBe("image/png"); diff --git a/app/__test__/ui/client/utils/AppReduced.spec.ts b/app/__test__/ui/client/utils/AppReduced.spec.ts new file mode 100644 index 0000000..58a7b3f --- /dev/null +++ b/app/__test__/ui/client/utils/AppReduced.spec.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AppReduced, type AppType } from "ui/client/utils/AppReduced"; +import type { BkndAdminProps } from "ui/Admin"; + +// Import the normalizeAdminPath function for testing +// Note: This assumes the function is exported or we need to test it indirectly through public methods + +describe("AppReduced", () => { + let mockAppJson: AppType; + let appReduced: AppReduced; + + beforeEach(() => { + mockAppJson = { + data: { + entities: {}, + relations: {}, + }, + flows: { + flows: {}, + }, + auth: {}, + } as AppType; + }); + + describe("getSettingsPath", () => { + it("should return settings path with basepath", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(); + + expect(result).toBe("~/admin/settings"); + }); + + it("should return settings path with empty basepath", () => { + const options: BkndAdminProps["config"] = { + basepath: "", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(); + + expect(result).toBe("~/settings"); + }); + + it("should append additional path segments", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(["user", "profile"]); + + expect(result).toBe("~/admin/settings/user/profile"); + }); + + it("should normalize multiple slashes", () => { + const options: BkndAdminProps["config"] = { + basepath: "//admin//", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(["//user//"]); + + expect(result).toBe("~/admin/settings/user"); + }); + + it("should handle basepath without leading slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(); + + expect(result).toBe("~/admin/settings"); + }); + }); + + describe("getAbsolutePath", () => { + it("should return absolute path with basepath", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath("dashboard"); + + expect(result).toBe("~/admin/dashboard"); + }); + + it("should return base path when no path provided", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath(); + + expect(result).toBe("~/admin"); + }); + + it("should normalize paths correctly", () => { + const options: BkndAdminProps["config"] = { + basepath: "//admin//", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath("//dashboard//"); + + expect(result).toBe("~/admin/dashboard"); + }); + }); + + describe("options getter", () => { + it("should return merged options with defaults", () => { + const customOptions: BkndAdminProps["config"] = { + basepath: "/custom-admin", + logo_return_path: "/custom-home", + }; + + appReduced = new AppReduced(mockAppJson, customOptions); + const options = appReduced.options; + + expect(options).toEqual({ + logo_return_path: "/custom-home", + basepath: "/custom-admin", + }); + }); + + it("should use default logo_return_path when not provided", () => { + const customOptions: BkndAdminProps["config"] = { + basepath: "/admin", + }; + + appReduced = new AppReduced(mockAppJson, customOptions); + const options = appReduced.options; + + expect(options.logo_return_path).toBe("/"); + expect(options.basepath).toBe("/admin"); + }); + }); + + describe("path normalization behavior", () => { + it("should normalize duplicate slashes in settings path", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(["//nested//path//"]); + + expect(result).toBe("~/admin/settings/nested/path"); + }); + + it("should handle root path normalization", () => { + const options: BkndAdminProps["config"] = { + basepath: "/", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath(); + + // The normalizeAdminPath function removes trailing slashes except for root "/" + // When basepath is "/", the result is "~/" which becomes "~" after normalization + expect(result).toBe("~"); + }); + + it("should preserve entity paths ending with slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath("entity/"); + + expect(result).toBe("~/admin/entity"); + }); + + it("should remove trailing slashes from non-entity paths", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath("dashboard/"); + + expect(result).toBe("~/admin/dashboard"); + }); + }); + + describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => { + it("should not produce double slashes when admin_basepath has trailing slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "/", + admin_basepath: "/admin/", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.withBasePath("/data"); + + expect(result).toBe("/admin/data"); + expect(result).not.toContain("//"); + }); + + it("should work correctly when admin_basepath has no trailing slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "/", + admin_basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.withBasePath("/data"); + + expect(result).toBe("/admin/data"); + }); + + it("should handle absolute paths with admin_basepath trailing slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "/", + admin_basepath: "/admin/", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getAbsolutePath("data"); + + expect(result).toBe("~/admin/data"); + expect(result).not.toContain("//"); + }); + + it("should handle settings path with admin_basepath trailing slash", () => { + const options: BkndAdminProps["config"] = { + basepath: "/", + admin_basepath: "/admin/", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(["general"]); + + expect(result).toBe("~/admin/settings/general"); + expect(result).not.toContain("//"); + }); + }); + + describe("edge cases", () => { + it("should handle undefined basepath", () => { + const options: BkndAdminProps["config"] = { + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(); + + // When basepath is undefined, it defaults to empty string + expect(result).toBe("~/settings"); + }); + + it("should handle null path segments", () => { + const options: BkndAdminProps["config"] = { + basepath: "/admin", + logo_return_path: "/", + }; + + appReduced = new AppReduced(mockAppJson, options); + const result = appReduced.getSettingsPath(["", "valid", ""]); + + expect(result).toBe("~/admin/settings/valid"); + }); + }); +}); diff --git a/app/src/cli/commands/sync.ts b/app/src/cli/commands/sync.ts index 8b8c5c4..2a22726 100644 --- a/app/src/cli/commands/sync.ts +++ b/app/src/cli/commands/sync.ts @@ -21,46 +21,47 @@ export const sync: CliCommand = (program) => { console.info(""); if (stmts.length === 0) { console.info(c.yellow("No changes to sync")); - process.exit(0); - } - // @todo: currently assuming parameters aren't used - const sql = stmts.map((d) => d.sql).join(";\n") + ";"; + } else { + // @todo: currently assuming parameters aren't used + const sql = stmts.map((d) => d.sql).join(";\n") + ";"; - if (options.force) { - console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); - await schema.sync({ force: true, drop: options.drop }); + if (options.force) { + console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); + await schema.sync({ force: true, drop: options.drop }); - console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); - console.info(`${c.green("Database synced")}`); - - if (options.seed) { - console.info(c.dim("\nExecuting seed...")); - const seed = app.options?.seed; - if (seed) { - await app.options?.seed?.({ - ...app.modules.ctx(), - app: app, - }); - console.info(c.green("Seed executed")); + console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); + console.info(`${c.green("Database synced")}`); + } else { + if (options.out) { + const output = options.sql ? sql : JSON.stringify(stmts, null, 2); + await writeFile(options.out, output); + console.info(`SQL written to ${c.cyan(options.out)}`); } else { - console.info(c.yellow("No seed function provided")); + console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql)); + + console.info( + c.yellow( + "\nNo statements have been executed. Use --force to perform database syncing operations", + ), + ); } } - } else { - if (options.out) { - const output = options.sql ? sql : JSON.stringify(stmts, null, 2); - await writeFile(options.out, output); - console.info(`SQL written to ${c.cyan(options.out)}`); - } else { - console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql)); + } - console.info( - c.yellow( - "\nNo statements have been executed. Use --force to perform database syncing operations", - ), - ); + if (options.seed) { + console.info(c.dim("\nExecuting seed...")); + const seed = app.options?.seed; + if (seed) { + await seed({ + ...app.modules.ctx(), + app: app, + }); + console.info(c.green("Seed executed")); + } else { + console.info(c.yellow("No seed function provided")); } } + process.exit(0); }); }; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index e098101..d7592c5 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -11,6 +11,7 @@ import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; import type { TApiUser } from "Api"; +import type { AppTheme } from "ui/client/use-theme"; import type { Manifest } from "vite"; const htmlBkndContextReplace = ""; @@ -20,7 +21,7 @@ export type AdminBkndWindowContext = { logout_route: string; admin_basepath: string; logo_return_path?: string; - theme?: "dark" | "light" | "system"; + theme?: AppTheme; }; // @todo: add migration to remove admin path from config @@ -31,7 +32,7 @@ export type AdminControllerOptions = { html?: string; forceDev?: boolean | { mainPath: string }; debugRerenders?: boolean; - theme?: "dark" | "light" | "system"; + theme?: AppTheme; logoReturnPath?: string; manifest?: Manifest; }; @@ -122,7 +123,7 @@ export class AdminController extends Controller { const obj: AdminBkndWindowContext = { user: c.get("auth")?.user, logout_route: authRoutes.logout, - admin_basepath: this.options.adminBasepath, + admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""), theme: this.options.theme, logo_return_path: this.options.logoReturnPath, }; @@ -304,7 +305,7 @@ const wrapperStyle = css` -moz-osx-font-smoothing: grayscale; color: rgb(9,9,11); background-color: rgb(250,250,250); - + @media (prefers-color-scheme: dark) { color: rgb(250,250,250); background-color: rgb(30,31,34); diff --git a/app/src/plugins/data/timestamp.plugin.spec.ts b/app/src/plugins/data/timestamp.plugin.spec.ts index bdf8811..87778d9 100644 --- a/app/src/plugins/data/timestamp.plugin.spec.ts +++ b/app/src/plugins/data/timestamp.plugin.spec.ts @@ -71,4 +71,54 @@ describe("timestamps plugin", () => { expect(updatedData.updated_at).toBeDefined(); expect(updatedData.updated_at).toBeInstanceOf(Date); }); + + test("index strategy", async () => { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts"] })], + }, + }); + await app.build(); + expect(app.em.indices.length).toBe(0); + { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })], + }, + }); + await app.build(); + expect(app.em.indices.length).toBe(1); + } + + { + const app = createApp({ + config: { + data: em({ + posts: entity("posts", { + title: text(), + }), + }).toJSON(), + }, + options: { + plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })], + }, + }); + await app.build(); + expect(app.em.indices.length).toBe(2); + } + }); }); diff --git a/app/src/plugins/data/timestamps.plugin.ts b/app/src/plugins/data/timestamps.plugin.ts index 0de5a94..aa22e5a 100644 --- a/app/src/plugins/data/timestamps.plugin.ts +++ b/app/src/plugins/data/timestamps.plugin.ts @@ -4,6 +4,7 @@ import { $console } from "bknd/utils"; export type TimestampsPluginOptions = { entities: string[]; setUpdatedOnCreate?: boolean; + indexStrategy?: "composite" | "individual"; }; /** @@ -19,6 +20,7 @@ export type TimestampsPluginOptions = { export function timestamps({ entities = [], setUpdatedOnCreate = true, + indexStrategy, }: TimestampsPluginOptions): AppPlugin { return (app: App) => ({ name: "timestamps", @@ -29,19 +31,35 @@ export function timestamps({ } const appEntities = app.em.entities.map((e) => e.name); + const actualEntities = entities.filter((e) => appEntities.includes(e)); return em( Object.fromEntries( - entities - .filter((e) => appEntities.includes(e)) - .map((e) => [ - e, - entity(e, { - created_at: datetime(), - updated_at: datetime(), - }), - ]), + actualEntities.map((e) => [ + e, + entity(e, { + created_at: datetime(), + updated_at: datetime(), + }), + ]), ), + (fns, schema) => { + if (indexStrategy) { + for (const entity of actualEntities) { + if (entity in schema) { + switch (indexStrategy) { + case "composite": + fns.index(schema[entity]!).on(["created_at", "updated_at"]); + break; + case "individual": + fns.index(schema[entity]!).on(["created_at"]); + fns.index(schema[entity]!).on(["updated_at"]); + break; + } + } + } + } + }, ); }, onBuilt: async () => { diff --git a/app/src/ui/client/utils/AppReduced.ts b/app/src/ui/client/utils/AppReduced.ts index 2ea5045..353faa4 100644 --- a/app/src/ui/client/utils/AppReduced.ts +++ b/app/src/ui/client/utils/AppReduced.ts @@ -22,7 +22,6 @@ export class AppReduced { protected appJson: AppType, protected _options: BkndAdminProps["config"] = {}, ) { - //console.log("received appjson", appJson); this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { return constructEntity(name, entity); @@ -70,20 +69,27 @@ export class AppReduced { get options() { return { - basepath: "", + basepath: "/", logo_return_path: "/", ...this._options, }; } + withBasePath(path: string | string[], absolute = false): string { + const paths = Array.isArray(path) ? path : [path]; + return [absolute ? "~" : null, this.options.basepath, this.options.admin_basepath, ...paths] + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/") + .replace(/\/$/, ""); + } + getSettingsPath(path: string[] = []): string { - const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/"); - return [base, ...path].join("/"); + return this.withBasePath(["settings", ...path], true); } getAbsolutePath(path?: string): string { - const { basepath } = this.options; - return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/"); + return this.withBasePath(path ?? [], true); } getAuthConfig() { diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 6b3a3e4..de83a07 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -33,7 +33,7 @@ export function Routes({ }) { const { theme } = useTheme(); const ctx = useBkndWindowContext(); - const actualBasePath = basePath || ctx.admin_basepath; + const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, ""); return (
diff --git a/app/tsconfig.build.json b/app/tsconfig.build.json index 31ea592..4d813b1 100644 --- a/app/tsconfig.build.json +++ b/app/tsconfig.build.json @@ -1,11 +1,11 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/types", - "rootDir": "./src", - "baseUrl": ".", - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" - }, - "include": ["./src/**/*.ts", "./src/**/*.tsx"], - "exclude": ["./node_modules", "./__test__", "./e2e"] + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/types", + "rootDir": "./src", + "baseUrl": ".", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./node_modules", "./__test__", "./e2e"] } diff --git a/bun.lock b/bun.lock index 25eaa15..f5b57ad 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "bknd", @@ -3846,7 +3846,7 @@ "@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - "@bknd/plasmic/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@bknd/plasmic/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], @@ -4750,7 +4750,7 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], - "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],