Author SHA1 Message Date
shishantbiswas 282c9f6054 fix: on delete
Run Tests / test (pull_request) Successful in 7m24s
2026-09-27 17:57:45 +05:30
shishantbiswas b294ee68ac Merge pull request 'Release/0.21' (#6) from release/0.21 into main
Run Tests / test (push) Successful in 3m53s
Reviewed-on: shishantbiswas/bknd#6
2026-09-26 13:17:00 +00:00
shishantbiswas e8a256af2a update: tests and workflow to work the same way it does in github (#5)
Run Tests / test (push) Successful in 8m37s
Reviewed-on: shishantbiswas/bknd#5
2026-09-25 13:37:27 +00:00
Cameron Pak 8c596781ff Merge pull request #380 from shishantbiswas/next-docs
Run Tests / test (push) Failing after 7m39s
add (docs): section for production build issue, React SDK setup
2026-03-28 06:06:29 -05:00
shishantbiswas 43cbdb3010 add(docs): update next.js docs to use colocation for <Admin /> component 2026-03-28 15:31:43 +05:30
shishantbiswas b3c9f258de add: prod section at the end 2026-03-28 14:55:38 +05:30
41 changed files with 837 additions and 671 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ jobs:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: bknd POSTGRES_DB: bknd
ports: # ports:
- 5430:5432 # - 5430:5432
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
+8 -5
View File
@@ -1,19 +1,22 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "js/ts.tsdk.path": "node_modules/typescript/lib",
"biome.enabled": true, "biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit", //"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit" "source.fixAll.biome": "explicit"
}, },
"typescript.preferences.importModuleSpecifier": "non-relative", "js/ts.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [ "js/ts.preferences.autoImportFileExcludePatterns": [
"**/dist/**", "**/dist/**",
"**/node_modules/**/dist/**", "**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter "**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
], ],
"typescript.preferences.includePackageJsonAutoImports": "on", "js/ts.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": { "js/ts.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"] "excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
} }
} }
+1 -2
View File
@@ -4,8 +4,7 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
const stripConnection = <T extends Record<string, any>>(cfg: T) => const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
+9 -3
View File
@@ -73,7 +73,13 @@ describe("Api", async () => {
it("should extract tokens case insensitive", async () => { it("should extract tokens case insensitive", async () => {
const token = await sign({ sub: "test" }, "1234"); const token = await sign({ sub: "test" }, "1234");
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token); expect(
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token); 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);
});
}); });
+11 -7
View File
@@ -51,14 +51,18 @@ describe("Authenticator", async () => {
}); });
const token = await auth.jwt({ sub: "test" }); const token = await auth.jwt({ sub: "test" });
const res = await auth.resolveAuthFromRequest(new Headers({ const res = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
})); }),
expect((res as any).sub).toBe("test") );
expect((res as any).sub).toBe("test");
const res2 = await auth.resolveAuthFromRequest(new Headers({ const res2 = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `bearer ${token}`, Authorization: `bearer ${token}`,
})); }),
expect((res2 as any).sub).toBe("test") );
}) expect((res2 as any).sub).toBe("test");
});
}); });
+1
View File
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
target: "media", target: "media",
config: { config: {
mappedBy: "image", mappedBy: "image",
on_delete: "set null",
}, },
}); });
// media should not see categories // media should not see categories
+9 -2
View File
@@ -8,9 +8,11 @@ import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
const credentials = { const credentials = {
host: "localhost", host: isCI ? "postgres" : "localhost",
port: 5430, port: 5432,
user: "postgres", user: "postgres",
password: "postgres", password: "postgres",
database: "bknd", database: "bknd",
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
describe("postgres", () => { describe("postgres", () => {
beforeAll(async () => { beforeAll(async () => {
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
console.log("Running in CI: Skipping manual docker run");
return;
}
if (!(await isPostgresRunning())) { 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 $`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, 500, 20); await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
+6 -26
View File
@@ -125,10 +125,7 @@ async function buildApi() {
async function rewriteClient(path: string) { async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text(); const bundle = await Bun.file(path).text();
await Bun.write( await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
path,
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
);
} }
/** /**
@@ -238,10 +235,7 @@ async function buildUiElements() {
/** /**
* Building adapters * Building adapters
*/ */
function baseConfig( function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
adapter: string,
overrides: Partial<tsup.Options> = {},
): tsup.Options {
return { return {
minify, minify,
sourcemap, sourcemap,
@@ -372,17 +366,11 @@ async function buildAdapters() {
metafile: false, metafile: false,
external: [/^bun\:.*/], external: [/^bun\:.*/],
}), }),
]); ]);
} }
async function buildAll() { async function buildAll() {
await Promise.all([ await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
buildApi(),
buildUi(),
buildUiElements(),
buildAdapters(),
]);
} }
// initial build // initial build
@@ -414,22 +402,14 @@ if (watch) {
}; };
// watch src directory recursively // watch src directory recursively
fsWatch( fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
join(import.meta.dir, "src"),
{ recursive: true },
(event, filename) => {
if (!filename) return; if (!filename) return;
// ignore non-source files // ignore non-source files
if ( if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
!filename.endsWith(".ts") &&
!filename.endsWith(".tsx") &&
!filename.endsWith(".css")
)
return; return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`)); oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild(); debouncedRebuild();
}, });
);
// keep process alive // keep process alive
await new Promise(() => {}); await new Promise(() => {});
+4 -1
View File
@@ -3,7 +3,10 @@ import path from "node:path";
import c from "picocolors"; import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1); const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">; type RunOptions = Omit<
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
"stdout" | "stderr"
>;
async function run( async function run(
cmd: string[] | string, cmd: string[] | string,
+1 -4
View File
@@ -20,10 +20,7 @@ export async function getApp<Env>(
* @param config - bknd configuration * @param config - bknd configuration
* @param args - environment variables * @param args - environment variables
*/ */
export function serve<Env>( export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
args: Env,
) {
return async (request: Request) => { return async (request: Request) => {
return (await getApp(config, args)).fetch(request); return (await getApp(config, args)).fetch(request);
}; };
@@ -2,8 +2,7 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv; export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> = export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
FrameworkBkndConfig<Env>;
/** /**
* Get bknd app instance * Get bknd app instance
+1 -3
View File
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
return result; return result;
} }
export const auth = (options?: { export const auth = (options?: { skip?: (string | RegExp)[] }) =>
skip?: (string | RegExp)[];
}) =>
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) { if (!c.get("auth")) {
c.set("auth", { c.set("auth", {
+1 -5
View File
@@ -31,11 +31,7 @@ describe.skipIf(ALL_TESTS)("plunk", () => {
from: undefined, from: undefined,
}); });
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>"; const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send( const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
"help@bknd.io",
"HTML Test",
htmlBody,
);
expect(response).toBeDefined(); expect(response).toBeDefined();
expect(response.success).toBe(true); expect(response.success).toBe(true);
}); });
@@ -1,4 +1,8 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely"; import {
Kysely,
PostgresDialect,
type PostgresDialectConfig as KyselyPostgresDialectConfig,
} from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector"; import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection"; import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection"; import { customIntrospector } from "../Connection";
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
} }
} }
export function postgresJs( export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
config: PostgresJSDialectConfig,
): PostgresJsConnection {
return new PostgresJsConnection(config); return new PostgresJsConnection(config);
} }
+4 -1
View File
@@ -194,7 +194,10 @@ export class Entity<
getFields({ getFields({
virtual = false, virtual = false,
primary = true, primary = true,
}: { virtual?: boolean; primary?: boolean } = {}): Field[] { }: {
virtual?: boolean;
primary?: boolean;
} = {}): Field[] {
return this.fields.filter((f) => { return this.fields.filter((f) => {
if (!virtual && f.isVirtual()) return false; if (!virtual && f.isVirtual()) return false;
if (!primary && f instanceof PrimaryField) return false; if (!primary && f instanceof PrimaryField) return false;
+3 -3
View File
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
emgr?: EventManager<any>, emgr?: EventManager<any>,
) { ) {
// add entities & relations // add entities & relations
entities.forEach((entity) => this.addEntity(entity)); entities.forEach((entity) => void this.addEntity(entity));
relations.forEach((relation) => this.addRelation(relation)); relations.forEach((relation) => void this.addRelation(relation));
indices.forEach((index) => this.addIndex(index)); indices.forEach((index) => void this.addIndex(index));
if (!Connection.isConnection(connection)) { if (!Connection.isConnection(connection)) {
throw new UnableToConnectException(""); throw new UnableToConnectException("");
+2 -1
View File
@@ -8,7 +8,7 @@ import {
RelationHelper, RelationHelper,
} from "../relations"; } from "../relations";
import type { RepoQuery } from "../server/query"; import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types"; import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationType } from "./relation-types";
const directions = ["source", "target"] as const; const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number]; export type TDirection = (typeof directions)[number];
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
mappedBy: s.string().optional(), mappedBy: s.string().optional(),
inversedBy: s.string().optional(), inversedBy: s.string().optional(),
required: s.boolean().optional(), required: s.boolean().optional(),
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
}); });
// don't make protected, App requires it to instantiatable // don't make protected, App requires it to instantiatable
+2 -1
View File
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField"; import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
import type { MutationInstructionResponse } from "./RelationMutator"; import type { MutationInstructionResponse } from "./RelationMutator";
import { type RelationType, RelationTypes } from "./relation-types"; import { type RelationType, RelationCascades, RelationTypes } from "./relation-types";
/** /**
* Source entity receives the mapping field * Source entity receives the mapping field
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
fieldConfig: s fieldConfig: s
.object({ .object({
label: s.string(), label: s.string(),
on_delete: s.string({ enum: RelationCascades }).optional(),
}) })
.optional(), .optional(),
...EntityRelation.schema.properties, ...EntityRelation.schema.properties,
+13 -5
View File
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor"; import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import type { TFieldTSType } from "data/entities/EntityTypescript"; import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils"; import { s } from "bknd/utils";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
export const relationFieldConfigSchema = s.strictObject({ export const relationFieldConfigSchema = s.strictObject({
reference: s.string(), reference: s.string(),
target: s.string(), // @todo: potentially has to be an instance! target: s.string(), // @todo: potentially has to be an instance!
target_field: s.string({ default: "id" }).optional(), target_field: s.string({ default: "id" }).optional(),
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(), target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(), on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
...baseFieldConfigSchema.properties, ...baseFieldConfigSchema.properties,
}); });
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>; export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
export type RelationFieldBaseConfig = { label?: string }; export type RelationFieldBaseConfig = {
label?: string;
on_delete?: RelationCascade;
};
export class RelationField extends Field<RelationFieldConfig> { export class RelationField extends Field<RelationFieldConfig> {
override readonly type = "relation"; override readonly type = "relation";
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
return new RelationField(name, { return new RelationField(name, {
...config, ...config,
required: relation.required, required: relation.required,
// the relation owns the referential action, the field only materializes it
on_delete: config?.on_delete ?? relation.config.on_delete,
reference: target.reference, reference: target.reference,
target: target.entity.name, target: target.entity.name,
target_field: target.entity.getPrimaryField().name, target_field: target.entity.getPrimaryField().name,
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
return this.config.reference; return this.config.reference;
} }
onDelete(): RelationCascade | undefined {
return this.config.on_delete;
}
target() { target() {
return this.config.target; return this.config.target;
} }
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
...super.schema()!, ...super.schema()!,
type: this.config.target_field_type ?? "integer", type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`, references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null", onDelete: this.config.on_delete ?? DEFAULT_RELATION_CASCADE,
}); });
} }
+4
View File
@@ -3,6 +3,7 @@ import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRela
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation"; import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation"; import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
import { type RelationType, RelationTypes } from "./relation-types"; import { type RelationType, RelationTypes } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export * from "./EntityRelation"; export * from "./EntityRelation";
export * from "./EntityRelationAnchor"; export * from "./EntityRelationAnchor";
@@ -28,6 +29,9 @@ export {
type PolymorphicRelationConfig, type PolymorphicRelationConfig,
RelationTypes, RelationTypes,
type RelationType, type RelationType,
RelationCascades,
type RelationCascade,
DEFAULT_RELATION_CASCADE,
// field // field
RelationField, RelationField,
relationFieldConfigSchema, relationFieldConfigSchema,
+10
View File
@@ -5,3 +5,13 @@ export const RelationTypes = {
Polymorphic: "poly", Polymorphic: "poly",
} as const; } as const;
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes]; export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
export const RelationCascades = [
"cascade",
"set null",
"set default",
"restrict",
"no action",
] as const;
export type RelationCascade = (typeof RelationCascades)[number];
export const DEFAULT_RELATION_CASCADE: RelationCascade = "set null";
+1 -1
View File
@@ -4,7 +4,7 @@ export {
type ClientProviderProps, type ClientProviderProps,
useApi, useApi,
useBaseUrl, useBaseUrl,
useClientContext useClientContext,
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
-1
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType, protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {}, protected _options: BkndAdminProps["config"] = {},
) { ) {
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity); return constructEntity(name, entity);
}); });
+5 -1
View File
@@ -4,7 +4,11 @@ export function Logo({
scale = 0.2, scale = 0.2,
fill, fill,
...props ...props
}: { scale?: number; fill?: string; theme?: string }) { }: {
scale?: number;
fill?: string;
theme?: string;
}) {
const t = useTheme(); const t = useTheme();
const theme = props.theme ?? t.theme; const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : theme === "light" ? "black" : "white"; const svgFill = fill ? fill : theme === "light" ? "black" : "white";
@@ -117,7 +117,11 @@ const ArrayAdd = ({
schema, schema,
path: _path, path: _path,
label = "Add", label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => { }: {
schema: JsonSchema;
path: string;
label?: string;
}) => {
const { const {
setValue, setValue,
value: { currentIndex }, value: { currentIndex },
+6 -1
View File
@@ -64,6 +64,11 @@ export function Step({
path = [], path = [],
id, id,
...rest ...rest
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) { }: {
children: React.ReactNode;
disabled?: boolean;
id: string;
path?: string[];
}) {
return <div {...rest}>{children}</div>; return <div {...rest}>{children}</div>;
} }
+6 -1
View File
@@ -35,7 +35,12 @@ export function checkMaxReached({
current = 0, current = 0,
overwrite, overwrite,
added, added,
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) { }: {
maxItems?: number;
current?: number;
overwrite?: boolean;
added: number;
}) {
if (!maxItems) { if (!maxItems) {
return { return {
reject: false, reject: false,
+6 -1
View File
@@ -101,7 +101,12 @@ export function useNavigate() {
} }
} }
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url; const _url = options?.absolute
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
/\/+/g,
"/",
)
: url;
const state = { const state = {
...options?.state, ...options?.state,
referrer: location, referrer: location,
@@ -283,7 +283,10 @@ function EntityJsonFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: TFieldApi; field: JsonField }) { }: {
fieldApi: TFieldApi;
field: JsonField;
}) {
const [error, setError] = useState<any>(null); const [error, setError] = useState<any>(null);
const handleUpdate = useEvent((value: any) => { const handleUpdate = useEvent((value: any) => {
setError(null); setError(null);
@@ -312,7 +315,10 @@ function EntityEnumFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: TFieldApi; field: EnumField }) { }: {
fieldApi: TFieldApi;
field: EnumField;
}) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value); fieldApi.handleChange(e.target.value);
}); });
@@ -1,6 +1,12 @@
import { Switch, TextInput } from "@mantine/core"; import { Switch, TextInput } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react"; import { IconDatabase } from "@tabler/icons-react";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data/relations"; import {
DEFAULT_RELATION_CASCADE,
ManyToOneRelation,
type RelationType,
RelationCascades,
RelationTypes,
} from "data/relations";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form"; import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { TbRefresh } from "react-icons/tb"; import { TbRefresh } from "react-icons/tb";
@@ -46,7 +52,9 @@ const schema = s.strictObject({
type: s.string({ enum: Relations.map((r) => r.type) }), type: s.string({ enum: Relations.map((r) => r.type) }),
source: stringIdentifier, source: stringIdentifier,
target: stringIdentifier, target: stringIdentifier,
config: s.object({}), config: s.object({
on_delete: s.string({ enum: RelationCascades }).optional(),
}),
}); });
type ComponentCtx<T extends FieldValues = FieldValues> = { type ComponentCtx<T extends FieldValues = FieldValues> = {
@@ -186,6 +194,25 @@ const Callout = ({ children }: { children: ReactNode }) => (
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div> <div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
); );
function OnDelete({
control,
label = "On target delete",
}: {
control: Control<any>;
label?: string;
}) {
return (
<MantineSelect
control={control}
name="config.on_delete"
label={label}
allowDeselect={false}
defaultValue={DEFAULT_RELATION_CASCADE}
data={RelationCascades.map((cascade) => ({ value: cascade, label: cascade }))}
/>
);
}
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) { function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
return ( return (
<> <>
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
control={control} control={control}
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)} placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
/> />
<OnDelete control={control} />
</div> </div>
<div /> <div />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -222,7 +250,6 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
</div> </div>
{source && target && config && ( {source && target && config && (
<Callout> <Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" /> <IconDatabase className="size-4" />
{source}.{config.mappedBy || target}_id {"→"} {target} {source}.{config.mappedBy || target}_id {"→"} {target}
@@ -238,6 +265,10 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "} When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>. <i>{config.required ? "required" : "optional"}</i>.
</p> </p>
<p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
{config.sourceCardinality ? ( {config.sourceCardinality ? (
<p> <p>
<Pre>{target}</Pre> should not have more than{" "} <Pre>{target}</Pre> should not have more than{" "}
@@ -245,7 +276,6 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>. {config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p> </p>
) : null} ) : null}
</>
</Callout> </Callout>
)} )}
</> </>
@@ -258,7 +288,7 @@ function OneToOne({
data: { data: {
source, source,
target, target,
config: { mappedBy, required }, config: { mappedBy, required, on_delete },
}, },
}: ComponentCtx) { }: ComponentCtx) {
return ( return (
@@ -271,6 +301,7 @@ function OneToOne({
placeholder={target} placeholder={target}
/> />
<Switch label="Required" {...register("config.required")} /> <Switch label="Required" {...register("config.required")} />
<OnDelete control={control} />
</div> </div>
<div /> <div />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -283,24 +314,26 @@ function OneToOne({
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" /> <IconDatabase className="size-4" />
{source}.{mappedBy || target}_id {"↔"} {target} {source}.{mappedBy || target}_id {"↔"} {target}
</pre> </pre>
<p> <p>
A single entry of <Pre>{source}</Pre> will have a reference to{" "} A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
<Pre>{target}</Pre>. .
</p> </p>
<p> <p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "} A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>). (which references <Pre>{target}</Pre>).
</p> </p>
<p> <p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "} When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>. <i>{required ? "required" : "optional"}</i>.
</p> </p>
</> <p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout> </Callout>
)} )}
</> </>
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
{...register("config.connectionTableMappedName")} {...register("config.connectionTableMappedName")}
placeholder={table} placeholder={table}
/> />
<OnDelete control={control} label="On delete (both references)" />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/*<TextInput {/*<TextInput
@@ -345,7 +379,6 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" /> <IconDatabase className="size-4" />
{source} {"→"} {table} {"←"} {target} {source} {"→"} {table} {"←"} {target}
@@ -356,7 +389,10 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
<p> <p>
A connection table <Pre>{table}</Pre> will be created to store the relations. A connection table <Pre>{table}</Pre> will be created to store the relations.
</p> </p>
</> <p>
If an entry of <Pre>{source}</Pre> or <Pre>{target}</Pre> is deleted, the
connection is <i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout> </Callout>
)} )}
</> </>
@@ -391,7 +427,6 @@ function Polymorphic({ register, control, data: { type, source, target, config }
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" /> <IconDatabase className="size-4" />
{source} {"←"} {target} {source} {"←"} {target}
@@ -410,7 +445,6 @@ function Polymorphic({ register, control, data: { type, source, target, config }
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>. {config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p> </p>
) : null} ) : null}
</>
</Callout> </Callout>
)} )}
</> </>
+11 -2
View File
@@ -110,7 +110,12 @@ const EntityLinkList = ({
title, title,
context, context,
suggestCreate = false, suggestCreate = false,
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => { }: {
entities: Entity[];
title?: string;
context: "data" | "schema";
suggestCreate?: boolean;
}) => {
const { $data } = useBkndData(); const { $data } = useBkndData();
const { readonly } = useBknd(); const { readonly } = useBknd();
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
entity, entity,
children, children,
enabled = true, enabled = true,
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => { }: {
entity: Entity;
children: DropdownClickableChild;
enabled?: boolean;
}) => {
if (!enabled) return children; if (!enabled) return children;
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { $data } = useBkndData(); const { $data } = useBkndData();
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
export const DataSettings = ({ export const DataSettings = ({
schema, schema,
config, config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => { }: {
schema: ModuleSchemas["data"];
config: ModuleConfigs["data"];
}) => {
const { app, readonly } = useBknd(); const { app, readonly } = useBknd();
const prefix = app.getAbsolutePath("settings"); const prefix = app.getAbsolutePath("settings");
const entities = Object.keys(config.entities ?? {}); const entities = Object.keys(config.entities ?? {});
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
showPlaceholder, showPlaceholder,
actions: { openFileInput }, actions: { openFileInput },
} = Media.useDropzone(); } = Media.useDropzone();
const { const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
isOver,
isOverAccepted,
files: [file] = [],
} = Media.useDropzoneState();
return ( return (
<div <div
+3 -1
View File
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
} }
.flex-animate { .flex-animate {
transition: flex-grow 0.2s ease, background-color 0.2s ease; transition:
flex-grow 0.2s ease,
background-color 0.2s ease;
} }
.flex-initial { .flex-initial {
flex: 0 1 auto; flex: 0 1 auto;
+1 -1
View File
@@ -1 +1 @@
declare module '*.css'; declare module "*.css";
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
## Serve the API ## Serve the API
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file: Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
```ts title="src/bknd.ts" ```ts title="src/bknd.ts"
import { import {
@@ -127,19 +127,40 @@ export const DELETE = handler;
``` ```
## Enabling the Admin UI ## Enabling the Admin UI
Create a file at `admin/[[...admin]]/page.client.tsx`:
``` tsx title="admin/[[...admin]]/page.client.tsx"
"use client";
import dynamic from "next/dynamic";
export const Admin = dynamic(async () => (await import("bknd/ui")).Admin, {
ssr: false,
});
```
<Callout type="info">
We are using [Colocation](https://nextjs.org/docs/app/getting-started/project-structure#colocation) to prevent server-side hydration error, and the dynamic function to load Admin component only on client side.
</Callout>
Create a page at `admin/[[...admin]]/page.tsx`: Create a page at `admin/[[...admin]]/page.tsx`:
```tsx title="admin/[[...admin]]/page.tsx" ```tsx title="admin/[[...admin]]/page.tsx"
import { Admin } from "bknd/ui";
import { getApi } from "@/bknd"; import { getApi } from "@/bknd";
import "bknd/dist/styles.css"; import "bknd/dist/styles.css";
import { Admin } from "./page.client";
import { Suspense } from "react";
import { redirect } from "next/navigation";
export default async function AdminPage() { export default async function AdminPage() {
// make sure to verify auth using headers // make sure to verify auth using headers
const api = await getApi({ verify: true }); const api = await getApi({ verify: true });
// early return if not logged in
if (!api.getUser()) {
return redirect("/unauthorized");
}
return ( return (
<Suspense>
<Admin <Admin
withProvider={{ user: api.getUser() }} withProvider={{ user: api.getUser() }}
config={{ config={{
@@ -148,10 +169,38 @@ export default async function AdminPage() {
theme: "system", theme: "system",
}} }}
/> />
</Suspense>
); );
} };
``` ```
## React SDK configuration
To use queries and mutation on client side, bknd provides first class support for it with it's [React SDK](/usage/react)
to set it up with Next.js add the `ClientProvider` to your root layout
```tsx title="app/layout.tsx"
import { ClientProvider } from "bknd/client";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ClientProvider>
{children}
</ClientProvider>
</body>
</html>
);
};
```
## Example usage of the API ## Example usage of the API
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components: You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
@@ -172,3 +221,26 @@ export default async function Home() {
); );
} }
``` ```
## Note for Production
If your admin component is not rendering and has a error like `Identifier 'b' has already been declared`, you will need to add these lines to your `next.config.(mjs|ts)` file:
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// ...
// for turbopack
experimental: {
turbopackMinify: false,
},
// for webpack
// webpack: (config) => {
// config.optimization.minimize = false;
// return config;
// },
};
export default nextConfig;
```