Merge pull request 'Release/0.21' (#6) from release/0.21 into main
Run Tests / test (push) Successful in 3m53s
Run Tests / test (push) Successful in 3m53s
Reviewed-on: shishantbiswas/bknd#6
This commit was merged in pull request #6.
This commit is contained in:
@@ -70,4 +70,10 @@ describe("Api", async () => {
|
||||
expect(params.token_transport).toBe("header");
|
||||
expect(params.host).toBe("http://another.com");
|
||||
});
|
||||
|
||||
it("should extract tokens case insensitive", async () => {
|
||||
const token = await sign({ sub: "test" }, "1234");
|
||||
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||
})
|
||||
});
|
||||
|
||||
@@ -38,4 +38,27 @@ describe("Authenticator", async () => {
|
||||
expect(cookie).toStartWith("auth=");
|
||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
});
|
||||
|
||||
test("bearer token is case insensitive", async () => {
|
||||
const auth = new Authenticator({}, null as any, {
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
fields: ["sub"],
|
||||
},
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
const token = await auth.jwt({ sub: "test" });
|
||||
|
||||
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
}));
|
||||
expect((res as any).sub).toBe("test")
|
||||
|
||||
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
||||
Authorization: `bearer ${token}`,
|
||||
}));
|
||||
expect((res2 as any).sub).toBe("test")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -48,12 +48,12 @@ describe("postgres", () => {
|
||||
|
||||
if (!(await isPostgresRunning())) {
|
||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
||||
await $waitUntil("Postgres is running", isPostgresRunning);
|
||||
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
disableConsoleLog();
|
||||
});
|
||||
}, 30000);
|
||||
afterAll(async () => {
|
||||
if (await isPostgresRunning()) {
|
||||
try {
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("AppAuth", () => {
|
||||
});
|
||||
|
||||
await app.build();
|
||||
app.registerAdminController();
|
||||
app.registerAdminController({ forceDev: true });
|
||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||
|
||||
// register custom route
|
||||
|
||||
@@ -7,7 +7,9 @@ import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import v9 from "./samples/v9.json";
|
||||
import v10 from "./samples/v10.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { CURRENT_VERSION } from "modules/db/migrations";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -61,7 +63,7 @@ async function getRawConfig(
|
||||
return await db
|
||||
.selectFrom("__bknd")
|
||||
.selectAll()
|
||||
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
||||
.where("version", "=", opts?.version ?? CURRENT_VERSION)
|
||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||
.execute();
|
||||
}
|
||||
@@ -115,7 +117,6 @@ describe("Migrations", () => {
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
const [config, secrets] = (await getRawConfig(app, {
|
||||
version: 10,
|
||||
types: ["config", "secrets"],
|
||||
})) as any;
|
||||
|
||||
@@ -129,4 +130,15 @@ describe("Migrations", () => {
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
});
|
||||
|
||||
test("migration from 10 to 11", async () => {
|
||||
expect(v10.version).toBe(10);
|
||||
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
|
||||
|
||||
const app = await createVersionedApp(v10);
|
||||
|
||||
expect(app.version()).toBeGreaterThan(10);
|
||||
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
|
||||
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"version": 10,
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"path": "/api/system/mcp",
|
||||
"logLevel": "warning"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {
|
||||
"test": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["read", "update"]
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"default_value": "INACTIVE",
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
|
||||
},
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": {
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"type": "jsonschema",
|
||||
"config": {
|
||||
"default_from_schema": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"one": {
|
||||
"type": "number",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"type": "boolean",
|
||||
"config": {
|
||||
"default_value": false,
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"etag": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"modified_at": {
|
||||
"type": "date",
|
||||
"config": {
|
||||
"type": "datetime",
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"reference": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "json",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {},
|
||||
"indices": {
|
||||
"idx_unique_media_path": {
|
||||
"entity": "media",
|
||||
"fields": ["path"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_media_reference": {
|
||||
"entity": "media",
|
||||
"fields": ["reference"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_media_entity_id": {
|
||||
"entity": "media",
|
||||
"fields": ["entity_id"],
|
||||
"unique": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guard": {
|
||||
"enabled": false
|
||||
},
|
||||
"roles": {}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,7 @@ describe("AppReduced", () => {
|
||||
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/",
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/",
|
||||
};
|
||||
|
||||
@@ -220,8 +219,7 @@ describe("AppReduced", () => {
|
||||
|
||||
it("should work correctly when admin_basepath has no trailing slash", () => {
|
||||
const options: BkndAdminProps["config"] = {
|
||||
basepath: "/",
|
||||
admin_basepath: "/admin",
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/",
|
||||
};
|
||||
|
||||
@@ -233,8 +231,7 @@ describe("AppReduced", () => {
|
||||
|
||||
it("should handle absolute paths with admin_basepath trailing slash", () => {
|
||||
const options: BkndAdminProps["config"] = {
|
||||
basepath: "/",
|
||||
admin_basepath: "/admin/",
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/",
|
||||
};
|
||||
|
||||
@@ -247,8 +244,7 @@ describe("AppReduced", () => {
|
||||
|
||||
it("should handle settings path with admin_basepath trailing slash", () => {
|
||||
const options: BkndAdminProps["config"] = {
|
||||
basepath: "/",
|
||||
admin_basepath: "/admin/",
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/",
|
||||
};
|
||||
|
||||
|
||||
@@ -335,11 +335,21 @@ async function buildAdapters() {
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("nuxt"),
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("solid-start"),
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("sqlite/edge"),
|
||||
entry: ["src/adapter/sqlite/edge.ts"],
|
||||
|
||||
+3
-2
@@ -3,10 +3,11 @@ import path from "node:path";
|
||||
import c from "picocolors";
|
||||
|
||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
||||
|
||||
async function run(
|
||||
cmd: string[] | string,
|
||||
opts: Bun.SpawnOptions.OptionsObject & {},
|
||||
opts: RunOptions,
|
||||
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -17,7 +18,7 @@ async function run(
|
||||
});
|
||||
|
||||
// Read from stdout
|
||||
const reader = (proc.stdout as ReadableStream).getReader();
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Function to read chunks
|
||||
|
||||
@@ -15,6 +15,12 @@ const configs = {
|
||||
nextjs: {
|
||||
base_path: "/admin",
|
||||
},
|
||||
nuxt: {
|
||||
base_path: "/admin",
|
||||
},
|
||||
"solid-start": {
|
||||
base_path: "/admin",
|
||||
},
|
||||
astro: {
|
||||
base_path: "/admin",
|
||||
},
|
||||
|
||||
+17
-3
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0-rc.1",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -60,7 +60,7 @@
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"fast-xml-parser": "^5.3.1",
|
||||
"hono": "4.10.4",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
@@ -233,6 +233,11 @@
|
||||
"import": "./dist/adapter/nextjs/index.js",
|
||||
"require": "./dist/adapter/nextjs/index.js"
|
||||
},
|
||||
"./adapter/nuxt": {
|
||||
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
||||
"import": "./dist/adapter/nuxt/index.js",
|
||||
"require": "./dist/adapter/nuxt/index.js"
|
||||
},
|
||||
"./adapter/react-router": {
|
||||
"types": "./dist/types/adapter/react-router/index.d.ts",
|
||||
"import": "./dist/adapter/react-router/index.js",
|
||||
@@ -258,6 +263,11 @@
|
||||
"import": "./dist/adapter/sveltekit/index.js",
|
||||
"require": "./dist/adapter/sveltekit/index.js"
|
||||
},
|
||||
"./adapter/solid-start": {
|
||||
"types": "./dist/types/adapter/solid-start/index.d.ts",
|
||||
"import": "./dist/adapter/solid-start/index.js",
|
||||
"require": "./dist/adapter/solid-start/index.js"
|
||||
},
|
||||
"./adapter/aws": {
|
||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||
"import": "./dist/adapter/aws/index.js",
|
||||
@@ -287,11 +297,13 @@
|
||||
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
||||
"adapter/solid-start": ["./dist/types/adapter/solid-start/index.d.ts"],
|
||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||
}
|
||||
},
|
||||
@@ -318,12 +330,14 @@
|
||||
"serverless",
|
||||
"cloudflare",
|
||||
"nextjs",
|
||||
"nuxt",
|
||||
"remix",
|
||||
"react-router",
|
||||
"astro",
|
||||
"sveltekit",
|
||||
"svelte",
|
||||
"bun",
|
||||
"node"
|
||||
"node",
|
||||
"solid-start"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ export class Api {
|
||||
}
|
||||
|
||||
// try authorization header
|
||||
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
||||
const headerToken = this.options.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||
if (headerToken) {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(headerToken);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./nuxt.adapter";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { afterAll, beforeAll, describe } from "bun:test";
|
||||
import * as nuxt from "./nuxt.adapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("nuxt adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: nuxt.getApp,
|
||||
makeHandler: nuxt.serve,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||
|
||||
export type NuxtEnv = NodeJS.ProcessEnv;
|
||||
export type NuxtBkndConfig<Env = NuxtEnv> = RuntimeBkndConfig<Env>;
|
||||
|
||||
/**
|
||||
* Get bknd app instance
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables
|
||||
*/
|
||||
export async function getApp<Env>(
|
||||
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||
args: Env,
|
||||
) {
|
||||
return await createRuntimeApp(config, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create middleware handler for Nuxt
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables
|
||||
*/
|
||||
export function serve<Env>(
|
||||
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||
args: Env,
|
||||
) {
|
||||
return async (request: Request) => {
|
||||
return (await getApp(config, args)).fetch(request);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./solid-start.adapter";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterAll, beforeAll, describe } from "bun:test";
|
||||
import * as solidStart from "./solid-start.adapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import type { SolidStartBkndConfig } from "./solid-start.adapter";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("solid-start adapter", () => {
|
||||
adapterTestSuite<SolidStartBkndConfig>(bunTestRunner, {
|
||||
makeApp: solidStart.getApp,
|
||||
makeHandler: solidStart.serve,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||
|
||||
export type SolidStartEnv = NodeJS.ProcessEnv;
|
||||
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
|
||||
|
||||
/**
|
||||
* Get bknd app instance
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables
|
||||
*/
|
||||
export async function getApp<Env = SolidStartEnv>(
|
||||
config: SolidStartBkndConfig<Env>,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createRuntimeApp(config, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create middleware handler for Solid Start
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables
|
||||
*/
|
||||
export function serve<Env = SolidStartEnv>(
|
||||
config: SolidStartBkndConfig<Env> = {},
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return async (req: Request) => {
|
||||
const app = await getApp(config, args);
|
||||
return app.fetch(req);
|
||||
};
|
||||
}
|
||||
@@ -430,7 +430,7 @@ export class Authenticator<
|
||||
let token: string | undefined;
|
||||
if (headers.has("Authorization")) {
|
||||
const bearerHeader = String(headers.get("Authorization"));
|
||||
token = bearerHeader.replace("Bearer ", "");
|
||||
token = bearerHeader.replace(/^Bearer\s+/i, "");
|
||||
} else {
|
||||
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
||||
token = await this.getAuthCookie(context);
|
||||
|
||||
@@ -136,8 +136,10 @@ export class Entity<
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
|
||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
||||
field.isFillable(context),
|
||||
);
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
@@ -189,9 +191,15 @@ export class Entity<
|
||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
getFields({
|
||||
virtual = false,
|
||||
primary = true,
|
||||
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
|
||||
return this.fields.filter((f) => {
|
||||
if (!virtual && f.isVirtual()) return false;
|
||||
if (!primary && f instanceof PrimaryField) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
addField(field: Field) {
|
||||
@@ -231,7 +239,7 @@ export class Entity<
|
||||
}
|
||||
}
|
||||
|
||||
const fields = this.getFillableFields(context, false);
|
||||
const fields = this.getFillableFields(context as any, false);
|
||||
|
||||
if (options?.ignoreUnknown !== true) {
|
||||
const field_names = fields.map((f) => f.name);
|
||||
@@ -275,7 +283,7 @@ export class Entity<
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
fields = this.getFields({ virtual: true });
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
|
||||
@@ -83,8 +83,10 @@ export class Mutator<
|
||||
}
|
||||
|
||||
// we should never get here, but just to be sure (why?)
|
||||
if (!field.isFillable(context)) {
|
||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||
if (!field.isFillable(context as any)) {
|
||||
throw new Error(
|
||||
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// transform from field
|
||||
|
||||
@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
|
||||
.strictObject({
|
||||
label: s.string(),
|
||||
description: s.string(),
|
||||
required: s.boolean({ default: false }),
|
||||
fillable: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||
]),
|
||||
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
||||
fillable: s.anyOf(
|
||||
[
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ["create", "update"] }), {
|
||||
title: "Context",
|
||||
uniqueItems: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
default: DEFAULT_FILLABLE,
|
||||
},
|
||||
),
|
||||
hidden: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
// @todo: tmp workaround
|
||||
@@ -103,7 +111,7 @@ export abstract class Field<
|
||||
return this.config?.default_value;
|
||||
}
|
||||
|
||||
isFillable(context?: TActionContext): boolean {
|
||||
isFillable(context?: "create" | "update"): boolean {
|
||||
if (Array.isArray(this.config.fillable)) {
|
||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||
}
|
||||
@@ -165,7 +173,7 @@ export abstract class Field<
|
||||
// @todo: add field level validation
|
||||
isValid(value: any, context: TActionContext): boolean {
|
||||
if (typeof value !== "undefined") {
|
||||
return this.isFillable(context);
|
||||
return this.isFillable(context as any);
|
||||
} else if (context === "create") {
|
||||
return !this.isRequired();
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export function fieldTestSuite(
|
||||
const _config = {
|
||||
..._requiredConfig,
|
||||
required: false,
|
||||
fillable: true,
|
||||
};
|
||||
|
||||
function fieldJson(field: Field) {
|
||||
@@ -116,10 +117,7 @@ export function fieldTestSuite(
|
||||
|
||||
expect(fieldJson(fillable)).toEqual({
|
||||
type: noConfigField.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
},
|
||||
config: _config,
|
||||
});
|
||||
|
||||
expect(fieldJson(required)).toEqual({
|
||||
@@ -150,7 +148,6 @@ export function fieldTestSuite(
|
||||
type: requiredAndDefault.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
required: true,
|
||||
default_value: config.defaultValue,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,8 @@ export function getChangeSet(
|
||||
(acc, _value, key) => {
|
||||
const field = fields.find((f) => f.name === key);
|
||||
// @todo: filtering virtual here, need to check (because of media)
|
||||
if (!field || field.isVirtual()) return;
|
||||
// @note: adding non-fillable here as it'll fail validaiton on backend
|
||||
if (!field || field.isVirtual() || !field.isFillable()) return;
|
||||
const value = _value === "" ? null : _value;
|
||||
|
||||
// normalize to null if undefined
|
||||
|
||||
@@ -77,7 +77,7 @@ export class SchemaManager {
|
||||
}
|
||||
|
||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||
const fields = entity.getFields(false);
|
||||
const fields = entity.getFields({ virtual: false });
|
||||
const indices = this.em.getIndicesOf(entity);
|
||||
|
||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { transformObject } from "bknd/utils";
|
||||
import type { Kysely } from "kysely";
|
||||
import { set } from "lodash-es";
|
||||
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
||||
|
||||
export type MigrationContext = {
|
||||
db: Kysely<any>;
|
||||
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
|
||||
return config;
|
||||
},
|
||||
},
|
||||
{
|
||||
// change field.config.fillable to only "create" and "update"
|
||||
version: 11,
|
||||
up: async (config: InitialModuleConfigs) => {
|
||||
const { data, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
...data,
|
||||
entities: transformObject(data?.entities ?? {}, (entity) => {
|
||||
return {
|
||||
...entity,
|
||||
fields: transformObject(entity?.fields ?? {}, (field) => {
|
||||
const fillable = field!.config?.fillable;
|
||||
if (!fillable || typeof fillable === "boolean") return field;
|
||||
return { ...field, config: { ...field!.config, fillable: true } };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||
|
||||
@@ -210,8 +210,9 @@ export class AdminController extends Controller {
|
||||
},
|
||||
}).then((res) => res.json());
|
||||
} else {
|
||||
const manifestPath = "bknd/dist/manifest.json";
|
||||
// @ts-ignore
|
||||
manifest = await import("bknd/dist/manifest.json", {
|
||||
manifest = await import(/* @vite-ignore */ manifestPath, {
|
||||
with: { type: "json" },
|
||||
}).then((res) => res.default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,858 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { sort } from "./sort.plugin";
|
||||
import { em, entity, text, number } from "bknd";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("sort plugin", () => {
|
||||
test("should add sort field to configured entities", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const taskEntity = app.em.entity("tasks");
|
||||
expect(taskEntity).toBeDefined();
|
||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||
expect(taskEntity?.field("position")?.type).toBe("number");
|
||||
});
|
||||
|
||||
test("should auto-assign sort values on insert (starting from 0)", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// insert first item
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
expect(task1.position).toBe(0);
|
||||
|
||||
// insert second item
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
expect(task2.position).toBe(1);
|
||||
|
||||
// insert third item
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
expect(task3.position).toBe(2);
|
||||
});
|
||||
|
||||
test("should preserve manually set sort values on insert", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// insert with explicit position
|
||||
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
|
||||
expect(task.position).toBe(10);
|
||||
});
|
||||
|
||||
test("should reorder items via API endpoint", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
|
||||
// move task3 (position 2) to position 0
|
||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: task3.id, position: 0 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
|
||||
expect(updatedTask3.position).toBe(0); // moved to position 0
|
||||
expect(updatedTask1.position).toBe(1); // shifted down
|
||||
expect(updatedTask2.position).toBe(2); // shifted down
|
||||
});
|
||||
|
||||
test("should automatically reorder when updating sort field directly", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task4 (position 3) to position 1 by updating directly
|
||||
await mutator.updateOne(task4.id, { position: 1 });
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(0); // unchanged
|
||||
expect(updatedTask2.position).toBe(2); // shifted down
|
||||
expect(updatedTask3.position).toBe(3); // shifted down
|
||||
expect(updatedTask4.position).toBe(1); // moved to position 1
|
||||
});
|
||||
|
||||
test("should automatically reorder when moving items up", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task1 (position 0) to position 2 by updating directly
|
||||
await mutator.updateOne(task1.id, { position: 2 });
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(2); // moved to position 2
|
||||
expect(updatedTask2.position).toBe(0); // shifted up
|
||||
expect(updatedTask3.position).toBe(1); // shifted up
|
||||
expect(updatedTask4.position).toBe(3); // unchanged
|
||||
});
|
||||
|
||||
test("should support scoped sorting", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1
|
||||
const { data: p1t1 } = await mutator.insertOne({
|
||||
title: "P1 Task 1",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t2 } = await mutator.insertOne({
|
||||
title: "P1 Task 2",
|
||||
project_id: 1,
|
||||
});
|
||||
|
||||
// create tasks in project 2
|
||||
const { data: p2t1 } = await mutator.insertOne({
|
||||
title: "P2 Task 1",
|
||||
project_id: 2,
|
||||
});
|
||||
const { data: p2t2 } = await mutator.insertOne({
|
||||
title: "P2 Task 2",
|
||||
project_id: 2,
|
||||
});
|
||||
|
||||
// positions should be scoped per project
|
||||
expect(p1t1.position).toBe(0);
|
||||
expect(p1t2.position).toBe(1);
|
||||
expect(p2t1.position).toBe(0); // resets for new scope
|
||||
expect(p2t2.position).toBe(1);
|
||||
});
|
||||
|
||||
test("should reorder only within scope", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1
|
||||
const { data: p1t1 } = await mutator.insertOne({
|
||||
title: "P1 Task 1",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t2 } = await mutator.insertOne({
|
||||
title: "P1 Task 2",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t3 } = await mutator.insertOne({
|
||||
title: "P1 Task 3",
|
||||
project_id: 1,
|
||||
});
|
||||
|
||||
// create tasks in project 2
|
||||
const { data: p2t1 } = await mutator.insertOne({
|
||||
title: "P2 Task 1",
|
||||
project_id: 2,
|
||||
});
|
||||
const { data: p2t2 } = await mutator.insertOne({
|
||||
title: "P2 Task 2",
|
||||
project_id: 2,
|
||||
});
|
||||
|
||||
// move p1t3 to position 0 (should only affect project 1)
|
||||
await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: p1t3.id, position: 0 }),
|
||||
});
|
||||
|
||||
// verify project 1 tasks
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
|
||||
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
|
||||
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
|
||||
|
||||
expect(updatedP1t3.position).toBe(0); // moved to position 0
|
||||
expect(updatedP1t1.position).toBe(1); // shifted
|
||||
expect(updatedP1t2.position).toBe(2); // shifted
|
||||
|
||||
// verify project 2 tasks are unchanged
|
||||
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
|
||||
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
|
||||
|
||||
expect(updatedP2t1.position).toBe(0); // unchanged
|
||||
expect(updatedP2t2.position).toBe(1); // unchanged
|
||||
});
|
||||
|
||||
test("should recalculate all positions", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks with irregular positions
|
||||
await mutator.insertOne({ title: "Task 1", position: 5 });
|
||||
await mutator.insertOne({ title: "Task 2", position: 10 });
|
||||
await mutator.insertOne({ title: "Task 3", position: 15 });
|
||||
await mutator.insertOne({ title: "Task 4", position: 100 });
|
||||
|
||||
// recalculate
|
||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify positions are now 0, 1, 2, 3
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(4);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
expect(tasks[2].position).toBe(2);
|
||||
expect(tasks[3].position).toBe(3);
|
||||
});
|
||||
|
||||
test("should recalculate positions within scope only", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1 with irregular positions
|
||||
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
|
||||
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
|
||||
|
||||
// create tasks in project 2 with irregular positions
|
||||
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
|
||||
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
|
||||
|
||||
// recalculate only project 1
|
||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ scope: 1 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify project 1 tasks are recalculated
|
||||
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
|
||||
where: { project_id: 1 },
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(p1Tasks.length).toBe(2);
|
||||
expect(p1Tasks[0].position).toBe(0);
|
||||
expect(p1Tasks[1].position).toBe(1);
|
||||
|
||||
// verify project 2 tasks are unchanged
|
||||
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
|
||||
where: { project_id: 2 },
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(p2Tasks.length).toBe(2);
|
||||
expect(p2Tasks[0].position).toBe(10); // unchanged
|
||||
expect(p2Tasks[1].position).toBe(20); // unchanged
|
||||
});
|
||||
|
||||
test("should handle moving items to the end", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task1 to the end (position 3)
|
||||
await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: task1.id, position: 3 }),
|
||||
});
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(3); // moved to end
|
||||
expect(updatedTask2.position).toBe(0); // shifted up
|
||||
expect(updatedTask3.position).toBe(1); // shifted up
|
||||
expect(updatedTask4.position).toBe(2); // shifted up
|
||||
});
|
||||
|
||||
test("should return 400 for invalid item id", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: 999999, position: 0 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("should handle multiple entities with different configurations", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
categories: entity("categories", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
categories: {
|
||||
field: "order",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
// verify both entities have their sort fields
|
||||
const taskEntity = app.em.entity("tasks");
|
||||
const categoryEntity = app.em.entity("categories");
|
||||
|
||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
|
||||
|
||||
// create items in both entities
|
||||
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
|
||||
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
|
||||
|
||||
expect(task.position).toBe(0);
|
||||
expect(category.order).toBe(0);
|
||||
|
||||
// verify both endpoints exist
|
||||
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const catRes = await app.server.request("/api/sort/categories/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(taskRes.status).toBe(200);
|
||||
expect(catRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not trigger reorder when updating other fields", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
|
||||
// update title only (should not trigger reordering)
|
||||
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
|
||||
|
||||
// verify positions are unchanged
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(0);
|
||||
expect(updatedTask2.position).toBe(1);
|
||||
expect(updatedTask2.title).toBe("Task 2 Updated");
|
||||
expect(updatedTask3.position).toBe(2);
|
||||
});
|
||||
|
||||
test("should handle null sort values", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create task with null position (bypass listener by using kysely directly)
|
||||
await app.connection.kysely
|
||||
.insertInto("tasks")
|
||||
.values({ title: "Task with null", position: null })
|
||||
.execute();
|
||||
|
||||
// create normal task
|
||||
await mutator.insertOne({ title: "Task 2" });
|
||||
|
||||
// update the null task to have a position
|
||||
const nullTask = await app.connection.kysely
|
||||
.selectFrom("tasks")
|
||||
.selectAll()
|
||||
.where("title", "=", "Task with null")
|
||||
.executeTakeFirst();
|
||||
|
||||
await mutator.updateOne(nullTask!.id, { position: 0 });
|
||||
|
||||
// verify both tasks have proper positions
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
});
|
||||
|
||||
test("should not create duplicates when moving to an occupied position", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create two tasks at positions 0 and 1
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
|
||||
expect(task1.position).toBe(0);
|
||||
expect(task2.position).toBe(1);
|
||||
|
||||
// move task2 (at position 1) to position 0
|
||||
await mutator.updateOne(task2.id, { position: 0 });
|
||||
|
||||
// verify no duplicates
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].id).toBe(task2.id);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].id).toBe(task1.id);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
|
||||
// verify no tasks have the same position
|
||||
const positions = tasks.map((t) => t.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
expect(uniquePositions.size).toBe(positions.length);
|
||||
});
|
||||
|
||||
test("should preserve order when recalculating", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks with specific positions
|
||||
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
|
||||
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
|
||||
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
|
||||
|
||||
// recalculate
|
||||
await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// verify order is preserved (B, A, C based on original positions)
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
|
||||
expect(tasks[1].position).toBe(1);
|
||||
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
|
||||
expect(tasks[2].position).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
|
||||
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
|
||||
import { Hono } from "hono";
|
||||
import { sql } from "kysely";
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
export type SortPluginOptions = {
|
||||
/**
|
||||
* The base path for the API endpoints.
|
||||
* @default "/api/sort"
|
||||
*/
|
||||
apiBasePath?: string;
|
||||
|
||||
/**
|
||||
* Configuration for entities that should have sorting enabled.
|
||||
* Key is the entity name, value is the configuration.
|
||||
*/
|
||||
entities: Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* The name of the sort property (must be a number field).
|
||||
*/
|
||||
field: string;
|
||||
|
||||
/**
|
||||
* Optional scope field name. If provided, sorting will only happen within the same scope.
|
||||
* For example, if scope is "category_id", items will only be sorted within items
|
||||
* that have the same category_id value.
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* The batch size for recalculating sort order.
|
||||
* @default 1000
|
||||
*/
|
||||
recalculateBatchSize?: number;
|
||||
};
|
||||
|
||||
class SortError extends Exception {
|
||||
override name = "SortError";
|
||||
override code = HttpStatus.BAD_REQUEST;
|
||||
}
|
||||
|
||||
export function sort({
|
||||
apiBasePath = "/api/sort",
|
||||
entities,
|
||||
recalculateBatchSize = DEFAULT_BATCH_SIZE,
|
||||
}: SortPluginOptions): AppPlugin {
|
||||
return (app: App) => {
|
||||
return {
|
||||
name: "sort",
|
||||
schema: () => {
|
||||
return em(
|
||||
Object.fromEntries(
|
||||
Object.entries(entities).map(([entityName, config]) => [
|
||||
entityName,
|
||||
entity(entityName, {
|
||||
[config.field]: number({ default_value: 0 }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
({ index }, schema) => {
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
const indexed = app.em.getIndexedFields(entityName);
|
||||
if (!indexed.some((f) => f.name === config.field)) {
|
||||
index(schema[entityName]!).on([config.field]);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onBuilt: async () => {
|
||||
invariant(
|
||||
entities && Object.keys(entities).length > 0,
|
||||
"At least one entity must be configured",
|
||||
);
|
||||
|
||||
// validate entities exist and have the configured fields
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
const entity = app.em.entity(entityName);
|
||||
invariant(entity, `Entity "${entityName}" not found in schema`);
|
||||
|
||||
const sortFieldSchema = entity.field(config.field)!;
|
||||
invariant(
|
||||
sortFieldSchema,
|
||||
`Sort field "${config.field}" not found in entity "${entityName}"`,
|
||||
);
|
||||
invariant(
|
||||
sortFieldSchema.type === "number",
|
||||
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
|
||||
);
|
||||
|
||||
if (config.scope) {
|
||||
const scopeFieldSchema = entity.field(config.scope);
|
||||
invariant(
|
||||
scopeFieldSchema,
|
||||
`Scope field "${config.scope}" not found in entity "${entityName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hono = new Hono();
|
||||
|
||||
// register recalculate endpoints for each entity
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
hono.post(
|
||||
`/${entityName}/recalculate`,
|
||||
jsc(
|
||||
"json",
|
||||
s
|
||||
.object({
|
||||
scope: s.any().optional(),
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const scope = body?.scope;
|
||||
|
||||
await recalculateSortOrder(
|
||||
app,
|
||||
entityName,
|
||||
config,
|
||||
recalculateBatchSize,
|
||||
scope,
|
||||
);
|
||||
|
||||
return c.json({ success: true, message: "Sort order recalculated" });
|
||||
},
|
||||
);
|
||||
|
||||
hono.post(
|
||||
`/${entityName}/reorder`,
|
||||
jsc(
|
||||
"json",
|
||||
s.object({
|
||||
id: s.any(),
|
||||
position: s.number(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id, position } = c.req.valid("json");
|
||||
|
||||
await reorderItem(app, entityName, config, id, position);
|
||||
|
||||
return c.json({ success: true, message: "Item reordered" });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.server.route(apiBasePath, hono);
|
||||
|
||||
// register listeners for automatic reordering
|
||||
registerListeners(app, entities);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function reorderItem(
|
||||
app: App,
|
||||
entityName: string,
|
||||
config: SortPluginOptions["entities"][string],
|
||||
id: any,
|
||||
newPosition: number,
|
||||
) {
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const em = app.em.fork();
|
||||
const kysely = em.connection.kysely;
|
||||
|
||||
// get the item
|
||||
const { data: item } = await em.repo(entityName).findOne({ id });
|
||||
if (!item) {
|
||||
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
|
||||
}
|
||||
|
||||
const oldPosition = item[sortField] as number | null | undefined;
|
||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||
|
||||
// update the item with new position (using forked em, so no listeners are triggered)
|
||||
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
|
||||
|
||||
// shift other items using kysely
|
||||
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
|
||||
if (newPosition < oldPosition) {
|
||||
// moving up: increment items between newPosition and oldPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where(sortField as any, "<", oldPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
} else {
|
||||
// moving down: decrement items between oldPosition and newPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||
.where(sortField as any, ">", oldPosition)
|
||||
.where(sortField as any, "<=", newPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
} else if (oldPosition === undefined || oldPosition === null) {
|
||||
// new item, shift everything at or after this position
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async function recalculateSortOrder(
|
||||
app: App,
|
||||
entityName: string,
|
||||
config: SortPluginOptions["entities"][string],
|
||||
batchSize: number,
|
||||
scope?: any,
|
||||
) {
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const db = app.connection.kysely;
|
||||
|
||||
const { count } = (await db
|
||||
.selectFrom(entityName)
|
||||
.select((eb) => eb.fn.count<number>("id").as("count"))
|
||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||
eb.where(scopeField as any, "=", scope),
|
||||
)
|
||||
.$castTo<{ count: number }>()
|
||||
.executeTakeFirst()) ?? { count: 0 };
|
||||
|
||||
const batches = Math.ceil(count / batchSize);
|
||||
for (let i = 0; i < batches; i++) {
|
||||
// get all items in scope, ordered by current sort value
|
||||
const items = await db
|
||||
.selectFrom(entityName)
|
||||
.select(["id", sortField])
|
||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||
eb.where(scopeField as any, "=", scope),
|
||||
)
|
||||
.orderBy(sortField, "asc")
|
||||
.limit(batchSize)
|
||||
.offset(i * batchSize)
|
||||
.execute();
|
||||
|
||||
const newQbs = items.map((item, index) =>
|
||||
db
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: index })
|
||||
.where("id", "=", item.id),
|
||||
);
|
||||
|
||||
await app.connection.executeQueries(...newQbs);
|
||||
$console.log(
|
||||
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
|
||||
const kysely = app.connection.kysely;
|
||||
|
||||
// handle insert events
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorInsertBefore,
|
||||
async (e) => {
|
||||
const entityName = e.params.entity.name;
|
||||
const config = entities[entityName];
|
||||
if (!config) return e.params.data;
|
||||
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const data = e.params.data;
|
||||
const scopeValue = scopeField ? data[scopeField] : undefined;
|
||||
|
||||
// if no position provided, set to max + 1
|
||||
if (data[sortField] === undefined || data[sortField] === null) {
|
||||
const query = kysely
|
||||
.selectFrom(entityName)
|
||||
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
|
||||
// add scope filter if needed
|
||||
.$if(Boolean(scopeField && scopeValue), (eb) =>
|
||||
eb.where(scopeField as any, "=", scopeValue as any),
|
||||
);
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
const max = result?.max ?? -1;
|
||||
|
||||
return {
|
||||
...data,
|
||||
[sortField]: max + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// if position is provided, shift other items at or after that position
|
||||
const newPosition = data[sortField] as number;
|
||||
|
||||
let shiftQuery = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await shiftQuery.execute();
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-sort-insert",
|
||||
},
|
||||
);
|
||||
|
||||
// handle update events
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorUpdateBefore,
|
||||
async (e) => {
|
||||
const entityName = e.params.entity.name;
|
||||
const config = entities[entityName];
|
||||
if (!config) return e.params.data;
|
||||
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const data = e.params.data;
|
||||
|
||||
// only handle if sort field is being updated
|
||||
if (!(sortField in data)) return e.params.data;
|
||||
|
||||
const newPosition = data[sortField] as number;
|
||||
const id = e.params.entityId;
|
||||
|
||||
// get the current item to know its old position and scope
|
||||
const item = await kysely
|
||||
.selectFrom(entityName)
|
||||
.selectAll()
|
||||
.where("id" as any, "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!item) return data;
|
||||
|
||||
const oldPosition = item[sortField] as number | null | undefined;
|
||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||
|
||||
// if oldPosition is null or undefined, treat as inserting at newPosition
|
||||
if (oldPosition === null || oldPosition === undefined) {
|
||||
// shift items at or after the new position
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
return data;
|
||||
}
|
||||
|
||||
// shift other items using kysely
|
||||
if (oldPosition !== newPosition) {
|
||||
if (newPosition < oldPosition) {
|
||||
// moving up: increment items between newPosition and oldPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where(sortField as any, "<", oldPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
} else {
|
||||
// moving down: decrement items between oldPosition and newPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||
.where(sortField as any, ">", oldPosition)
|
||||
.where(sortField as any, "<=", newPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-sort-update",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
||||
export { sort, type SortPluginOptions } from "./data/sort.plugin";
|
||||
|
||||
@@ -16,6 +16,11 @@ export type BkndAdminConfig = {
|
||||
* @default `/`
|
||||
*/
|
||||
basepath?: string;
|
||||
/**
|
||||
* Sub-path for the Admin UI within the base path
|
||||
* @default ``
|
||||
*/
|
||||
admin_basepath?: string;
|
||||
/**
|
||||
* Path to return to when clicking the logo
|
||||
* @default `/`
|
||||
|
||||
@@ -77,7 +77,7 @@ export class AppReduced {
|
||||
|
||||
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]
|
||||
return [absolute ? "~" : null, this.options.basepath, ...paths]
|
||||
.filter(Boolean)
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/")
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Group = <E extends ElementType = "div">({
|
||||
{...props}
|
||||
data-role="group"
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5 w-full",
|
||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
as === "fieldset" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
@@ -97,7 +97,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||
disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed",
|
||||
!disabledOrReadonly &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
props.className,
|
||||
@@ -154,7 +154,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
@@ -214,7 +214,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
{...props}
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed scale-150 ml-1"
|
||||
checked={checked}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
@@ -260,7 +260,6 @@ export const Switch = forwardRef<
|
||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||
)}
|
||||
onCheckedChange={(bool) => {
|
||||
console.log("setting", bool);
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
{...(props as any)}
|
||||
@@ -294,7 +293,7 @@ export const Select = forwardRef<
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
||||
"appearance-none h-11 w-full",
|
||||
!props.multiple && "border-r-8 border-r-transparent",
|
||||
props.className,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { useBkndOptions } from "ui/client/BkndProvider";
|
||||
|
||||
/*
|
||||
* Transforms `path` into its relative `base` version
|
||||
@@ -70,11 +71,13 @@ export function Link({
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const bkndOptions = useBkndOptions();
|
||||
const adminBasePath = bkndOptions?.admin_basepath ?? "";
|
||||
const _href = props.href ?? props.to;
|
||||
|
||||
const href = router
|
||||
.hrefs(
|
||||
_href[0] === "~" ? _href.slice(1) : router.base + _href,
|
||||
_href[0] === "~" ? adminBasePath + _href.slice(1) : router.base + _href,
|
||||
router, // pass router as a second argument for convinience
|
||||
)
|
||||
.replace("//", "/");
|
||||
@@ -82,8 +85,9 @@ export function Link({
|
||||
const active =
|
||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||
|
||||
// console.log({ adminBasePath, _href, href, bkndOptions })
|
||||
if (native) {
|
||||
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
||||
return <a className={`${active ? "active " : ""}${className}`} {...props} href={href} />;
|
||||
}
|
||||
|
||||
const wouterOnClick = (e: any) => {
|
||||
@@ -99,10 +103,11 @@ export function Link({
|
||||
};
|
||||
|
||||
return (
|
||||
// @ts-expect-error className is not typed on WouterLink
|
||||
<WouterLink
|
||||
// @ts-expect-error className is not typed on WouterLink
|
||||
className={`${active ? "active " : ""}${className}`}
|
||||
{...props}
|
||||
className={`${active ? "active " : ""}${className}`}
|
||||
href={`~${href}`}
|
||||
onClick={wouterOnClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function useNavigate() {
|
||||
}
|
||||
}
|
||||
|
||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||
const state = {
|
||||
...options?.state,
|
||||
referrer: location,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
import Admin from "./Admin";
|
||||
//import "./main.css";
|
||||
import "./styles.css";
|
||||
|
||||
function render() {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function EntityForm({
|
||||
className,
|
||||
action,
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
const fields = entity.getFields({ virtual: true, primary: false });
|
||||
const options = useEntityAdminOptions(entity, action);
|
||||
|
||||
return (
|
||||
@@ -93,10 +93,6 @@ export function EntityForm({
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.isFillable(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _key = `${entity.name}-${field.name}-${key}`;
|
||||
|
||||
return (
|
||||
@@ -128,7 +124,7 @@ export function EntityForm({
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
disabled={fieldsDisabled || !field.isFillable(action)}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useEntityForm({
|
||||
}: EntityFormProps) {
|
||||
const data = initialData ?? {};
|
||||
// @todo: check if virtual must be filtered
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
const fields = entity.getFields({ virtual: true, primary: false });
|
||||
|
||||
// filter defaultValues to only contain fillable fields
|
||||
const defaultValues = getDefaultValues(fields, data);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
|
||||
+2
-3
@@ -2,11 +2,11 @@
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"incremental": true,
|
||||
"module": "ESNext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": false,
|
||||
"target": "ES2022",
|
||||
"target": "es2023",
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
@@ -26,7 +26,6 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"outDir": "./dist/types",
|
||||
"paths": {
|
||||
"*": ["./src/*"],
|
||||
|
||||
Reference in New Issue
Block a user