mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ca52a25c | |||
| 1ef2287d34 | |||
| eec8b64bae | |||
| b60fd01960 | |||
| f04239332a | |||
| 3da257e843 | |||
| a762789e93 | |||
| 11f76d788a | |||
| 3e58a17769 | |||
| a3b33a0312 | |||
| c7c1811267 | |||
| a1be1b4bf4 | |||
| 5763a6e150 | |||
| e246396225 | |||
| 8823b94eb7 | |||
| 79ca2a9939 | |||
| edb5d5f4a9 | |||
| 600b55fada | |||
| 95a8289afa | |||
| a55eca75d0 | |||
| b94dec65d2 | |||
| e5d6ed536c | |||
| 4e718a063d | |||
| 3859b9ba00 |
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Entity, NumberField, TextField } from "data";
|
||||
import * as p from "data/prototype";
|
||||
import { Entity, NumberField, TextField } from "../../../src/data";
|
||||
|
||||
describe("[data] Entity", async () => {
|
||||
const entity = new Entity("test", [
|
||||
@@ -48,7 +47,14 @@ describe("[data] Entity", async () => {
|
||||
expect(entity.getField("new_field")).toBe(field);
|
||||
});
|
||||
|
||||
test.only("types", async () => {
|
||||
console.log(entity.toTypes());
|
||||
});
|
||||
// @todo: move this to ClientApp
|
||||
/*test("serialize and deserialize", async () => {
|
||||
const json = entity.toJSON();
|
||||
//sconsole.log("json", json.fields);
|
||||
const newEntity = Entity.deserialize(json);
|
||||
//console.log("newEntity", newEntity.toJSON().fields);
|
||||
expect(newEntity).toBeInstanceOf(Entity);
|
||||
expect(json).toEqual(newEntity.toJSON());
|
||||
expect(json.fields).toEqual(newEntity.toJSON().fields);
|
||||
});*/
|
||||
});
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
|
||||
em.addRelation(new ManyToOneRelation(posts, users));
|
||||
expect(em.relations.all.length).toBe(1);
|
||||
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
||||
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
|
||||
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
|
||||
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
|
||||
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
|
||||
expect(em.hasRelations("users")).toBe(true);
|
||||
expect(em.hasRelations("posts")).toBe(true);
|
||||
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.12.0",
|
||||
"version": "0.11.2",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { $console, type DB } from "core";
|
||||
import { $console, type DB, type PrimaryFieldType } from "core";
|
||||
import { secureRandomString, transformObject } from "core/utils";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
|
||||
@@ -8,13 +8,11 @@ import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||
import { AppUserPool } from "auth/AppUserPool";
|
||||
import type { AppEntity } from "core/config";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare module "core" {
|
||||
interface Users extends AppEntity, UserFieldSchema {}
|
||||
interface DB {
|
||||
users: Users;
|
||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,3 @@ export { debug } from "./debug";
|
||||
export { user } from "./user";
|
||||
export { create } from "./create";
|
||||
export { copyAssets } from "./copy-assets";
|
||||
export { types } from "./types";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./types";
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { makeAppFromEnv } from "cli/commands/run";
|
||||
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||
import { writeFile } from "cli/utils/sys";
|
||||
import c from "picocolors";
|
||||
|
||||
export const types: CliCommand = (program) => {
|
||||
program
|
||||
.command("types")
|
||||
.description("generate types")
|
||||
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
|
||||
.addOption(new Option("--no-write", "do not write to file").default(true))
|
||||
.action(action);
|
||||
};
|
||||
|
||||
async function action({
|
||||
outfile,
|
||||
write,
|
||||
}: {
|
||||
outfile: string;
|
||||
write: boolean;
|
||||
}) {
|
||||
const app = await makeAppFromEnv({
|
||||
server: "node",
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const et = new EntityTypescript(app.em);
|
||||
|
||||
if (write) {
|
||||
await writeFile(outfile, et.toString());
|
||||
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
||||
} else {
|
||||
console.info(et.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { $console } from "core";
|
||||
import { execSync, exec as nodeExec } from "node:child_process";
|
||||
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
@@ -49,16 +48,6 @@ export async function fileExists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFile(filePath: string, content: string) {
|
||||
try {
|
||||
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
$console.error("Failed to write file", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
|
||||
const stdio = opts?.silent ? "pipe" : "inherit";
|
||||
const output = execSync(command, {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
|
||||
|
||||
export interface AppEntity<IdType extends number = number> {
|
||||
id: PrimaryFieldType<IdType>;
|
||||
}
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
export interface DB {
|
||||
// make sure to make unknown as "any"
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug, env } from "./env";
|
||||
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
|
||||
export { type PrimaryFieldType, config, type DB } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
|
||||
@@ -218,7 +218,7 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
|
||||
* @param object
|
||||
* @param sources
|
||||
*/
|
||||
export function mergeObject<R = unknown>(object, ...sources): R {
|
||||
export function mergeObject(object, ...sources) {
|
||||
for (const source of sources) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) {
|
||||
@@ -359,50 +359,3 @@ export function getPath(
|
||||
throw new Error(`Invalid path: ${path.join(".")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
|
||||
const nl = indent ? "\n" : "";
|
||||
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
|
||||
const openPad = pad(_level + 1);
|
||||
const closePad = pad(_level);
|
||||
|
||||
// primitives
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
const t = typeof value;
|
||||
if (t === "string") return JSON.stringify(value); // handles escapes
|
||||
if (t === "number" || t === "boolean") return String(value);
|
||||
|
||||
// arrays
|
||||
if (Array.isArray(value)) {
|
||||
const out = value
|
||||
.map((v) => objectToJsLiteral(v, indent, _level + 1))
|
||||
.join(", " + (indent ? nl + openPad : ""));
|
||||
return (
|
||||
"[" +
|
||||
(indent && value.length ? nl + openPad : "") +
|
||||
out +
|
||||
(indent && value.length ? nl + closePad : "") +
|
||||
"]"
|
||||
);
|
||||
}
|
||||
|
||||
// objects
|
||||
if (t === "object") {
|
||||
const entries = Object.entries(value).map(([k, v]) => {
|
||||
const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier?
|
||||
const key = idOk ? k : JSON.stringify(k); // quote if needed
|
||||
return key + ": " + objectToJsLiteral(v, indent, _level + 1);
|
||||
});
|
||||
const out = entries.join(", " + (indent ? nl + openPad : ""));
|
||||
return (
|
||||
"{" +
|
||||
(indent && entries.length ? nl + openPad : "") +
|
||||
out +
|
||||
(indent && entries.length ? nl + closePad : "") +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
throw new TypeError(`Unsupported data type: ${t}`);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type EntityManager,
|
||||
constructEntity,
|
||||
constructRelation,
|
||||
constructIndex,
|
||||
} from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
@@ -36,7 +35,9 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
);
|
||||
|
||||
const indices = transformObject(_indices, (index, name) => {
|
||||
return constructIndex(index, _entity, name);
|
||||
const entity = _entity(index.entity)!;
|
||||
const fields = index.fields.map((f) => entity.field(f)!);
|
||||
return new EntityIndex(entity, fields, index.unique, name);
|
||||
});
|
||||
|
||||
for (const entity of Object.values(entities)) {
|
||||
|
||||
+27
-30
@@ -1,6 +1,5 @@
|
||||
import type { DB } from "core";
|
||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
||||
|
||||
@@ -26,23 +25,21 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
readOne<E extends keyof DB | string>(
|
||||
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||
["entity", entity as any, id],
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
readOneBy<E extends keyof DB | string>(
|
||||
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
|
||||
return this.readMany(entity, {
|
||||
...query,
|
||||
@@ -51,8 +48,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
|
||||
}
|
||||
|
||||
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
const input = query ?? this.options.defaultQuery;
|
||||
@@ -65,70 +64,68 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
return this.post<T>(["entity", entity as any, "query"], input);
|
||||
}
|
||||
|
||||
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
reference: R,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
|
||||
readManyByReference<
|
||||
E extends keyof DB | string,
|
||||
R extends keyof DB | string,
|
||||
Data = R extends keyof DB ? DB[R] : EntityData,
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
["entity", entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery,
|
||||
);
|
||||
}
|
||||
|
||||
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Insertable<Input>,
|
||||
input: Omit<Data, "id">,
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Insertable<Input>[],
|
||||
input: Omit<Data, "id">[],
|
||||
) {
|
||||
if (!input || !Array.isArray(input) || input.length === 0) {
|
||||
throw new Error("input is required");
|
||||
}
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Updateable<Input>,
|
||||
input: Partial<Omit<Data, "id">>,
|
||||
) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
|
||||
}
|
||||
|
||||
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
update: Updateable<Input>,
|
||||
update: Partial<Omit<Data, "id">>,
|
||||
) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
|
||||
update,
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
|
||||
}
|
||||
|
||||
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
|
||||
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ export const indicesSchema = tb.Type.Object(
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type TAppDataIndex = Static<(typeof indicesSchema)[number]>;
|
||||
|
||||
export const dataConfigSchema = tb.Type.Object(
|
||||
{
|
||||
|
||||
@@ -280,15 +280,6 @@ export class Entity<
|
||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toTypes() {
|
||||
return {
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
comment: this.config.description,
|
||||
fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])),
|
||||
};
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { AppAuth, AppMedia } from "modules";
|
||||
|
||||
export type TEntityTSType = {
|
||||
name: string;
|
||||
type: TEntityType;
|
||||
comment?: string;
|
||||
fields: Record<string, TFieldTSType>;
|
||||
};
|
||||
|
||||
// [select, insert, update]
|
||||
type TFieldContextType = boolean | [boolean, boolean, boolean];
|
||||
|
||||
export type TFieldTSType = {
|
||||
required?: TFieldContextType;
|
||||
fillable?: TFieldContextType;
|
||||
type: "PrimaryFieldType" | string;
|
||||
comment?: string;
|
||||
import?: {
|
||||
package: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type EntityTypescriptOptions = {
|
||||
indentWidth?: number;
|
||||
indentChar?: string;
|
||||
entityCommentMultiline?: boolean;
|
||||
fieldCommentMultiline?: boolean;
|
||||
};
|
||||
|
||||
// keep a local copy here until properties have a type
|
||||
const systemEntities = {
|
||||
users: AppAuth.usersFields,
|
||||
media: AppMedia.mediaFields,
|
||||
};
|
||||
|
||||
export class EntityTypescript {
|
||||
constructor(
|
||||
protected em: EntityManager,
|
||||
protected _options: EntityTypescriptOptions = {},
|
||||
) {}
|
||||
|
||||
get options() {
|
||||
return {
|
||||
...this._options,
|
||||
indentWidth: 2,
|
||||
indentChar: " ",
|
||||
entityCommentMultiline: true,
|
||||
fieldCommentMultiline: false,
|
||||
};
|
||||
}
|
||||
|
||||
toTypes() {
|
||||
return this.em.entities.map((e) => e.toTypes());
|
||||
}
|
||||
|
||||
protected getTab(count = 1) {
|
||||
return this.options.indentChar.repeat(this.options.indentWidth).repeat(count);
|
||||
}
|
||||
|
||||
collectImports(
|
||||
type: TEntityTSType,
|
||||
imports: Record<string, string[]> = {},
|
||||
): Record<string, string[]> {
|
||||
for (const [, entity_type] of Object.entries(type.fields)) {
|
||||
for (const imp of entity_type.import ?? []) {
|
||||
const name = imp.name;
|
||||
const pkg = imp.package;
|
||||
if (!imports[pkg]) {
|
||||
imports[pkg] = [];
|
||||
}
|
||||
if (!imports[pkg].includes(name)) {
|
||||
imports[pkg].push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
typeName(name: string) {
|
||||
return autoFormatString(name);
|
||||
}
|
||||
|
||||
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
|
||||
let string = "";
|
||||
const coment_multiline = this.options.fieldCommentMultiline;
|
||||
const indent = opts?.indent ?? 1;
|
||||
for (const [field_name, field_type] of Object.entries(type.fields)) {
|
||||
if (opts?.ignore_fields?.includes(field_name)) continue;
|
||||
|
||||
let f = "";
|
||||
f += this.commentString(field_type.comment, indent, coment_multiline);
|
||||
f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `;
|
||||
f += field_type.type + ";";
|
||||
f += "\n";
|
||||
string += f;
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
relationToFieldType(relation: EntityRelation, entity: Entity) {
|
||||
const other = relation.other(entity);
|
||||
const listable = relation.isListableFor(entity);
|
||||
const name = this.typeName(other.entity.name);
|
||||
|
||||
let type = name;
|
||||
if (other.entity.type === "system") {
|
||||
type = `DB["${other.entity.name}"]`;
|
||||
}
|
||||
|
||||
return {
|
||||
fields: {
|
||||
[other.reference]: {
|
||||
required: false,
|
||||
type: `${type}${listable ? "[]" : ""}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
importsToString(imports: Record<string, string[]>) {
|
||||
const strings: string[] = [];
|
||||
for (const [pkg, names] of Object.entries(imports)) {
|
||||
strings.push(`import type { ${names.join(", ")} } from "${pkg}";`);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
commentString(comment?: string, indents = 0, multiline = true) {
|
||||
if (!comment) return "";
|
||||
const indent = this.getTab(indents);
|
||||
if (!multiline) return `${indent}// ${comment}\n`;
|
||||
return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`;
|
||||
}
|
||||
|
||||
entityToTypeString(
|
||||
entity: Entity,
|
||||
opts?: { ignore_fields?: string[]; indent?: number; export?: boolean },
|
||||
) {
|
||||
const type = entity.toTypes();
|
||||
const name = this.typeName(type.name);
|
||||
const indent = opts?.indent ?? 1;
|
||||
const min_indent = Math.max(0, indent - 1);
|
||||
|
||||
let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline);
|
||||
s += `${opts?.export ? "export " : ""}interface ${name} {\n`;
|
||||
s += this.fieldTypesToString(type, opts);
|
||||
|
||||
// add listable relations
|
||||
const relations = this.em.relations.relationsOf(entity);
|
||||
const rel_types = relations.map((r) =>
|
||||
this.relationToFieldType(r, entity),
|
||||
) as TEntityTSType[];
|
||||
for (const rel_type of rel_types) {
|
||||
s += this.fieldTypesToString(rel_type, {
|
||||
indent,
|
||||
});
|
||||
}
|
||||
s += `${this.getTab(min_indent)}}`;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const strings: string[] = [];
|
||||
const tables: Record<string, string> = {};
|
||||
const imports: Record<string, string[]> = {
|
||||
"bknd/core": ["DB"],
|
||||
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
|
||||
};
|
||||
|
||||
// add global types
|
||||
let g = "declare global {\n";
|
||||
g += `${this.getTab(1)}type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;\n`;
|
||||
g += "}";
|
||||
strings.push(g);
|
||||
|
||||
const system_entities = this.em.entities.filter((e) => e.type === "system");
|
||||
|
||||
for (const entity of this.em.entities) {
|
||||
// skip system entities, declare addtional props in the DB interface
|
||||
if (system_entities.includes(entity)) continue;
|
||||
|
||||
const type = entity.toTypes();
|
||||
if (!type) continue;
|
||||
this.collectImports(type, imports);
|
||||
tables[type.name] = this.typeName(type.name);
|
||||
const s = this.entityToTypeString(entity, {
|
||||
export: true,
|
||||
});
|
||||
strings.push(s);
|
||||
}
|
||||
|
||||
// write tables
|
||||
let tables_string = "interface Database {\n";
|
||||
for (const [name, type] of Object.entries(tables)) {
|
||||
tables_string += `${this.getTab(1)}${name}: ${type};\n`;
|
||||
}
|
||||
tables_string += "}";
|
||||
strings.push(tables_string);
|
||||
|
||||
// merge
|
||||
let merge = `declare module "bknd/core" {\n`;
|
||||
for (const systemEntity of system_entities) {
|
||||
const system_fields = Object.keys(systemEntities[systemEntity.name]);
|
||||
const additional_fields = systemEntity.fields
|
||||
.filter((f) => !system_fields.includes(f.name) && f.type !== "primary")
|
||||
.map((f) => f.name);
|
||||
if (additional_fields.length === 0) continue;
|
||||
|
||||
merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, {
|
||||
ignore_fields: ["id", ...system_fields],
|
||||
indent: 2,
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
merge += `${this.getTab(1)}interface DB extends Database {}\n}`;
|
||||
strings.push(merge);
|
||||
|
||||
const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")];
|
||||
return final.join("\n\n");
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,4 @@ export class BooleanField<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "boolean",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "../entities";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import { $console } from "core";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const dateFieldConfigSchema = Type.Composite(
|
||||
@@ -145,11 +144,4 @@ export class DateField<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "Date | string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const enumFieldConfigSchema = Type.Composite(
|
||||
@@ -141,14 +140,4 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const union = this.getOptions().map(({ value }) =>
|
||||
typeof value === "string" ? `"${value}"` : value,
|
||||
);
|
||||
return {
|
||||
...super.toType(),
|
||||
type: union.length > 0 ? union.join(" | ") : "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { EntityManager } from "../entities";
|
||||
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||
import type { FieldSpec } from "data/connection/Connection";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
// @todo: contexts need to be reworked
|
||||
@@ -236,14 +235,6 @@ export abstract class Field<
|
||||
return this.toSchemaWrapIfRequired(Type.Any());
|
||||
}
|
||||
|
||||
toType(): TFieldTSType {
|
||||
return {
|
||||
required: this.isRequired(),
|
||||
comment: this.getDescription(),
|
||||
type: "any",
|
||||
};
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// @todo: current workaround because of fixed string type
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
@@ -99,11 +98,4 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "any",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||
import { Default, FromSchema, objectToJsLiteral, type Static } from "core/utils";
|
||||
import { Default, FromSchema, type Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const jsonSchemaFieldConfigSchema = Type.Composite(
|
||||
@@ -122,12 +121,4 @@ export class JsonSchemaField<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
import: [{ package: "json-schema-to-ts", name: "FromSchema" }],
|
||||
type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const numberFieldConfigSchema = Type.Composite(
|
||||
@@ -103,11 +102,4 @@ export class NumberField<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { config } from "core";
|
||||
import type { Static } from "core/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const primaryFieldConfigSchema = Type.Composite([
|
||||
@@ -49,13 +48,4 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
required: true,
|
||||
import: [{ package: "kysely", name: "Generated" }],
|
||||
type: "Generated<number>",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,11 +121,4 @@ export class TextField<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export {
|
||||
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
export { constructEntity, constructRelation, constructIndex } from "./schema/constructor";
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
|
||||
@@ -11,9 +11,6 @@ import type { RelationType } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const directions = ["source", "target"] as const;
|
||||
export type TDirection = (typeof directions)[number];
|
||||
|
||||
export type KyselyJsonFrom = any;
|
||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
@@ -30,7 +27,7 @@ export abstract class EntityRelation<
|
||||
|
||||
// @todo: add unit tests
|
||||
// allowed directions, used in RelationAccessor for visibility
|
||||
directions: TDirection[] = ["source", "target"];
|
||||
directions: ("source" | "target")[] = ["source", "target"];
|
||||
|
||||
static schema = Type.Object({
|
||||
mappedBy: Type.Optional(Type.String()),
|
||||
@@ -105,10 +102,6 @@ export abstract class EntityRelation<
|
||||
);
|
||||
}
|
||||
|
||||
self(entity: Entity | string): EntityRelationAnchor {
|
||||
return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source;
|
||||
}
|
||||
|
||||
ref(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.source : this.target;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Field, baseFieldConfigSchema } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
@@ -84,11 +83,4 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { Entity, EntityIndex, type Field } from "data";
|
||||
import {
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataField,
|
||||
type TAppDataIndex,
|
||||
type TAppDataRelation,
|
||||
} from "data/data-schema";
|
||||
import { Entity, type Field } from "data";
|
||||
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
||||
|
||||
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
@@ -39,17 +32,3 @@ export function constructRelation(
|
||||
relationConfig.config,
|
||||
);
|
||||
}
|
||||
|
||||
export function constructIndex(
|
||||
indexConfig: TAppDataIndex,
|
||||
resolver: (name: Entity | string) => Entity,
|
||||
name: string,
|
||||
) {
|
||||
const entity = resolver(indexConfig.entity);
|
||||
return new EntityIndex(
|
||||
entity,
|
||||
entity.fields.filter((f) => indexConfig.fields.includes(f.name)),
|
||||
indexConfig.unique,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $console, type AppEntity } from "core";
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
||||
import { Module } from "modules/Module";
|
||||
@@ -17,9 +17,8 @@ import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-sche
|
||||
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
declare module "core" {
|
||||
interface Media extends AppEntity, MediaFieldSchema {}
|
||||
interface DB {
|
||||
media: Media;
|
||||
media: { id: PrimaryFieldType } & MediaFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { DB, PrimaryFieldType } from "core";
|
||||
import { objectTransform } from "core/utils/objects";
|
||||
import { encodeSearch } from "core/utils/reqres";
|
||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import type { EntityData, RepoQueryIn } from "data";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
|
||||
export class UseEntityApiError<Payload = any> extends Error {
|
||||
@@ -24,26 +23,6 @@ export class UseEntityApiError<Payload = any> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface UseEntityReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
Response = ResponseObject<RepositoryResponse<Selectable<Data>>>,
|
||||
> {
|
||||
create: (input: Insertable<Data>) => Promise<Response>;
|
||||
read: (
|
||||
query?: RepoQueryIn,
|
||||
) => Promise<
|
||||
ResponseObject<
|
||||
RepositoryResponse<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>
|
||||
>
|
||||
>;
|
||||
update: Id extends undefined
|
||||
? (input: Updateable<Data>, id: Id) => Promise<Response>
|
||||
: (input: Updateable<Data>) => Promise<Response>;
|
||||
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -51,26 +30,28 @@ export const useEntity = <
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
): UseEntityReturn<Entity, Id, Data> => {
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
|
||||
return {
|
||||
create: async (input: Insertable<Data>) => {
|
||||
const res = await api.createOne(entity, input as any);
|
||||
create: async (input: Omit<Data, "id">) => {
|
||||
const res = await api.createOne(entity, input);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
read: async (query?: RepoQueryIn) => {
|
||||
read: async (query: RepoQueryIn = {}) => {
|
||||
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
// must be manually typed
|
||||
return res as unknown as Id extends undefined
|
||||
? ResponseObject<Data[]>
|
||||
: ResponseObject<Data>;
|
||||
},
|
||||
// @ts-ignore
|
||||
update: async (input: Updateable<Data>, _id: PrimaryFieldType | undefined = id) => {
|
||||
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
|
||||
if (!_id) {
|
||||
throw new Error("id is required");
|
||||
}
|
||||
@@ -78,9 +59,8 @@ export const useEntity = <
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
// @ts-ignore
|
||||
_delete: async (_id: PrimaryFieldType | undefined = id) => {
|
||||
if (!_id) {
|
||||
throw new Error("id is required");
|
||||
@@ -90,7 +70,7 @@ export const useEntity = <
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -111,19 +91,6 @@ export function makeKey(
|
||||
);
|
||||
}
|
||||
|
||||
interface UseEntityQueryReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
|
||||
Return = Id extends undefined ? ResponseObject<Data[]> : ResponseObject<Data>,
|
||||
> extends Omit<SWRResponse<Return>, "mutate">,
|
||||
Omit<ReturnType<typeof useEntity<Entity, Id>>, "read"> {
|
||||
mutate: (id?: PrimaryFieldType) => Promise<any>;
|
||||
mutateRaw: SWRResponse<Return>["mutate"];
|
||||
api: Api["data"];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useEntityQuery = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -132,11 +99,11 @@ export const useEntityQuery = <
|
||||
id?: Id,
|
||||
query?: RepoQueryIn,
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean },
|
||||
): UseEntityQueryReturn<Entity, Id> => {
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
const key = makeKey(api, entity as string, id, query);
|
||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||
const fetcher = () => read(query ?? {});
|
||||
const fetcher = () => read(query);
|
||||
|
||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||
@@ -169,7 +136,6 @@ export const useEntityQuery = <
|
||||
...swr,
|
||||
...mapped,
|
||||
mutate: mutateFn,
|
||||
// @ts-ignore
|
||||
mutateRaw: swr.mutate,
|
||||
api,
|
||||
key,
|
||||
@@ -178,8 +144,8 @@ export const useEntityQuery = <
|
||||
|
||||
export async function mutateEntityCache<
|
||||
Entity extends keyof DB | string,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Selectable<Data>>) {
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
|
||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
|
||||
function update(prev: any, partialNext: any) {
|
||||
if (
|
||||
typeof prev !== "undefined" &&
|
||||
@@ -210,37 +176,28 @@ export async function mutateEntityCache<
|
||||
);
|
||||
}
|
||||
|
||||
interface UseEntityMutateReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
> extends Omit<ReturnType<typeof useEntityQuery<Entity, Id>>, "mutate"> {
|
||||
mutate: Id extends undefined
|
||||
? (id: PrimaryFieldType, data: Partial<Selectable<Data>>) => Promise<void>
|
||||
: (data: Partial<Selectable<Data>>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useEntityMutate = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
options?: SWRConfiguration,
|
||||
): UseEntityMutateReturn<Entity, Id, Data> => {
|
||||
) => {
|
||||
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
|
||||
...options,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const _mutate = id
|
||||
? (data: Partial<Selectable<Data>>) => mutateEntityCache($q.api, entity, id, data)
|
||||
: (id: PrimaryFieldType, data: Partial<Selectable<Data>>) =>
|
||||
mutateEntityCache($q.api, entity, id, data);
|
||||
? (data) => mutateEntityCache($q.api, entity, id, data)
|
||||
: (id, data) => mutateEntityCache($q.api, entity, id, data);
|
||||
|
||||
return {
|
||||
...$q,
|
||||
mutate: _mutate,
|
||||
} as any;
|
||||
mutate: _mutate as unknown as Id extends undefined
|
||||
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
|
||||
: (data: Partial<Data>) => Promise<void>,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@ export function useBkndData() {
|
||||
};
|
||||
const $data = {
|
||||
entity: (name: string) => entities[name],
|
||||
indicesOf: (name: string) => app.indices.filter((i) => i.entity.name === name),
|
||||
modals,
|
||||
system: (name: string) => ({
|
||||
any: entities[name]?.type === "system",
|
||||
@@ -83,7 +82,6 @@ export function useBkndData() {
|
||||
$data,
|
||||
entities,
|
||||
relations: app.relations,
|
||||
indices: app.indices,
|
||||
config: config.data,
|
||||
schema: schema.data,
|
||||
actions,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { App } from "App";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityIndex,
|
||||
type EntityRelation,
|
||||
constructEntity,
|
||||
constructRelation,
|
||||
constructIndex,
|
||||
} from "data";
|
||||
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
import type { BkndAdminOptions } from "ui/client/BkndProvider";
|
||||
@@ -21,7 +14,6 @@ export class AppReduced {
|
||||
// @todo: change to record
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _flows: Flow[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -38,10 +30,6 @@ export class AppReduced {
|
||||
return constructRelation(relation, this.entity.bind(this));
|
||||
});
|
||||
|
||||
this._indices = Object.entries(this.appJson.data.indices ?? {}).map(([name, index]) => {
|
||||
return constructIndex(index, this.entity.bind(this), name);
|
||||
});
|
||||
|
||||
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
|
||||
// @ts-ignore
|
||||
// @todo: fix constructing flow
|
||||
@@ -70,10 +58,6 @@ export class AppReduced {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get indices(): EntityIndex[] {
|
||||
return this._indices;
|
||||
}
|
||||
|
||||
get flows(): Flow[] {
|
||||
return this._flows;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ import {
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export type CanvasProps = Omit<ReactFlowProps, "onNodesChange" | "onEdgesChange"> & {
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
onNodesChange?: (changes: any) => void;
|
||||
onEdgesChange?: (changes: any) => void;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
@@ -35,8 +33,8 @@ export function Canvas({
|
||||
onDropNewEdge,
|
||||
...props
|
||||
}: CanvasProps) {
|
||||
const [nodes, setNodes, _onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, _onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useTheme();
|
||||
|
||||
@@ -44,22 +42,6 @@ export function Canvas({
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPointerPressed, setIsPointerPressed] = useState(false);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes) => {
|
||||
_onNodesChange(changes);
|
||||
props.onNodesChange?.(changes);
|
||||
},
|
||||
[_onNodesChange, props.onNodesChange],
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
_onEdgesChange(changes);
|
||||
props.onEdgesChange?.(changes);
|
||||
},
|
||||
[_onEdgesChange, props.onEdgesChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey) {
|
||||
setIsCommandPressed(true);
|
||||
@@ -191,6 +173,8 @@ export function Canvas({
|
||||
snapToGrid
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodesConnectable={false}
|
||||
/*panOnDrag={isSpacePressed}*/
|
||||
panOnDrag={true}
|
||||
@@ -199,8 +183,6 @@ export function Canvas({
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={!isSpacePressed}
|
||||
{...props}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
>
|
||||
{backgroundStyle === "lines" && (
|
||||
<Background
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DefaultNode({ selected, children, className, ...props }: TDefaul
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative w-80 shadow-lg rounded-lg bg-background",
|
||||
selected && "ring-4 ring-blue-400/50",
|
||||
selected && "outline outline-blue-500/25",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -246,6 +246,7 @@ export const Switch = forwardRef<
|
||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||
)}
|
||||
onCheckedChange={(bool) => {
|
||||
console.log("setting", bool);
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
{...(props as any)}
|
||||
@@ -271,7 +272,7 @@ export const Switch = forwardRef<
|
||||
export const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<"select"> & {
|
||||
options?: { value: string; label: string; disabled?: boolean }[] | (string | number)[];
|
||||
options?: { value: string; label: string }[] | (string | number)[];
|
||||
}
|
||||
>(({ children, options, ...props }, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
@@ -296,7 +297,7 @@ export const Select = forwardRef<
|
||||
return o;
|
||||
})
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -78,7 +78,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
required={schema.minItems > 0}
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface CollapsibleListRootProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const Root = ({ className, ...props }: CollapsibleListRootProps) => (
|
||||
<div className={twMerge("flex flex-col gap-2 max-w-4xl", className)} {...props} />
|
||||
);
|
||||
|
||||
export interface CollapsibleListItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hasError?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background",
|
||||
hasError && "border-error",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface CollapsibleListPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}
|
||||
|
||||
const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge("flex flex-row justify-between p-3 gap-3 items-center", className)}
|
||||
>
|
||||
{left && <div className="flex flex-row items-center p-2 bg-primary/5 rounded">{left}</div>}
|
||||
<div className="font-mono flex-grow flex flex-row gap-3">{children}</div>
|
||||
{right && <div className="flex flex-row gap-4 items-center">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface CollapsibleListDetailProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) =>
|
||||
open && (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CollapsibleList = {
|
||||
Root,
|
||||
Item,
|
||||
Preview,
|
||||
Detail,
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import { use, createContext, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useParams } from "wouter";
|
||||
|
||||
// extract path segment from path, e.g. /auth/strategies/:strategy? -> "strategy"
|
||||
function extractPathSegment(path: string): string {
|
||||
const match = path.match(/:(\w+)\??/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
// get url by replacing path segment with identifier
|
||||
// e.g. /auth/strategies/:strategy? -> /auth/strategies/x
|
||||
function getPath(path: string, identifier?: string) {
|
||||
if (!identifier) {
|
||||
return path.replace(/\/:\w+\??/, "");
|
||||
}
|
||||
return path.replace(/:\w+\??/, identifier);
|
||||
}
|
||||
|
||||
export function useRoutePathState(_path?: string, identifier?: string) {
|
||||
const ctx = useRoutePathContext(_path ?? "");
|
||||
const path = _path ?? ctx?.path ?? "";
|
||||
const segment = extractPathSegment(path);
|
||||
const routeIdentifier = useParams()[segment];
|
||||
const [localActive, setLocalActive] = useState(routeIdentifier === identifier);
|
||||
const active = ctx ? identifier === ctx.activeIdentifier : localActive;
|
||||
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
function toggle(_open?: boolean) {
|
||||
const open = _open ?? !active;
|
||||
|
||||
if (ctx) {
|
||||
ctx.setActiveIdentifier(open ? identifier! : "");
|
||||
}
|
||||
|
||||
if (path) {
|
||||
if (open) {
|
||||
navigate(getPath(path, identifier));
|
||||
} else {
|
||||
navigate(getPath(path));
|
||||
}
|
||||
} else {
|
||||
setLocalActive(open);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx && _path && identifier) {
|
||||
setLocalActive(routeIdentifier === identifier);
|
||||
}
|
||||
}, [routeIdentifier, identifier, _path]);
|
||||
|
||||
return {
|
||||
active,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
||||
type RoutePathStateContextType = {
|
||||
defaultIdentifier?: string;
|
||||
path: string;
|
||||
activeIdentifier: string;
|
||||
setActiveIdentifier: (identifier: string) => void;
|
||||
};
|
||||
const RoutePathStateContext = createContext<RoutePathStateContextType>(undefined!);
|
||||
|
||||
export function RoutePathStateProvider({
|
||||
children,
|
||||
defaultIdentifier,
|
||||
path,
|
||||
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
|
||||
const segment = extractPathSegment(path);
|
||||
const routeIdentifier = useParams()[segment];
|
||||
const [activeIdentifier, setActiveIdentifier] = useState(
|
||||
routeIdentifier ?? defaultIdentifier ?? "",
|
||||
);
|
||||
return (
|
||||
<RoutePathStateContext.Provider
|
||||
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
|
||||
>
|
||||
{children}
|
||||
</RoutePathStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useRoutePathContext(path?: string) {
|
||||
const ctx = use(RoutePathStateContext);
|
||||
if (ctx && (!path || ctx.path === path)) {
|
||||
return ctx;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { useLocation } from "wouter";
|
||||
@@ -377,15 +376,6 @@ export function Scrollable({
|
||||
);
|
||||
}
|
||||
|
||||
type SectionHeaderAccordionItemProps = {
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
title,
|
||||
open,
|
||||
@@ -393,7 +383,14 @@ export const SectionHeaderAccordionItem = ({
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight,
|
||||
}: SectionHeaderAccordionItemProps) => (
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
className={twMerge(
|
||||
@@ -425,19 +422,6 @@ export const SectionHeaderAccordionItem = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||
routePattern,
|
||||
identifier,
|
||||
...props
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||
// it's optional because it could be provided using the context
|
||||
routePattern?: string;
|
||||
identifier: string;
|
||||
}) => {
|
||||
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
||||
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
||||
};
|
||||
|
||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { ButtonLink } from "ui/components/buttons/Button";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { JsonViewer } from "ui";
|
||||
|
||||
export type MediaInfoModalProps = {
|
||||
file: FileState;
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider, useReactFlow } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity, TAppDataField } from "data/data-schema";
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Canvas, type CanvasProps } from "ui/components/canvas/Canvas";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { layoutWithDagre } from "ui/components/canvas/layouts";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { EntityTableNode } from "./EntityTableNode";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { useCallback } from "react";
|
||||
import { mergeObject, transformObject } from "core/utils";
|
||||
|
||||
export interface TCanvasEntityField extends TAppDataField {
|
||||
name: string;
|
||||
indexed?: boolean;
|
||||
}
|
||||
|
||||
export interface TCanvasEntity extends TAppDataEntity {
|
||||
label: string;
|
||||
fields: Record<string, TCanvasEntityField>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function entitiesToNodes(
|
||||
entities: AppDataConfig["entities"],
|
||||
indices?: AppDataConfig["indices"],
|
||||
): Node<TCanvasEntity>[] {
|
||||
const indexed_fields = Object.entries(indices ?? {}).flatMap(([, index]) => index.fields);
|
||||
|
||||
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
|
||||
return Object.entries(entities ?? {}).map(([name, entity]) => {
|
||||
return {
|
||||
id: name,
|
||||
data: {
|
||||
...entity,
|
||||
label: name,
|
||||
fields: transformObject(entity.fields ?? {}, (f, name) => ({
|
||||
...f,
|
||||
name,
|
||||
indexed: indexed_fields.includes(name),
|
||||
})),
|
||||
},
|
||||
data: { label: name, ...entity },
|
||||
type: "entity",
|
||||
dragHandle: ".drag-handle",
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -91,29 +65,24 @@ const nodeTypes = {
|
||||
entity: EntityTableNode.Component,
|
||||
} as const;
|
||||
|
||||
const getEdgeStyle = (theme: string) => ({
|
||||
stroke: theme === "light" ? "#ccc" : "#666",
|
||||
});
|
||||
|
||||
const getMarkerEndStyle = (theme: string) => ({
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777",
|
||||
});
|
||||
|
||||
export function DataSchemaCanvas() {
|
||||
const {
|
||||
config: { data },
|
||||
} = useBknd();
|
||||
const { theme } = useTheme();
|
||||
const nodes = entitiesToNodes(data.entities, data.indices);
|
||||
console.log(nodes);
|
||||
const nodes = entitiesToNodes(data.entities);
|
||||
const edges = relationsToEdges(data.relations).map((e) => ({
|
||||
...e,
|
||||
style: getEdgeStyle(theme),
|
||||
style: {
|
||||
stroke: theme === "light" ? "#ccc" : "#666",
|
||||
},
|
||||
type: "smoothstep",
|
||||
markerEnd: getMarkerEndStyle(theme),
|
||||
markerEnd: {
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777",
|
||||
},
|
||||
}));
|
||||
|
||||
const nodeLayout = layoutWithDagre({
|
||||
@@ -138,7 +107,7 @@ export function DataSchemaCanvas() {
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ActualCanvas
|
||||
<Canvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -150,49 +119,7 @@ export function DataSchemaCanvas() {
|
||||
}}
|
||||
>
|
||||
<Panels zoom minimap />
|
||||
</ActualCanvas>
|
||||
</Canvas>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function toRecord(nodes: Node<TAppDataEntity>[]): Record<string, Node<TAppDataEntity>> {
|
||||
return Object.fromEntries(nodes.map((n) => [n.id, n]));
|
||||
}
|
||||
|
||||
function ActualCanvas(props: CanvasProps) {
|
||||
const flow = useReactFlow();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const onNodesChange = useCallback((changes: any) => {
|
||||
const nodes = mergeObject<Record<string, Node<TAppDataEntity>>>(
|
||||
toRecord(flow.getNodes()),
|
||||
toRecord(changes),
|
||||
);
|
||||
const selected = Object.values(nodes).filter((n) => n.selected);
|
||||
const selected_names = selected.map((n) => n.id);
|
||||
flow.setEdges((edges) =>
|
||||
edges.map((edge) => {
|
||||
if (selected_names.includes(edge.source) || selected_names.includes(edge.target)) {
|
||||
return {
|
||||
...edge,
|
||||
animated: true,
|
||||
style: { stroke: "#6495c6" },
|
||||
markerEnd: {
|
||||
...getMarkerEndStyle(theme),
|
||||
color: "#6495c6",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
style: getEdgeStyle(theme),
|
||||
animated: false,
|
||||
markerEnd: getMarkerEndStyle(theme),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return <Canvas {...props} onNodesChange={onNodesChange as any} />;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import type { TAppDataEntity } from "data/data-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbBolt, TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { useState } from "react";
|
||||
import { TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import type { TCanvasEntity, TCanvasEntityField } from "./DataSchemaCanvas";
|
||||
|
||||
export type TableProps = {
|
||||
name: string;
|
||||
type?: string;
|
||||
fields: TCanvasEntityField[];
|
||||
fields: TableField[];
|
||||
};
|
||||
export type TableField = {
|
||||
name: string;
|
||||
type: string;
|
||||
primary?: boolean;
|
||||
foreign?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
|
||||
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { data } = props;
|
||||
const fields = props.data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
@@ -27,11 +32,10 @@ function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
|
||||
{Object.entries(fields).map(([name, field], index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
field={field}
|
||||
field={{ name, ...field }}
|
||||
table={data.label}
|
||||
index={index}
|
||||
last={field_count === index + 1}
|
||||
selected={props.selected && ["relation", "primary"].includes(field.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -49,16 +53,13 @@ const TableRow = ({
|
||||
index,
|
||||
onHover,
|
||||
last,
|
||||
selected,
|
||||
}: {
|
||||
field: TCanvasEntityField;
|
||||
field: TableField;
|
||||
table: string;
|
||||
index: number;
|
||||
last?: boolean;
|
||||
selected?: boolean;
|
||||
onHover?: (hovered: boolean) => void;
|
||||
}) => {
|
||||
const [navigate] = useNavigate();
|
||||
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
|
||||
const handles = true;
|
||||
const handleId = `${table}:${field.name}`;
|
||||
@@ -66,12 +67,10 @@ const TableRow = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-pointer",
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto",
|
||||
last && "rounded-bl-lg rounded-br-lg",
|
||||
selected && "bg-primary/5",
|
||||
"hover:bg-primary/5",
|
||||
)}
|
||||
onClick={() => navigate(`/entity/${table}/fields/${field.name}`)}
|
||||
>
|
||||
{handles && (
|
||||
<Handle
|
||||
@@ -87,12 +86,7 @@ const TableRow = ({
|
||||
{field.type === "primary" && <TbKey className="text-yellow-700" />}
|
||||
{field.type === "relation" && <TbDiamonds className="text-sky-700" />}
|
||||
</div>
|
||||
<div className="flex flex-grow items-center gap-1">
|
||||
<span className={field.config?.required ? "font-bold" : "opacity-90"}>
|
||||
{field.name}
|
||||
</span>{" "}
|
||||
{field.indexed && <TbBolt className="text-warning-foreground/50" />}
|
||||
</div>
|
||||
<div className="flex flex-grow">{field.name}</div>
|
||||
<div className="flex opacity-60">{field.type}</div>
|
||||
|
||||
{handles && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isDebug } from "core";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { type ChangeEvent, useState } from "react";
|
||||
import {
|
||||
TbAt,
|
||||
TbBrandAppleFilled,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TbBrandX,
|
||||
TbSettings,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
@@ -32,8 +33,6 @@ import {
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { CollapsibleList } from "ui/components/list/CollapsibleList";
|
||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||
|
||||
export function AuthStrategiesList(props) {
|
||||
useBrowserTitle(["Auth", "Strategies"]);
|
||||
@@ -105,7 +104,7 @@ function AuthStrategiesListInternal() {
|
||||
<p className="opacity-70">
|
||||
Allow users to sign in or sign up using different strategies.
|
||||
</p>
|
||||
<CollapsibleList.Root>
|
||||
<div className="flex flex-col gap-2 max-w-4xl">
|
||||
<Strategy type="password" name="password" />
|
||||
<Strategy type="oauth" name="google" />
|
||||
<Strategy type="oauth" name="github" />
|
||||
@@ -114,7 +113,7 @@ function AuthStrategiesListInternal() {
|
||||
<Strategy type="oauth" name="instagram" unavailable />
|
||||
<Strategy type="oauth" name="apple" unavailable />
|
||||
<Strategy type="oauth" name="discord" unavailable />
|
||||
</CollapsibleList.Root>
|
||||
</div>
|
||||
</div>
|
||||
<FormDebug />
|
||||
</AppShell.Scrollable>
|
||||
@@ -139,40 +138,47 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
|
||||
]),
|
||||
);
|
||||
const schema = schemas[type];
|
||||
|
||||
const { active, toggle } = useRoutePathState("/strategies/:strategy?", name);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!schema) return null;
|
||||
|
||||
return (
|
||||
<FormContextOverride schema={schema} prefix={name}>
|
||||
<CollapsibleList.Item
|
||||
hasError={errors.length > 0}
|
||||
className={
|
||||
unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined
|
||||
}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background",
|
||||
unavailable && "opacity-20 pointer-events-none cursor-not-allowed",
|
||||
errors.length > 0 && "border-red-500",
|
||||
)}
|
||||
>
|
||||
<CollapsibleList.Preview
|
||||
left={<StrategyIcon type={type} provider={name} />}
|
||||
right={
|
||||
<>
|
||||
<StrategyToggle type={type} />
|
||||
<IconButton
|
||||
Icon={TbSettings}
|
||||
size="lg"
|
||||
iconProps={{ strokeWidth: 1.5 }}
|
||||
variant={active ? "primary" : "ghost"}
|
||||
onClick={() => toggle(!active)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className="leading-none">{autoFormatString(name)}</span>
|
||||
</CollapsibleList.Preview>
|
||||
<CollapsibleList.Detail open={active}>
|
||||
<StrategyForm type={type} name={name} />
|
||||
</CollapsibleList.Detail>
|
||||
</CollapsibleList.Item>
|
||||
<div className="flex flex-row justify-between p-3 gap-3 items-center">
|
||||
<div className="flex flex-row items-center p-2 bg-primary/5 rounded">
|
||||
<StrategyIcon type={type} provider={name} />
|
||||
</div>
|
||||
<div className="font-mono flex-grow flex flex-row gap-3">
|
||||
<span className="leading-none">{autoFormatString(name)}</span>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<StrategyToggle type={type} />
|
||||
<IconButton
|
||||
Icon={TbSettings}
|
||||
size="lg"
|
||||
iconProps={{ strokeWidth: 1.5 }}
|
||||
variant={open ? "primary" : "ghost"}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
|
||||
)}
|
||||
>
|
||||
<StrategyForm type={type} name={name} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function AuthRoutes() {
|
||||
<Route path="/users" component={AuthUsersList} />
|
||||
<Route path="/roles" component={AuthRolesList} />
|
||||
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
|
||||
<Route path="/strategies/:strategy?" component={AuthStrategiesList} />
|
||||
<Route path="/strategies" component={AuthStrategiesList} />
|
||||
<Route path="/settings" component={AuthSettings} />
|
||||
</AuthRoot>
|
||||
);
|
||||
|
||||
@@ -83,6 +83,10 @@ export function DataRoot({ children }) {
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable initialOffset={96}>
|
||||
<div className="flex flex-col flex-grow py-3 gap-3">
|
||||
{/*<div className="pt-3 px-3">
|
||||
<SearchInput placeholder="Search entities" />
|
||||
</div>*/}
|
||||
|
||||
<EntityLinkList entities={entityList.regular} context={context} suggestCreate />
|
||||
<EntityLinkList entities={entityList.system} context={context} title="System" />
|
||||
<EntityLinkList
|
||||
|
||||
@@ -52,6 +52,7 @@ export function DataEntityUpdate({ params }) {
|
||||
}
|
||||
|
||||
async function onSubmitted(changeSet?: EntityData) {
|
||||
console.log("update:changeSet", changeSet);
|
||||
//return;
|
||||
if (!changeSet) {
|
||||
goBack();
|
||||
|
||||
@@ -30,12 +30,14 @@ import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||
import { EntityIndicesForm } from "./forms/entity.indices.form";
|
||||
import type { TAppDataIndex } from "data/data-schema";
|
||||
|
||||
export function DataSchemaEntity({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
const [value, setValue] = useState("fields");
|
||||
|
||||
function toggle(value) {
|
||||
return () => setValue(value);
|
||||
}
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
const entity = $data.entity(params.entity as string)!;
|
||||
@@ -45,103 +47,115 @@ export function DataSchemaEntity({ params }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoutePathStateProvider
|
||||
path={`/entity/${entity.name}/:setting?`}
|
||||
defaultIdentifier="fields"
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Data",
|
||||
onClick: () =>
|
||||
navigate(routes.data.root() + routes.data.entity.list(entity.name), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Data",
|
||||
onClick: () =>
|
||||
navigate(
|
||||
routes.data.root() + routes.data.entity.list(entity.name),
|
||||
{
|
||||
absolute: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<Link to="/" className="hidden md:inline">
|
||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-col h-full" key={entity.name}>
|
||||
<Fields entity={entity} />
|
||||
|
||||
<BasicSettings entity={entity} />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="relations"
|
||||
title="Relations"
|
||||
ActiveIcon={IconCirclesRelation}
|
||||
>
|
||||
<Empty
|
||||
title="Relations"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "relations"]), {
|
||||
absolute: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
<Indices entity={entity} />
|
||||
<div className="flex flex-row gap-4">
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<Link to="/" className="hidden md:inline">
|
||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</RoutePathStateProvider>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-col h-full" key={entity.name}>
|
||||
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
|
||||
|
||||
<BasicSettings entity={entity} open={value === "2"} toggle={toggle("2")} />
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Relations"
|
||||
open={value === "3"}
|
||||
toggle={toggle("3")}
|
||||
ActiveIcon={IconCirclesRelation}
|
||||
>
|
||||
<Empty
|
||||
title="Relations"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
|
||||
}}
|
||||
/>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Indices"
|
||||
open={value === "4"}
|
||||
toggle={toggle("4")}
|
||||
ActiveIcon={IconBolt}
|
||||
>
|
||||
<Empty
|
||||
title="Indices"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "indices"]), {
|
||||
absolute: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Fields = ({ entity }: { entity: Entity }) => {
|
||||
const Fields = ({
|
||||
entity,
|
||||
open,
|
||||
toggle,
|
||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updates, setUpdates] = useState(0);
|
||||
const { actions, $data } = useBkndData();
|
||||
@@ -160,9 +174,10 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
|
||||
|
||||
return (
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="fields"
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Fields"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
ActiveIcon={IconAlignJustified}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
@@ -177,7 +192,6 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||
)}
|
||||
<EntityFieldsForm
|
||||
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
||||
fields={initialFields}
|
||||
ref={ref}
|
||||
key={String(updates)}
|
||||
@@ -223,11 +237,15 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
const BasicSettings = ({
|
||||
entity,
|
||||
open,
|
||||
toggle,
|
||||
}: { entity: Entity; open: boolean; toggle: () => void }) => {
|
||||
const d = useBkndData();
|
||||
const config = d.entities?.[entity.name]?.config;
|
||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||
@@ -253,9 +271,10 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="settings"
|
||||
<AppShell.SectionHeaderAccordionItem
|
||||
title="Settings"
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
ActiveIcon={IconSettings}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
@@ -274,40 +293,6 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
/>
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Indices = ({ entity }: { entity: Entity }) => {
|
||||
const [navigate] = useNavigate();
|
||||
const [data, setData] = useState<Record<string, TAppDataIndex>>({});
|
||||
const d = useBkndData();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
async function handleUpdate() {
|
||||
console.log("update", data);
|
||||
return;
|
||||
/*if (submitting) return;
|
||||
setSubmitting(true);
|
||||
await d.actions.entity.patch(entity.name).indices.set(data);
|
||||
setSubmitting(false);*/
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="indices"
|
||||
title="Indices"
|
||||
ActiveIcon={IconBolt}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
<Button variant="primary" disabled={!open || submitting} onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
|
||||
<EntityIndicesForm entity={entity} onChange={setData} />
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
</AppShell.SectionHeaderAccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ import { Popover } from "ui/components/overlay/Popover";
|
||||
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||
@@ -64,7 +63,6 @@ export type EntityFieldsFormProps = {
|
||||
onChange?: (formData: TAppDataEntityFields) => void;
|
||||
sortable?: boolean;
|
||||
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
||||
routePattern?: string;
|
||||
};
|
||||
|
||||
export type EntityFieldsFormRef = {
|
||||
@@ -76,10 +74,7 @@ export type EntityFieldsFormRef = {
|
||||
};
|
||||
|
||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||
function EntityFieldsForm(
|
||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
|
||||
ref,
|
||||
) {
|
||||
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
|
||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||
name,
|
||||
field,
|
||||
@@ -171,7 +166,6 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
dnd={dnd}
|
||||
routePattern={routePattern}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -185,7 +179,6 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
form={formProps}
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
routePattern={routePattern}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -280,7 +273,6 @@ function EntityField({
|
||||
remove,
|
||||
errors,
|
||||
dnd,
|
||||
routePattern,
|
||||
}: {
|
||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||
index: number;
|
||||
@@ -291,12 +283,11 @@ function EntityField({
|
||||
remove: (index: number) => void;
|
||||
errors: any;
|
||||
dnd?: SortableItemProps;
|
||||
routePattern?: string;
|
||||
}) {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
const prefix = `fields.${index}.field` as const;
|
||||
const type = field.field.type;
|
||||
const name = watch(`fields.${index}.name`);
|
||||
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
|
||||
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
|
||||
const specificData = omit(field.field.config, commonProps);
|
||||
const disabled = fieldSpec.disabled || [];
|
||||
@@ -309,11 +300,9 @@ function EntityField({
|
||||
return () => {
|
||||
if (name.length === 0) {
|
||||
remove(index);
|
||||
toggle();
|
||||
} else if (window.confirm(`Sure to delete "${name}"?`)) {
|
||||
remove(index);
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
window.confirm(`Sure to delete "${name}"?`) && remove(index);
|
||||
};
|
||||
}
|
||||
//console.log("register", register(`${prefix}.config.required`));
|
||||
@@ -324,7 +313,7 @@ function EntityField({
|
||||
key={field.id}
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background mb-2",
|
||||
active && "mb-6",
|
||||
opened && "mb-6",
|
||||
hasErrors && "border-red-500 ",
|
||||
)}
|
||||
{...dndProps}
|
||||
@@ -382,13 +371,13 @@ function EntityField({
|
||||
Icon={TbSettings}
|
||||
disabled={is_primary}
|
||||
iconProps={{ strokeWidth: 1.5 }}
|
||||
onClick={() => toggle()}
|
||||
variant={active ? "primary" : "ghost"}
|
||||
onClick={handlers.toggle}
|
||||
variant={opened ? "primary" : "ghost"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
{opened && (
|
||||
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
|
||||
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
|
||||
<Tabs defaultValue="general">
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { TbBolt, TbTrash } from "react-icons/tb";
|
||||
import type { Entity } from "data/entities";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { CollapsibleList } from "ui/components/list/CollapsibleList";
|
||||
import { EntityIndex } from "data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import type { TAppDataIndex } from "data/data-schema";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
export interface EntityIndicesFormProps {
|
||||
entity: Entity;
|
||||
onChange?: (indices: Record<string, TAppDataIndex>) => void;
|
||||
}
|
||||
|
||||
export function EntityIndicesForm({ entity, onChange }: EntityIndicesFormProps) {
|
||||
const { $data } = useBkndData();
|
||||
const [indices, setIndices] = useState<EntityIndex[]>($data.indicesOf(entity.name));
|
||||
const [create, setCreate] = useState<TAppDataIndex>({
|
||||
entity: entity.name,
|
||||
fields: [],
|
||||
unique: false,
|
||||
});
|
||||
const indexed_fields = indices.flatMap((i) => i.fields).map((f) => f.name);
|
||||
const required_fields = indices
|
||||
.flatMap((i) => i.fields)
|
||||
.filter((f) => f.isRequired())
|
||||
.map((f) => f.name);
|
||||
|
||||
const fields = entity.fields.filter(
|
||||
(f) => !["primary", "relation", "media"].includes(f.type) && !indexed_fields.includes(f.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.(Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])));
|
||||
}, [indices]);
|
||||
|
||||
function handleAdd() {
|
||||
setIndices((prev) => [
|
||||
...prev,
|
||||
new EntityIndex(
|
||||
entity,
|
||||
create.fields.map((f) => entity.fields.find((f2) => f2.name === f)!),
|
||||
create.unique,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
//console.log("indices", { indices, schema, config });
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{indices.map((index) => (
|
||||
<CollapsibleList.Item key={index.name} title={index.name}>
|
||||
<CollapsibleList.Preview
|
||||
left={<TbBolt />}
|
||||
right={<IconButton size="lg" Icon={TbTrash} />}
|
||||
>
|
||||
<span>{index.fields.map((f) => f.name).join(", ")}</span>
|
||||
<span className="opacity-50">{index.name}</span>
|
||||
</CollapsibleList.Preview>
|
||||
</CollapsibleList.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row gap-7 items-center">
|
||||
<span className="font-bold">Add Index</span>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Formy.Label className="opacity-70">Unique</Formy.Label>
|
||||
<Formy.Switch
|
||||
checked={create.unique}
|
||||
size="sm"
|
||||
onCheckedChange={(checked) => {
|
||||
setCreate((prev) => ({
|
||||
...prev,
|
||||
unique: checked,
|
||||
fields: prev.fields.some((f) => required_fields.includes(f))
|
||||
? prev.fields
|
||||
: [],
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
<Formy.Label className="opacity-70">Field</Formy.Label>
|
||||
<div className="min-w-0">
|
||||
<Formy.Select
|
||||
className="h-9 py-1.5 pl-3 pr-8"
|
||||
options={fields.map((f) => ({
|
||||
label: f.getLabel()!,
|
||||
value: f.name,
|
||||
disabled: create.unique && !required_fields.includes(f.name),
|
||||
}))}
|
||||
value={create.fields[0]}
|
||||
onChange={(e) => {
|
||||
setCreate((prev) => ({ ...prev, fields: [e.target.value] }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<Button variant="primary" disabled={create.fields.length === 0} onClick={handleAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<JsonViewer
|
||||
json={{
|
||||
create,
|
||||
data: Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])),
|
||||
}}
|
||||
expand={9}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default function DataRoutes() {
|
||||
|
||||
<Route path="/schema" nest>
|
||||
<Route path="/" component={DataSchemaIndex} />
|
||||
<Route path="/entity/:entity/:setting?/:sub?" component={DataSchemaEntity} />
|
||||
<Route path="/entity/:entity" component={DataSchemaEntity} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</DataRoot>
|
||||
|
||||
@@ -45,40 +45,8 @@ function QueryMutateDataApi() {
|
||||
);
|
||||
}
|
||||
|
||||
function QueryMutateDataApi2() {
|
||||
const { mutate } = useEntityMutate("users");
|
||||
const { data, ...r } = useEntityQuery("users", undefined, {
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
bla
|
||||
<pre>{JSON.stringify(r.key)}</pre>
|
||||
{r.error && <div>failed to load</div>}
|
||||
{r.isLoading && <div>loading...</div>}
|
||||
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||
{data && (
|
||||
<div>
|
||||
{data.map((user) => (
|
||||
<input
|
||||
key={String(user.id)}
|
||||
type="text"
|
||||
value={user.email}
|
||||
onChange={async (e) => {
|
||||
await mutate(user.id, { email: e.target.value });
|
||||
}}
|
||||
className="border border-black"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryDataApi() {
|
||||
const { data, update, ...r } = useEntityQuery("users", undefined, {
|
||||
const { data, update, ...r } = useEntityQuery("comments", undefined, {
|
||||
sort: { by: "id", dir: "asc" },
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
+1378
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ title: 'Introduction'
|
||||
description: 'Integrate bknd into your runtime/framework of choice'
|
||||
---
|
||||
|
||||
import { cloudflare, nextjs, rr, astro, bun, node, docker, vite, aws } from "/snippets/integration-icons.mdx"
|
||||
import { cloudflare, nextjs, reactRouter, astro, bun, node, docker, vite, aws } from "/snippets/integration-icons.mdx"
|
||||
|
||||
## Start with a Framework
|
||||
bknd seamlessly integrates with popular frameworks, allowing you to use what you're already familar with. The following guides will help you get started with your framework of choice.
|
||||
@@ -16,7 +16,7 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
|
||||
/>
|
||||
<Card
|
||||
title="React Router"
|
||||
icon={<div className="text-primary-light">{rr}</div>}
|
||||
icon={<div className="text-primary-light">{reactRouter}</div>}
|
||||
href="/integration/react-router"
|
||||
/>
|
||||
<Card
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Introduction
|
||||
---
|
||||
|
||||
import { cloudflare, nextjs, rr, astro, bun, node, docker, vite, aws, d1, libsql, sqlite, postgres, turso } from "/snippets/integration-icons.mdx"
|
||||
import { cloudflare, nextjs, reactRouter, astro, bun, node, docker, vite, aws, d1, libsql, sqlite, postgres, turso } from "/snippets/integration-icons.mdx"
|
||||
import { Stackblitz, examples } from "/snippets/stackblitz.mdx"
|
||||
|
||||
Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature-rich backend that runs in any JavaScript environment.
|
||||
@@ -44,7 +44,7 @@ in the future, so stay tuned!
|
||||
/>
|
||||
<Card
|
||||
title="React Router"
|
||||
icon={<div className="text-primary-light">{rr}</div>}
|
||||
icon={<div className="text-primary-light">{reactRouter}</div>}
|
||||
href="/integration/react-router"
|
||||
/>
|
||||
<Card
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"navigation": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": ["start", "motivation"]
|
||||
"pages": ["introduction", "motivation"]
|
||||
},
|
||||
{
|
||||
"group": "Usage",
|
||||
|
||||
Generated
-10855
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "bknd-docs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "mintlify dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mintlify": "4.0.510"
|
||||
}
|
||||
}
|
||||
"name": "bknd-docs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "mintlify dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mintlify": "^4.0.285"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export const nextjs = <svg xmlns="http://www.w3.org/2000/svg" width="28px" heigh
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 15V9l7.745 10.65A9 9 0 1 1 19 17.657M15 12V9"/></svg>
|
||||
|
||||
export const rr = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 256 140"><path d="M78.066 92.588c12.818 0 23.209-10.391 23.209-23.21c0-12.817-10.391-23.208-23.21-23.208c-12.817 0-23.208 10.39-23.208 23.209s10.391 23.209 23.209 23.209m-54.857 46.417c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.391-23.208-23.21-23.208C10.392 92.588 0 102.978 0 115.796s10.39 23.21 23.209 23.21m209.582 0c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.39-23.208-23.209-23.208s-23.209 10.39-23.209 23.208s10.391 23.21 23.21 23.21"/><path fill="currentColor" d="M156.565 70.357c-.742-7.754-1.12-14.208-7.06-18.744c-7.522-5.744-16.044-2.017-26.54-5.806C112.65 43.312 105 34.155 105 23.24C105 10.405 115.578 0 128.626 0c9.665 0 17.974 5.707 21.634 13.883c5.601 10.64 1.96 21.467 8.998 26.921c8.333 6.458 19.568 1.729 32.104 7.848a23.6 23.6 0 0 1 9.84 8.425A22.86 22.86 0 0 1 205 69.718c0 10.915-7.65 20.073-17.964 22.568c-10.497 3.789-19.019.062-26.541 5.806c-8.46 6.46-3.931 17.
|
||||
export const reactRouter = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 256 140"><path d="M78.066 92.588c12.818 0 23.209-10.391 23.209-23.21c0-12.817-10.391-23.208-23.21-23.208c-12.817 0-23.208 10.39-23.208 23.209s10.391 23.209 23.209 23.209m-54.857 46.417c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.391-23.208-23.21-23.208C10.392 92.588 0 102.978 0 115.796s10.39 23.21 23.209 23.21m209.582 0c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.39-23.208-23.209-23.208s-23.209 10.39-23.209 23.208s10.391 23.21 23.21 23.21"/><path fill="currentColor" d="M156.565 70.357c-.742-7.754-1.12-14.208-7.06-18.744c-7.522-5.744-16.044-2.017-26.54-5.806C112.65 43.312 105 34.155 105 23.24C105 10.405 115.578 0 128.626 0c9.665 0 17.974 5.707 21.634 13.883c5.601 10.64 1.96 21.467 8.998 26.921c8.333 6.458 19.568 1.729 32.104 7.848a23.6 23.6 0 0 1 9.84 8.425A22.86 22.86 0 0 1 205 69.718c0 10.915-7.65 20.073-17.964 22.568c-10.497 3.789-19.019.062-26.541 5.806c-8.46 6.46-3.931 17.
|
||||
267-10.826 28.682c-3.913 7.518-11.867 12.663-21.043 12.663c-13.048 0-23.626-10.405-23.626-23.24c0-9.323 5.582-17.364 13.638-21.066c12.536-6.12 23.77-1.39 32.104-7.848c4.807-3.726 5.823-9.473 5.823-16.926"/></svg>;
|
||||
|
||||
export const astro = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user