Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282c9f6054 | ||
|
|
b294ee68ac | ||
|
|
e8a256af2a | ||
|
|
8c596781ff | ||
|
|
43cbdb3010 | ||
|
|
b3c9f258de |
@@ -16,8 +16,8 @@ jobs:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: bknd
|
||||
ports:
|
||||
- 5430:5432
|
||||
# ports:
|
||||
# - 5430:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--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,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
//"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||
"js/ts.preferences.autoImportFileExcludePatterns": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**/dist/**",
|
||||
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
||||
],
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"js/ts.preferences.includePackageJsonAutoImports": "on",
|
||||
"js/ts.tsserver.watchOptions": {
|
||||
"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 { bunTestRunner } from "adapter/bun/test";
|
||||
|
||||
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
|
||||
omitKeys(cfg, ["connection"]);
|
||||
const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
@@ -73,7 +73,13 @@ describe("Api", async () => {
|
||||
|
||||
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);
|
||||
})
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,14 +51,18 @@ describe("Authenticator", async () => {
|
||||
});
|
||||
const token = await auth.jwt({ sub: "test" });
|
||||
|
||||
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||
const res = await auth.resolveAuthFromRequest(
|
||||
new Headers({
|
||||
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}`,
|
||||
}));
|
||||
expect((res2 as any).sub).toBe("test")
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect((res2 as any).sub).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
|
||||
target: "media",
|
||||
config: {
|
||||
mappedBy: "image",
|
||||
on_delete: "set null",
|
||||
},
|
||||
});
|
||||
// media should not see categories
|
||||
|
||||
@@ -8,9 +8,11 @@ import { $ } from "bun";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
|
||||
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
|
||||
|
||||
const credentials = {
|
||||
host: "localhost",
|
||||
port: 5430,
|
||||
host: isCI ? "postgres" : "localhost",
|
||||
port: 5432,
|
||||
user: "postgres",
|
||||
password: "postgres",
|
||||
database: "bknd",
|
||||
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
|
||||
|
||||
describe("postgres", () => {
|
||||
beforeAll(async () => {
|
||||
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
|
||||
console.log("Running in CI: Skipping manual docker run");
|
||||
return;
|
||||
}
|
||||
|
||||
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, 500, 20);
|
||||
|
||||
+6
-26
@@ -125,10 +125,7 @@ async function buildApi() {
|
||||
|
||||
async function rewriteClient(path: string) {
|
||||
const bundle = await Bun.file(path).text();
|
||||
await Bun.write(
|
||||
path,
|
||||
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
||||
);
|
||||
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,10 +235,7 @@ async function buildUiElements() {
|
||||
/**
|
||||
* Building adapters
|
||||
*/
|
||||
function baseConfig(
|
||||
adapter: string,
|
||||
overrides: Partial<tsup.Options> = {},
|
||||
): tsup.Options {
|
||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||
return {
|
||||
minify,
|
||||
sourcemap,
|
||||
@@ -372,17 +366,11 @@ async function buildAdapters() {
|
||||
metafile: false,
|
||||
external: [/^bun\:.*/],
|
||||
}),
|
||||
|
||||
]);
|
||||
}
|
||||
|
||||
async function buildAll() {
|
||||
await Promise.all([
|
||||
buildApi(),
|
||||
buildUi(),
|
||||
buildUiElements(),
|
||||
buildAdapters(),
|
||||
]);
|
||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||
}
|
||||
|
||||
// initial build
|
||||
@@ -414,22 +402,14 @@ if (watch) {
|
||||
};
|
||||
|
||||
// watch src directory recursively
|
||||
fsWatch(
|
||||
join(import.meta.dir, "src"),
|
||||
{ recursive: true },
|
||||
(event, filename) => {
|
||||
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
// ignore non-source files
|
||||
if (
|
||||
!filename.endsWith(".ts") &&
|
||||
!filename.endsWith(".tsx") &&
|
||||
!filename.endsWith(".css")
|
||||
)
|
||||
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
||||
return;
|
||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||
debouncedRebuild();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// keep process alive
|
||||
await new Promise(() => {});
|
||||
|
||||
+4
-1
@@ -3,7 +3,10 @@ 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">;
|
||||
type RunOptions = Omit<
|
||||
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
|
||||
"stdout" | "stderr"
|
||||
>;
|
||||
|
||||
async function run(
|
||||
cmd: string[] | string,
|
||||
|
||||
@@ -20,10 +20,7 @@ export async function getApp<Env>(
|
||||
* @param config - bknd configuration
|
||||
* @param args - environment variables
|
||||
*/
|
||||
export function serve<Env>(
|
||||
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||
args: Env,
|
||||
) {
|
||||
export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
|
||||
return async (request: 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 TanstackStartConfig<Env = TanstackStartEnv> =
|
||||
FrameworkBkndConfig<Env>;
|
||||
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
|
||||
|
||||
/**
|
||||
* Get bknd app instance
|
||||
|
||||
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const auth = (options?: {
|
||||
skip?: (string | RegExp)[];
|
||||
}) =>
|
||||
export const auth = (options?: { skip?: (string | RegExp)[] }) =>
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
if (!c.get("auth")) {
|
||||
c.set("auth", {
|
||||
|
||||
@@ -31,11 +31,7 @@ describe.skipIf(ALL_TESTS)("plunk", () => {
|
||||
from: undefined,
|
||||
});
|
||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
||||
const response = await driver.send(
|
||||
"help@bknd.io",
|
||||
"HTML Test",
|
||||
htmlBody,
|
||||
);
|
||||
const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
|
||||
expect(response).toBeDefined();
|
||||
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 { PostgresConnection, plugins } from "./PostgresConnection";
|
||||
import { customIntrospector } from "../Connection";
|
||||
|
||||
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresJs(
|
||||
config: PostgresJSDialectConfig,
|
||||
): PostgresJsConnection {
|
||||
export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
|
||||
return new PostgresJsConnection(config);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,10 @@ export class Entity<
|
||||
getFields({
|
||||
virtual = false,
|
||||
primary = true,
|
||||
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
|
||||
}: {
|
||||
virtual?: boolean;
|
||||
primary?: boolean;
|
||||
} = {}): Field[] {
|
||||
return this.fields.filter((f) => {
|
||||
if (!virtual && f.isVirtual()) return false;
|
||||
if (!primary && f instanceof PrimaryField) return false;
|
||||
|
||||
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
emgr?: EventManager<any>,
|
||||
) {
|
||||
// add entities & relations
|
||||
entities.forEach((entity) => this.addEntity(entity));
|
||||
relations.forEach((relation) => this.addRelation(relation));
|
||||
indices.forEach((index) => this.addIndex(index));
|
||||
entities.forEach((entity) => void this.addEntity(entity));
|
||||
relations.forEach((relation) => void this.addRelation(relation));
|
||||
indices.forEach((index) => void this.addIndex(index));
|
||||
|
||||
if (!Connection.isConnection(connection)) {
|
||||
throw new UnableToConnectException("");
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RelationHelper,
|
||||
} from "../relations";
|
||||
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;
|
||||
export type TDirection = (typeof directions)[number];
|
||||
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
|
||||
mappedBy: s.string().optional(),
|
||||
inversedBy: s.string().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
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
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
|
||||
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
|
||||
fieldConfig: s
|
||||
.object({
|
||||
label: s.string(),
|
||||
on_delete: s.string({ enum: RelationCascades }).optional(),
|
||||
})
|
||||
.optional(),
|
||||
...EntityRelation.schema.properties,
|
||||
|
||||
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
||||
|
||||
export const relationFieldConfigSchema = s.strictObject({
|
||||
reference: s.string(),
|
||||
target: s.string(), // @todo: potentially has to be an instance!
|
||||
target_field: s.string({ default: "id" }).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,
|
||||
});
|
||||
|
||||
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> {
|
||||
override readonly type = "relation";
|
||||
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
return new RelationField(name, {
|
||||
...config,
|
||||
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,
|
||||
target: target.entity.name,
|
||||
target_field: target.entity.getPrimaryField().name,
|
||||
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
return this.config.reference;
|
||||
}
|
||||
|
||||
onDelete(): RelationCascade | undefined {
|
||||
return this.config.on_delete;
|
||||
}
|
||||
|
||||
target() {
|
||||
return this.config.target;
|
||||
}
|
||||
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
...super.schema()!,
|
||||
type: this.config.target_field_type ?? "integer",
|
||||
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 { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
||||
|
||||
export * from "./EntityRelation";
|
||||
export * from "./EntityRelationAnchor";
|
||||
@@ -28,6 +29,9 @@ export {
|
||||
type PolymorphicRelationConfig,
|
||||
RelationTypes,
|
||||
type RelationType,
|
||||
RelationCascades,
|
||||
type RelationCascade,
|
||||
DEFAULT_RELATION_CASCADE,
|
||||
// field
|
||||
RelationField,
|
||||
relationFieldConfigSchema,
|
||||
|
||||
@@ -5,3 +5,13 @@ export const RelationTypes = {
|
||||
Polymorphic: "poly",
|
||||
} as const;
|
||||
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";
|
||||
|
||||
@@ -77,14 +77,6 @@ export default function Admin(props: BkndAdminProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function BkndWrapper({ options, children }: { options?: BkndAdminConfig; children: ReactNode }) {
|
||||
return (
|
||||
<BkndProvider options={options} fallback={<Skeleton theme={options?.theme} />}>
|
||||
{children}
|
||||
</BkndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminInner(props: BkndAdminProps) {
|
||||
const { theme } = useTheme();
|
||||
const config = {
|
||||
@@ -92,10 +84,16 @@ function AdminInner(props: BkndAdminProps) {
|
||||
...useBkndWindowContext(),
|
||||
};
|
||||
|
||||
const BkndWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
|
||||
{children}
|
||||
</BkndProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||
<Notifications position="top-right" />
|
||||
<Routes BkndWrapper={BkndWrapper} options={config} basePath={config?.basepath}>
|
||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||
{props.children}
|
||||
</Routes>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
type ClientProviderProps,
|
||||
useApi,
|
||||
useBaseUrl,
|
||||
useClientContext
|
||||
useClientContext,
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
|
||||
@@ -22,7 +22,6 @@ export class AppReduced {
|
||||
protected appJson: AppType,
|
||||
protected _options: BkndAdminProps["config"] = {},
|
||||
) {
|
||||
|
||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||
return constructEntity(name, entity);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ export function Logo({
|
||||
scale = 0.2,
|
||||
fill,
|
||||
...props
|
||||
}: { scale?: number; fill?: string; theme?: string }) {
|
||||
}: {
|
||||
scale?: number;
|
||||
fill?: string;
|
||||
theme?: string;
|
||||
}) {
|
||||
const t = useTheme();
|
||||
const theme = props.theme ?? t.theme;
|
||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||
|
||||
@@ -117,7 +117,11 @@ const ArrayAdd = ({
|
||||
schema,
|
||||
path: _path,
|
||||
label = "Add",
|
||||
}: { schema: JsonSchema; path: string; label?: string }) => {
|
||||
}: {
|
||||
schema: JsonSchema;
|
||||
path: string;
|
||||
label?: string;
|
||||
}) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
|
||||
@@ -64,6 +64,11 @@ export function Step({
|
||||
path = [],
|
||||
id,
|
||||
...rest
|
||||
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
path?: string[];
|
||||
}) {
|
||||
return <div {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,12 @@ export function checkMaxReached({
|
||||
current = 0,
|
||||
overwrite,
|
||||
added,
|
||||
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
|
||||
}: {
|
||||
maxItems?: number;
|
||||
current?: number;
|
||||
overwrite?: boolean;
|
||||
added: number;
|
||||
}) {
|
||||
if (!maxItems) {
|
||||
return {
|
||||
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 = {
|
||||
...options?.state,
|
||||
referrer: location,
|
||||
|
||||
@@ -283,7 +283,10 @@ function EntityJsonFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||
}: {
|
||||
fieldApi: TFieldApi;
|
||||
field: JsonField;
|
||||
}) {
|
||||
const [error, setError] = useState<any>(null);
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
setError(null);
|
||||
@@ -312,7 +315,10 @@ function EntityEnumFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: TFieldApi; field: EnumField }) {
|
||||
}: {
|
||||
fieldApi: TFieldApi;
|
||||
field: EnumField;
|
||||
}) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
fieldApi.handleChange(e.target.value);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Switch, TextInput } from "@mantine/core";
|
||||
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 Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
||||
import { TbRefresh } from "react-icons/tb";
|
||||
@@ -46,7 +52,9 @@ const schema = s.strictObject({
|
||||
type: s.string({ enum: Relations.map((r) => r.type) }),
|
||||
source: stringIdentifier,
|
||||
target: stringIdentifier,
|
||||
config: s.object({}),
|
||||
config: s.object({
|
||||
on_delete: s.string({ enum: RelationCascades }).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
||||
control={control}
|
||||
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
||||
/>
|
||||
<OnDelete control={control} />
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -222,7 +250,6 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
||||
</div>
|
||||
{source && target && config && (
|
||||
<Callout>
|
||||
<>
|
||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||
<IconDatabase className="size-4" />
|
||||
{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{" "}
|
||||
<i>{config.required ? "required" : "optional"}</i>.
|
||||
</p>
|
||||
<p>
|
||||
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
||||
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
||||
</p>
|
||||
{config.sourceCardinality ? (
|
||||
<p>
|
||||
<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>.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
@@ -258,7 +288,7 @@ function OneToOne({
|
||||
data: {
|
||||
source,
|
||||
target,
|
||||
config: { mappedBy, required },
|
||||
config: { mappedBy, required, on_delete },
|
||||
},
|
||||
}: ComponentCtx) {
|
||||
return (
|
||||
@@ -271,6 +301,7 @@ function OneToOne({
|
||||
placeholder={target}
|
||||
/>
|
||||
<Switch label="Required" {...register("config.required")} />
|
||||
<OnDelete control={control} />
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -283,24 +314,26 @@ function OneToOne({
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||
<IconDatabase className="size-4" />
|
||||
{source}.{mappedBy || target}_id {"↔"} {target}
|
||||
</pre>
|
||||
<p>
|
||||
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
|
||||
<Pre>{target}</Pre>.
|
||||
A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
|
||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||
A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
|
||||
(which references <Pre>{target}</Pre>).
|
||||
</p>
|
||||
<p>
|
||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||
<i>{required ? "required" : "optional"}</i>.
|
||||
</p>
|
||||
</>
|
||||
<p>
|
||||
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
||||
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
||||
</p>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
||||
{...register("config.connectionTableMappedName")}
|
||||
placeholder={table}
|
||||
/>
|
||||
<OnDelete control={control} label="On delete (both references)" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/*<TextInput
|
||||
@@ -345,7 +379,6 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||
<IconDatabase className="size-4" />
|
||||
{source} {"→"} {table} {"←"} {target}
|
||||
@@ -356,7 +389,10 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
||||
<p>
|
||||
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
@@ -391,7 +427,6 @@ function Polymorphic({ register, control, data: { type, source, target, config }
|
||||
</div>
|
||||
{source && target && (
|
||||
<Callout>
|
||||
<>
|
||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||
<IconDatabase className="size-4" />
|
||||
{source} {"←"} {target}
|
||||
@@ -410,7 +445,6 @@ function Polymorphic({ register, control, data: { type, source, target, config }
|
||||
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -110,7 +110,12 @@ const EntityLinkList = ({
|
||||
title,
|
||||
context,
|
||||
suggestCreate = false,
|
||||
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
|
||||
}: {
|
||||
entities: Entity[];
|
||||
title?: string;
|
||||
context: "data" | "schema";
|
||||
suggestCreate?: boolean;
|
||||
}) => {
|
||||
const { $data } = useBkndData();
|
||||
const { readonly } = useBknd();
|
||||
const navigate = useRouteNavigate();
|
||||
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
|
||||
entity,
|
||||
children,
|
||||
enabled = true,
|
||||
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
|
||||
}: {
|
||||
entity: Entity;
|
||||
children: DropdownClickableChild;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
if (!enabled) return children;
|
||||
const [navigate] = useNavigate();
|
||||
const { $data } = useBkndData();
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { AuthRegister } from "ui/routes/auth/auth.register";
|
||||
import { BkndModalsProvider } from "ui/modals";
|
||||
import { useBkndWindowContext } from "bknd/client";
|
||||
import type { BkndAdminProps } from "ui/Admin";
|
||||
import ToolsRoutes from "./tools";
|
||||
|
||||
// @ts-ignore
|
||||
@@ -25,12 +24,10 @@ try {
|
||||
|
||||
export function Routes({
|
||||
BkndWrapper,
|
||||
options,
|
||||
basePath = "",
|
||||
children,
|
||||
}: {
|
||||
BkndWrapper: ComponentType<{ children: ReactNode; options?: BkndAdminProps["config"] }>;
|
||||
options?: BkndAdminProps["config"];
|
||||
BkndWrapper: ComponentType<{ children: ReactNode }>;
|
||||
basePath?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
@@ -46,7 +43,7 @@ export function Routes({
|
||||
<Route path="/auth/login" component={AuthLogin} />
|
||||
<Route path="/auth/register" component={AuthRegister} />
|
||||
|
||||
<BkndWrapper options={options}>
|
||||
<BkndWrapper>
|
||||
<BkndModalsProvider>
|
||||
<Route path="/" nest>
|
||||
<Root>
|
||||
|
||||
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
|
||||
export const DataSettings = ({
|
||||
schema,
|
||||
config,
|
||||
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
|
||||
}: {
|
||||
schema: ModuleSchemas["data"];
|
||||
config: ModuleConfigs["data"];
|
||||
}) => {
|
||||
const { app, readonly } = useBknd();
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
const entities = Object.keys(config.entities ?? {});
|
||||
|
||||
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
|
||||
showPlaceholder,
|
||||
actions: { openFileInput },
|
||||
} = Media.useDropzone();
|
||||
const {
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
files: [file] = [],
|
||||
} = Media.useDropzoneState();
|
||||
const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
|
||||
}
|
||||
|
||||
.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: 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
|
||||
|
||||
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"
|
||||
import {
|
||||
@@ -127,19 +127,40 @@ export const DELETE = handler;
|
||||
```
|
||||
|
||||
## 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`:
|
||||
|
||||
```tsx title="admin/[[...admin]]/page.tsx"
|
||||
import { Admin } from "bknd/ui";
|
||||
import { getApi } from "@/bknd";
|
||||
import "bknd/dist/styles.css";
|
||||
import { Admin } from "./page.client";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminPage() {
|
||||
// make sure to verify auth using headers
|
||||
const api = await getApi({ verify: true });
|
||||
|
||||
// early return if not logged in
|
||||
if (!api.getUser()) {
|
||||
return redirect("/unauthorized");
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Admin
|
||||
withProvider={{ user: api.getUser() }}
|
||||
config={{
|
||||
@@ -148,10 +169,38 @@ export default async function AdminPage() {
|
||||
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
|
||||
|
||||
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