Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e0086fcd | ||
|
|
282c9f6054 | ||
|
|
b294ee68ac | ||
|
|
e8a256af2a | ||
|
|
8c596781ff | ||
|
|
43cbdb3010 | ||
|
|
b3c9f258de |
@@ -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
|
||||||
|
|||||||
Vendored
+8
-5
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user