Merge remote-tracking branch 'origin/release/0.6' into refactor/optimize-ui-bundle-size

# Conflicts:
#	app/build.ts
#	app/package.json
This commit is contained in:
dswbx
2025-01-18 14:13:34 +01:00
177 changed files with 3364 additions and 1616 deletions

View File

@@ -128,15 +128,17 @@ export class Api {
};
}
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth();
}
async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth();
return this.getAuthState();
}
async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try {
const res = await this.auth.me();
if (!res.ok || !res.body.user) {

View File

@@ -1,8 +1,5 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { auth } from "auth/middlewares";
import { config } from "core";
import { Event } from "core/events";
import { patternMatch } from "core/utils";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import {
type InitialModuleConfigs,

View File

@@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, type App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
export type NextjsBkndConfig = FrameworkBkndConfig;
export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[];
};
type GetServerSidePropsContext = {
req: IncomingMessage;
@@ -32,10 +34,13 @@ export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api
};
}
function getCleanRequest(req: Request) {
// clean search params from "route" attribute
function getCleanRequest(
req: Request,
{ cleanSearch = ["route"] }: Pick<NextjsBkndConfig, "cleanSearch">
) {
const url = new URL(req.url);
url.searchParams.delete("route");
cleanSearch?.forEach((k) => url.searchParams.delete(k));
return new Request(url.toString(), {
method: req.method,
headers: req.headers,
@@ -44,12 +49,12 @@ function getCleanRequest(req: Request) {
}
let app: App;
export function serve(config: NextjsBkndConfig = {}) {
export function serve({ cleanSearch, ...config }: NextjsBkndConfig = {}) {
return async (req: Request) => {
if (!app) {
app = await createFrameworkApp(config);
}
const request = getCleanRequest(req);
const request = getCleanRequest(req, { cleanSearch });
return app.fetch(request, process.env);
};
}

View File

@@ -1,11 +1,16 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy
} from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { auth } from "auth/middlewares";
import { type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
import type { Hono } from "hono";
import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import { pick } from "lodash-es";
import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
@@ -79,8 +84,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
super.setBuilt();
this._controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
}
get controller(): AuthController {
@@ -219,10 +224,23 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}
private toggleStrategyValueVisibility(visible: boolean) {
const field = this.getUsersEntity().field("strategy_value")!;
const toggle = (name: string, visible: boolean) => {
const field = this.getUsersEntity().field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
field.config.hidden = !visible;
field.config.fillable = visible;
// @todo: think about a PasswordField that automatically hashes on save?
}
@@ -237,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
static usersFields = {
email: text().required(),
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
strategy: text({
fillable: ["create"],
hidden: ["update", "form"]
}).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"]
@@ -260,14 +281,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
try {
const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles }));
users.__replaceField("role", field);
this.replaceEntityField(users, "role", enumm({ enum: roles }));
} catch (e) {}
try {
const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies }));
users.__replaceField("strategy", field);
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
} catch (e) {}
}

View File

@@ -1,3 +1,4 @@
import type { AuthActionResponse } from "auth/api/AuthController";
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
@@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
};
}
async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input);
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input);
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input);
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input);
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async actionSchema(strategy: string, action: string) {
return this.get<Strategy>([strategy, "actions", action, "schema.json"]);
}
async action(strategy: string, action: string, input: any) {
return this.post<AuthActionResponse>([strategy, "actions", action], input);
}
/**
* @deprecated use login("password", ...) instead
* @param input
*/
async loginWithPassword(input: any) {
return this.login("password", input);
}
/**
* @deprecated use register("password", ...) instead
* @param input
*/
async registerWithPassword(input: any) {
return this.register("password", input);
}
me() {
return this.get<{ user: SafeUser | null }>(["me"]);
}

View File

@@ -1,5 +1,16 @@
import type { AppAuth } from "auth";
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller } from "modules/Controller";
import type { ServerEnv } from "modules/Module";
export type AuthActionResponse = {
success: boolean;
action: string;
data?: SafeUser;
errors?: any;
};
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
@@ -10,6 +21,68 @@ export class AuthController extends Controller {
return this.auth.ctx.guard;
}
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.();
if (!actions) {
return;
}
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const name = strategy.getName();
const { create, change } = actions;
const em = this.auth.em;
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
const valid = parse(create.schema, body, {
skipMark: true
});
const processed = (await create.preprocess?.(valid)) ?? valid;
// @todo: check processed for "role" and check permissions
const mutator = em.mutator(this.auth.config.entity_name as "users");
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
...processed,
strategy: name
});
mutator.__unstable_toggleSystemEntityCreation(true);
return c.json({
success: true,
action: "create",
strategy: name,
data: created as unknown as SafeUser
} as AuthActionResponse);
} catch (e) {
if (e instanceof TypeInvalidError) {
return c.json(
{
success: false,
errors: e.errors
},
400
);
}
throw e;
}
}
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
}
mainHono.route(`/${name}/actions`, hono);
}
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
@@ -18,11 +91,12 @@ export class AuthController extends Controller {
for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() });
return c.json({ user: this.auth.authenticator.getUser() });
}
return c.json({ user: null }, 403);

View File

@@ -0,0 +1,4 @@
import { Permission } from "core";
export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update");

View File

@@ -1,6 +1,14 @@
import { Exception } from "core";
import { type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils";
import {
type Static,
StringEnum,
type TObject,
Type,
parse,
runtimeSupports,
transformObject
} from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
@@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module";
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
@@ -17,6 +33,7 @@ export interface Strategy {
getMode: () => "form" | "external";
getName: () => string;
toJSON: (secrets?: boolean) => any;
getActions?: () => StrategyActions;
}
export type User = {
@@ -274,6 +291,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return c.req.header("Content-Type") === "application/json";
}
async getBody(c: Context) {
if (this.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
private getSuccessPath(c: Context) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
@@ -338,3 +363,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
};
}
}
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>
) {
return {
schema,
preprocess
} as StrategyAction<S>;
}

View File

@@ -2,6 +2,7 @@ import type { Authenticator, Strategy } from "auth";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { type Context, Hono } from "hono";
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
@@ -54,17 +55,9 @@ export class PasswordStrategy implements Strategy {
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
async function getBody(c: Context) {
if (authenticator.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
return hono
.post("/login", async (c) => {
const body = await getBody(c);
const body = await authenticator.getBody(c);
try {
const payload = await this.login(body);
@@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy {
}
})
.post("/register", async (c) => {
const body = await getBody(c);
const body = await authenticator.getBody(c);
const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload);
@@ -85,6 +78,27 @@ export class PasswordStrategy implements Strategy {
});
}
getActions(): StrategyActions {
return {
create: createStrategyAction(
Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
}),
async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password)
};
}
)
};
}
getSchema() {
return schema;
}

View File

@@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth";
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
export { Role } from "./authorize/Role";
export * as AuthPermissions from "./auth-permissions";

View File

@@ -26,25 +26,28 @@ export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
createMiddleware<ServerEnv>(async (c, next) => {
// make sure to only register once
if (c.get("auth_registered")) {
throw new Error(`auth middleware already registered for ${getPath(c)}`);
}
c.set("auth_registered", true);
const app = c.get("app");
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
const guard = app?.modules.ctx().guard;
const authenticator = app?.module.auth.authenticator;
if (!skipped) {
const resolved = c.get("auth_resolved");
if (!resolved) {
if (!app.module.auth.enabled) {
guard?.setUserContext(undefined);
} else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
// make sure to only register once
if (c.get("auth_registered")) {
skipped = true;
console.warn(`auth middleware already registered for ${getPath(c)}`);
} else {
c.set("auth_registered", true);
if (!skipped) {
const resolved = c.get("auth_resolved");
if (!resolved) {
if (!app?.module.auth.enabled) {
guard?.setUserContext(undefined);
} else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
}
}
}
}

View File

@@ -5,8 +5,13 @@ import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
export interface DB {}
export interface DB {
// make sure to make unknown as "any"
[key: string]: {
id: PrimaryFieldType;
[key: string]: any;
};
}
export const config = {
server: {

View File

@@ -1,17 +1,38 @@
export abstract class Event<Params = any> {
export type EventClass = {
new (params: any): Event<any, any>;
slug: string;
};
export abstract class Event<Params = any, Returning = void> {
_returning!: Returning;
/**
* Unique event slug
* Must be static, because registering events is done by class
*/
static slug: string = "untitled-event";
params: Params;
returned: boolean = false;
validate(value: Returning): Event<Params, Returning> | void {
throw new EventReturnedWithoutValidation(this as any, value);
}
protected clone<This extends Event<Params, Returning> = Event<Params, Returning>>(
this: This,
params: Params
): This {
const cloned = new (this.constructor as any)(params);
cloned.returned = true;
return cloned as This;
}
constructor(params: Params) {
this.params = params;
}
}
// @todo: current workaround: potentially there is none and that's the way
// @todo: current workaround: potentially there is "none" and that's the way
export class NoParamEvent extends Event<null> {
static override slug: string = "noparam-event";
@@ -19,3 +40,19 @@ export class NoParamEvent extends Event<null> {
super(null);
}
}
export class InvalidEventReturn extends Error {
constructor(expected: string, given: string) {
super(`Expected "${expected}", got "${given}"`);
}
}
export class EventReturnedWithoutValidation extends Error {
constructor(
event: EventClass,
public data: any
) {
// @ts-expect-error slug is static
super(`Event "${event.constructor.slug}" returned without validation`);
}
}

View File

@@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager";
export const ListenerModes = ["sync", "async"] as const;
export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event = Event> = (
export type ListenerHandler<E extends Event<any, any>> = (
event: E,
slug: string,
) => Promise<void> | void;
slug: string
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";
event: EventClass;
handler: ListenerHandler<E>;
once: boolean = false;
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
this.event = event;

View File

@@ -1,14 +1,19 @@
import type { Event } from "./Event";
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
export type RegisterListenerConfig =
| ListenerMode
| {
mode?: ListenerMode;
once?: boolean;
};
export interface EmitsEvents {
emgr: EventManager;
}
export type EventClass = {
new (params: any): Event;
slug: string;
};
// for compatibility, moved it to Event.ts
export type { EventClass };
export class EventManager<
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
@@ -17,16 +22,20 @@ export class EventManager<
protected listeners: EventListener[] = [];
enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
constructor(
events?: RegisteredEvents,
private options?: {
listeners?: EventListener[];
onError?: (event: Event, e: unknown) => void;
onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void;
asyncExecutor?: typeof Promise.all;
}
) {
if (events) {
this.registerEvents(events);
}
if (listeners) {
for (const listener of listeners) {
this.addListener(listener);
}
}
options?.listeners?.forEach((l) => this.addListener(l));
}
enable() {
@@ -82,9 +91,11 @@ export class EventManager<
return !!this.events.find((e) => slug === e.slug);
}
protected throwIfEventNotRegistered(event: EventClass) {
if (!this.eventExists(event)) {
throw new Error(`Event "${event.slug}" not registered`);
protected throwIfEventNotRegistered(event: EventClass | Event | string) {
if (!this.eventExists(event as any)) {
// @ts-expect-error
const name = event.constructor?.slug ?? event.slug ?? event;
throw new Error(`Event "${name}" not registered`);
}
}
@@ -117,55 +128,108 @@ export class EventManager<
return this;
}
protected createEventListener(
_event: EventClass | string,
handler: ListenerHandler<any>,
_config: RegisterListenerConfig = "async"
) {
const event =
typeof _event === "string" ? this.events.find((e) => e.slug === _event)! : _event;
const config = typeof _config === "string" ? { mode: _config } : _config;
const listener = new EventListener(event, handler, config.mode);
if (config.once) {
listener.once = true;
}
this.addListener(listener as any);
}
onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
event: ActualEvent,
handler: ListenerHandler<Instance>,
mode: ListenerMode = "async"
config?: RegisterListenerConfig
) {
this.throwIfEventNotRegistered(event);
const listener = new EventListener(event, handler, mode);
this.addListener(listener as any);
this.createEventListener(event, handler, config);
}
on<Params = any>(
slug: string,
handler: ListenerHandler<Event<Params>>,
mode: ListenerMode = "async"
config?: RegisterListenerConfig
) {
const event = this.events.find((e) => e.slug === slug);
if (!event) {
throw new Error(`Event "${slug}" not registered`);
}
this.onEvent(event, handler, mode);
this.createEventListener(slug, handler, config);
}
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") {
this.events.forEach((event) => this.onEvent(event, handler, mode));
onAny(handler: ListenerHandler<Event<unknown>>, config?: RegisterListenerConfig) {
this.events.forEach((event) => this.onEvent(event, handler, config));
}
async emit(event: Event) {
protected executeAsyncs(promises: (() => Promise<void>)[]) {
const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e));
executor(promises.map((p) => p())).then(() => void 0);
}
async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
// @ts-expect-error slug is static
const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
return;
return event;
}
if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`);
}
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
//console.log("---!-- emitting", slug, listeners.length);
const syncs: EventListener[] = [];
const asyncs: (() => Promise<void>)[] = [];
this.listeners = this.listeners.filter((listener) => {
// if no match, keep and ignore
if (listener.event.slug !== slug) return true;
for (const listener of listeners) {
if (listener.mode === "sync") {
await listener.handler(event, listener.event.slug);
syncs.push(listener);
} else {
listener.handler(event, listener.event.slug);
asyncs.push(async () => await listener.handler(event, listener.event.slug));
}
// Remove if `once` is true, otherwise keep
return !listener.once;
});
// execute asyncs
this.executeAsyncs(asyncs);
// execute syncs
let _event: Actual = event;
for (const listener of syncs) {
try {
const return_value = (await listener.handler(_event, listener.event.slug)) as any;
if (typeof return_value !== "undefined") {
const newEvent = _event.validate(return_value);
// @ts-expect-error slug is static
if (newEvent && newEvent.constructor.slug === slug) {
if (!newEvent.returned) {
throw new Error(
// @ts-expect-error slug is static
`Returned event ${newEvent.constructor.slug} must be marked as returned.`
);
}
_event = newEvent as Actual;
}
}
} catch (e) {
if (e instanceof InvalidEventReturn) {
this.options?.onInvalidReturn?.(_event, e);
console.warn(`Invalid return of event listener for "${slug}": ${e.message}`);
} else if (this.options?.onError) {
this.options.onError(_event, e);
} else {
throw e;
}
}
}
return _event;
}
}

View File

@@ -1,8 +1,8 @@
export { Event, NoParamEvent } from "./Event";
export { Event, NoParamEvent, InvalidEventReturn } from "./Event";
export {
EventListener,
ListenerModes,
type ListenerMode,
type ListenerHandler,
type ListenerHandler
} from "./EventListener";
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";

View File

@@ -130,7 +130,10 @@ export class SchemaObject<Schema extends TObject> {
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => path + "." + k);
const keys = getFullPathKeys(value).map((k) => {
// only prepend path if given
return path.length > 0 ? path + "." + k : k;
});
const overwritePaths = keys.filter((k) => {
return this.options?.overwritePaths?.some((p) => {
if (typeof p === "string") {

View File

@@ -49,7 +49,7 @@ type LiteralExpressionCondition<Exps extends Expressions> = {
[key: string]: Primitive | ExpressionCondition<Exps>;
};
const OperandOr = "$or";
const OperandOr = "$or" as const;
type OperandCondition<Exps extends Expressions> = {
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
};

View File

@@ -12,3 +12,4 @@ export * from "./uuid";
export { FromSchema } from "./typebox/from-schema";
export * from "./test";
export * from "./runtime";
export * from "./numbers";

View File

@@ -0,0 +1,5 @@
export function clampNumber(value: number, min: number, max: number): number {
const lower = Math.min(min, max);
const upper = Math.max(min, max);
return Math.max(lower, Math.min(value, upper));
}

View File

@@ -115,6 +115,7 @@ export function parse<Schema extends TSchema = TSchema>(
} else if (options?.onError) {
options.onError(Errors(schema, data));
} else {
//console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2));
throw new TypeInvalidError(schema, data);
}

View File

@@ -69,18 +69,9 @@ export class AppData extends Module<typeof dataConfigSchema> {
}
override getOverwritePaths() {
return [
/^entities\..*\.config$/,
/^entities\..*\.fields\..*\.config$/
///^entities\..*\.fields\..*\.config\.schema$/
];
return [/^entities\..*\.config$/, /^entities\..*\.fields\..*\.config$/];
}
/*registerController(server: AppServer) {
console.log("adding data controller to", this.basepath);
server.add(this.basepath, new DataController(this.em));
}*/
override toJSON(secrets?: boolean): AppDataConfig {
return {
...this.config,

View File

@@ -1,15 +1,17 @@
import type { DB } from "core";
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
export type DataApiOptions = BaseModuleApiOptions & {
defaultQuery?: Partial<RepoQuery>;
queryLengthLimit: number;
defaultQuery: Partial<RepoQuery>;
};
export class DataApi extends ModuleApi<DataApiOptions> {
protected override getDefaultOptions(): Partial<DataApiOptions> {
return {
basepath: "/api/data",
queryLengthLimit: 1000,
defaultQuery: {
limit: 10
}
@@ -19,26 +21,32 @@ export class DataApi extends ModuleApi<DataApiOptions> {
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
) {
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
}
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: Partial<RepoQuery> = {}
query: RepoQueryIn = {}
) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any],
query ?? this.options.defaultQuery
);
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery;
const req = this.get<T>([entity as any], input);
if (req.request.url.length <= this.options.queryLengthLimit) {
return req;
}
return this.post<T>([entity as any, "query"], input);
}
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: Partial<RepoQuery> = {}) {
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference],
query ?? this.options.defaultQuery

View File

@@ -70,7 +70,7 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
@@ -85,8 +85,6 @@ export class DataController extends Controller {
return func;
}
hono.use("*", permission(SystemPermissions.accessApi));
// info
hono.get(
"/",
@@ -283,7 +281,7 @@ export class DataController extends Controller {
return c.notFound();
}
const options = (await c.req.valid("json")) as RepoQuery;
console.log("options", options);
//console.log("options", options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });

View File

@@ -1,6 +1,5 @@
import type { DatabaseIntrospector, SqliteDatabase } from "kysely";
import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely";
import { Kysely, SqliteDialect } from "kysely";
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
@@ -14,7 +13,7 @@ class CustomSqliteDialect extends SqliteDialect {
export class SqliteLocalConnection extends SqliteConnection {
constructor(private database: SqliteDatabase) {
const plugins = [new DeserializeJsonValuesPlugin()];
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomSqliteDialect({ database }),
plugins

View File

@@ -98,8 +98,8 @@ export class Entity<
getDefaultSort() {
return {
by: this.config.sort_field,
dir: this.config.sort_dir
by: this.config.sort_field ?? "id",
dir: this.config.sort_dir ?? "asc"
};
}
@@ -192,14 +192,41 @@ export class Entity<
this.data = data;
}
isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean {
// @todo: add tests
isValidData(
data: EntityData,
context: TActionContext,
options?: {
explain?: boolean;
ignoreUnknown?: boolean;
}
): boolean {
if (typeof data !== "object") {
if (options?.explain) {
throw new Error(`Entity "${this.name}" data must be an object`);
}
}
const fields = this.getFillableFields(context, false);
//const fields = this.fields;
//console.log("data", data);
if (options?.ignoreUnknown !== true) {
const field_names = fields.map((f) => f.name);
const given_keys = Object.keys(data);
const unknown_keys = given_keys.filter((key) => !field_names.includes(key));
if (unknown_keys.length > 0) {
if (options?.explain) {
throw new Error(
`Entity "${this.name}" data must only contain known keys, unknown: "${unknown_keys}"`
);
}
}
}
for (const field of fields) {
if (!field.isValid(data[field.name], context)) {
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
if (explain) {
if (options?.explain) {
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
}

View File

@@ -111,15 +111,18 @@ export class EntityManager<TBD extends object = DefaultDB> {
// caused issues because this.entity() was using a reference (for when initial config was given)
}
entity(e: Entity | keyof TBD | string): Entity {
entity<Silent extends true | false = false>(
e: Entity | keyof TBD | string,
silent?: Silent
): Silent extends true ? Entity | undefined : Entity {
// make sure to always retrieve by name
const entity = this.entities.find((entity) =>
e instanceof Entity ? entity.name === e.name : entity.name === e
);
if (!entity) {
// @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
if (silent === true) return undefined as any;
throw new EntityNotDefinedException(e instanceof Entity ? e.name : (e as string));
}
return entity;

View File

@@ -132,14 +132,17 @@ export class Mutator<
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
}
// @todo: establish the original order from "data"
const result = await this.emgr.emit(
new Mutator.Events.MutatorInsertBefore({ entity, data: data as any })
);
// if listener returned, take what's returned
const _data = result.returned ? result.params.data : data;
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(data, "create"))
...(await this.getValidatedData(_data, "create"))
};
await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData }));
// check if required fields are present
const required = entity.getRequiredFields();
for (const field of required) {
@@ -169,16 +172,17 @@ export class Mutator<
throw new Error("ID must be provided for update");
}
const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit(
const result = await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({
entity,
entityId: id,
data: validatedData as any
data
})
);
const _data = result.returned ? result.params.data : data;
const validatedData = await this.getValidatedData(_data, "update");
const query = this.conn
.updateTable(entity.name)
.set(validatedData as any)

View File

@@ -65,7 +65,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return this.em.connection.kysely;
}
private getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = {
@@ -103,17 +103,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.select = options.select;
}
if (options.with && options.with.length > 0) {
for (const entry of options.with) {
const related = this.em.relationOf(entity.name, entry);
if (!related) {
throw new InvalidSearchParamsException(
`WITH: "${entry}" is not a relation of "${entity.name}"`
);
}
validated.with.push(entry);
}
if (options.with) {
const depth = WithBuilder.validateWiths(this.em, entity.name, options.with);
// @todo: determine allowed depth
validated.with = options.with;
}
if (options.join && options.join.length > 0) {
@@ -235,43 +228,79 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return { ...response, data: data[0]! };
}
private buildQuery(
addOptionsToQueryBuilder(
_qb?: RepositoryQB,
_options?: Partial<RepoQuery>,
exclude_options: (keyof RepoQuery)[] = []
): { qb: RepositoryQB; options: RepoQuery } {
config?: {
validate?: boolean;
ignore?: (keyof RepoQuery)[];
alias?: string;
defaults?: Pick<RepoQuery, "limit" | "offset">;
}
) {
const entity = this.entity;
const options = this.getValidOptions(_options);
let qb = _qb ?? (this.conn.selectFrom(entity.name) as RepositoryQB);
const alias = entity.name;
const options = config?.validate !== false ? this.getValidOptions(_options) : _options;
if (!options) return qb;
const alias = config?.alias ?? entity.name;
const aliased = (field: string) => `${alias}.${field}`;
let qb = this.conn
.selectFrom(entity.name)
.select(entity.getAliasedSelectFrom(options.select, alias));
const ignore = config?.ignore ?? [];
const defaults = {
limit: 10,
offset: 0,
...config?.defaults
};
//console.log("build query options", options);
if (!exclude_options.includes("with") && options.with) {
/*console.log("build query options", {
entity: entity.name,
options,
config
});*/
if (!ignore.includes("select") && options.select) {
qb = qb.select(entity.getAliasedSelectFrom(options.select, alias));
}
if (!ignore.includes("with") && options.with) {
qb = WithBuilder.addClause(this.em, qb, entity, options.with);
}
if (!exclude_options.includes("join") && options.join) {
if (!ignore.includes("join") && options.join) {
qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
}
// add where if present
if (!exclude_options.includes("where") && options.where) {
if (!ignore.includes("where") && options.where) {
qb = WhereBuilder.addClause(qb, options.where);
}
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit);
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset);
if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit);
if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset);
// sorting
if (!exclude_options.includes("sort")) {
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
if (!ignore.includes("sort")) {
qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc");
}
//console.log("options", { _options, options, exclude_options });
return { qb, options };
return qb as RepositoryQB;
}
private buildQuery(
_options?: Partial<RepoQuery>,
ignore: (keyof RepoQuery)[] = []
): { qb: RepositoryQB; options: RepoQuery } {
const entity = this.entity;
const options = this.getValidOptions(_options);
return {
qb: this.addOptionsToQueryBuilder(undefined, options, {
ignore,
alias: entity.name
}),
options
};
}
async findId(

View File

@@ -30,7 +30,7 @@ function key(e: unknown): string {
return e as string;
}
const expressions: TExpression<any, any, any>[] = [
const expressions = [
exp(
"$eq",
(v: Primitive) => isPrimitive(v),

View File

@@ -1,42 +1,82 @@
import { isObject } from "core/utils";
import type { KyselyJsonFrom, RepoQuery } from "data";
import { InvalidSearchParamsException } from "data/errors";
import type { Entity, EntityManager, RepositoryQB } from "../../entities";
export class WithBuilder {
private static buildClause(
static addClause(
em: EntityManager<any>,
qb: RepositoryQB,
entity: Entity,
withString: string
withs: RepoQuery["with"]
) {
const relation = em.relationOf(entity.name, withString);
if (!relation) {
throw new Error(`Relation "${withString}" not found`);
if (!withs || !isObject(withs)) {
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
return qb;
}
const cardinality = relation.ref(withString).cardinality;
//console.log("with--builder", { entity: entity.name, withString, cardinality });
const fns = em.connection.fn;
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
if (!jsonFrom) {
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
}
try {
return relation.buildWith(entity, qb, jsonFrom, withString);
} catch (e) {
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`);
}
}
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) {
if (withs.length === 0) return qb;
let newQb = qb;
for (const entry of withs) {
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
for (const [ref, query] of Object.entries(withs)) {
const relation = em.relationOf(entity.name, ref);
if (!relation) {
throw new Error(`Relation "${entity.name}<>${ref}" not found`);
}
const cardinality = relation.ref(ref).cardinality;
const jsonFrom: KyselyJsonFrom =
cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
if (!jsonFrom) {
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
}
const other = relation.other(entity);
newQb = newQb.select((eb) => {
let subQuery = relation.buildWith(entity, ref)(eb);
if (query) {
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
Boolean
) as any
});
}
if (query.with) {
subQuery = WithBuilder.addClause(em, subQuery, other.entity, query.with as any);
}
return jsonFrom(subQuery).as(other.reference);
});
}
return newQb;
}
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
let depth = 0;
if (!withs || !isObject(withs)) {
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
return depth;
}
const child_depths: number[] = [];
for (const [ref, query] of Object.entries(withs)) {
const related = em.relationOf(entity, ref);
if (!related) {
throw new InvalidSearchParamsException(
`WITH: "${ref}" is not a relation of "${entity}"`
);
}
depth++;
if ("with" in query) {
child_depths.push(WithBuilder.validateWiths(em, ref, query.with as any));
}
}
if (child_depths.length > 0) {
depth += Math.max(...child_depths);
}
return depth;
}
}

View File

@@ -1,20 +1,48 @@
import type { PrimaryFieldType } from "core";
import { Event } from "core/events";
import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "../server/data-query-impl";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> {
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before";
override validate(data: EntityData) {
const { entity } = this.params;
if (!entity.isValidData(data, "create")) {
throw new InvalidEventReturn("EntityData", "invalid");
}
return this.clone({
entity,
data
});
}
}
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
static override slug = "mutator-insert-after";
}
export class MutatorUpdateBefore extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
}> {
export class MutatorUpdateBefore extends Event<
{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
},
EntityData
> {
static override slug = "mutator-update-before";
override validate(data: EntityData) {
const { entity, ...rest } = this.params;
if (!entity.isValidData(data, "update")) {
throw new InvalidEventReturn("EntityData", "invalid");
}
return this.clone({
...rest,
entity,
data
});
}
}
export class MutatorUpdateAfter extends Event<{
entity: Entity;

View File

@@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails
export const ActionContext = ["create", "read", "update", "delete"] as const;
export type TActionContext = (typeof ActionContext)[number];
@@ -157,8 +160,12 @@ export abstract class Field<
return this.config.virtual ?? false;
}
getLabel(): string {
return this.config.label ?? snakeToPascalWithSpaces(this.name);
getLabel(options?: { fallback?: boolean }): string | undefined {
return this.config.label
? this.config.label
: options?.fallback !== false
? snakeToPascalWithSpaces(this.name)
: undefined;
}
getDescription(): string | undefined {

View File

@@ -8,6 +8,7 @@ export * from "./prototype";
export {
type RepoQuery,
type RepoQueryIn,
defaultQuerySchema,
querySchema,
whereSchema

View File

@@ -1,5 +1,5 @@
import { type Static, Type, parse } from "core/utils";
import type { SelectQueryBuilder } from "kysely";
import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
import type { Entity, EntityData, EntityManager } from "../entities";
import {
type EntityRelationAnchor,
@@ -67,10 +67,8 @@ export abstract class EntityRelation<
*/
abstract buildWith(
entity: Entity,
qb: KyselyQueryBuilder,
jsonFrom: KyselyJsonFrom,
reference: string
): KyselyQueryBuilder;
): (eb: ExpressionBuilder<any, any>) => KyselyQueryBuilder;
abstract buildJoin(
entity: Entity,

View File

@@ -1,4 +1,5 @@
import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import { Entity, type EntityManager } from "../entities";
import { type Field, PrimaryField, VirtualField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl";
@@ -123,7 +124,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.groupBy(groupBy);
}
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
buildWith(entity: Entity) {
if (!this.em) {
throw new Error("EntityManager not set, can't build");
}
@@ -138,7 +139,29 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
(f) => !(f instanceof RelationField || f instanceof PrimaryField)
);
return qb.select((eb) => {
return (eb: ExpressionBuilder<any, any>) =>
eb
.selectFrom(other.entity.name)
.select((eb2) => {
const select: any[] = other.entity.getSelect(other.entity.name);
if (additionalFields.length > 0) {
const conn = this.connectionEntity.name;
select.push(
jsonBuildObject(
Object.fromEntries(
additionalFields.map((f) => [f.name, eb2.ref(`${conn}.${f.name}`)])
)
).as(this.connectionTableMappedName)
);
}
return select;
})
.whereRef(entityRef, "=", otherRef)
.innerJoin(...join)
.limit(limit);
/*return qb.select((eb) => {
const select: any[] = other.entity.getSelect(other.entity.name);
// @todo: also add to find by references
if (additionalFields.length > 0) {
@@ -160,7 +183,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.innerJoin(...join)
.limit(limit)
).as(other.reference);
});
});*/
}
initialize(em: EntityManager<any>) {

View File

@@ -1,6 +1,7 @@
import type { PrimaryFieldType } from "core";
import { snakeToPascalWithSpaces } from "core/utils";
import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
@@ -155,23 +156,14 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
return qb.innerJoin(self.entity.name, entityRef, otherRef).groupBy(groupBy);
}
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom, reference: string) {
buildWith(entity: Entity, reference: string) {
const { self, entityRef, otherRef, relationRef } = this.queryInfo(entity, reference);
const limit =
self.cardinality === 1
? 1
: this.config.with_limit ?? ManyToOneRelation.DEFAULTS.with_limit;
//console.log("buildWith", entity.name, reference, { limit });
return qb.select((eb) =>
jsonFrom(
eb
.selectFrom(`${self.entity.name} as ${relationRef}`)
.select(self.entity.getSelect(relationRef))
.whereRef(entityRef, "=", otherRef)
.limit(limit)
).as(relationRef)
);
return (eb: ExpressionBuilder<any, any>) =>
eb
.selectFrom(`${self.entity.name} as ${relationRef}`)
.whereRef(entityRef, "=", otherRef)
.$if(self.cardinality === 1, (qb) => qb.limit(1));
}
/**

View File

@@ -1,4 +1,5 @@
import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl";
@@ -87,20 +88,15 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
};
}
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
buildWith(entity: Entity) {
const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity);
const limit = other.cardinality === 1 ? 1 : 5;
return qb.select((eb) =>
jsonFrom(
eb
.selectFrom(other.entity.name)
.select(other.entity.getSelect(other.entity.name))
.where(whereLhs, "=", reference)
.whereRef(entityRef, "=", otherRef)
.limit(limit)
).as(other.reference)
);
return (eb: ExpressionBuilder<any, any>) =>
eb
.selectFrom(other.entity.name)
.where(whereLhs, "=", reference)
.whereRef(entityRef, "=", otherRef)
.$if(other.cardinality === 1, (qb) => qb.limit(1));
}
override isListableFor(entity: Entity): boolean {

View File

@@ -1,3 +1,4 @@
import type { TThis } from "@sinclair/typebox";
import {
type SchemaOptions,
type Static,
@@ -6,7 +7,7 @@ import {
Type,
Value
} from "core/utils";
import { WhereBuilder } from "../entities";
import { WhereBuilder, type WhereQuery } from "../entities";
const NumberOrString = (options: SchemaOptions = {}) =>
Type.Transform(Type.Union([Type.Number(), Type.String()], options))
@@ -14,10 +15,8 @@ const NumberOrString = (options: SchemaOptions = {}) =>
.Encode(String);
const limit = NumberOrString({ default: 10 });
const offset = NumberOrString({ default: 0 });
// @todo: allow "id" and "-id"
const sort_default = { by: "id", dir: "asc" };
const sort = Type.Transform(
Type.Union(
@@ -27,20 +26,20 @@ const sort = Type.Transform(
}
)
)
.Decode((value) => {
.Decode((value): { by: string; dir: "asc" | "desc" } => {
if (typeof value === "string") {
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
const dir = value[0] === "-" ? "desc" : "asc";
return { by: dir === "desc" ? value.slice(1) : value, dir };
return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
} else if (/^{.*}$/.test(value)) {
return JSON.parse(value);
return JSON.parse(value) as any;
}
return sort_default;
return sort_default as any;
}
return value;
return value as any;
})
.Encode(JSON.stringify);
.Encode((value) => value);
const stringArray = Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] })
@@ -64,21 +63,63 @@ export const whereSchema = Type.Transform(
})
.Encode(JSON.stringify);
export const querySchema = Type.Object(
{
limit: Type.Optional(limit),
offset: Type.Optional(offset),
sort: Type.Optional(sort),
select: Type.Optional(stringArray),
with: Type.Optional(stringArray),
join: Type.Optional(stringArray),
where: Type.Optional(whereSchema)
},
{
additionalProperties: false
export type RepoWithSchema = Record<
string,
Omit<RepoQueryIn, "with"> & {
with?: unknown;
}
>;
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)]))
.Decode((value) => {
let _value = typeof value === "string" ? [value] : value;
if (Array.isArray(value)) {
if (!value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
}
return _value as RepoWithSchema;
})
.Encode((value) => value);
export const querySchema = Type.Recursive(
(Self) =>
Type.Partial(
Type.Object(
{
limit: limit,
offset: offset,
sort: sort,
select: stringArray,
with: withSchema(Self),
join: stringArray,
where: whereSchema
},
{
// @todo: determine if unknown is allowed, it's ignore anyway
additionalProperties: false
}
)
),
{ $id: "query-schema" }
);
export type RepoQueryIn = Static<typeof querySchema>;
export type RepoQueryIn = {
limit?: number;
offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[];
with?: string[] | Record<string, RepoQueryIn>;
join?: string[];
where?: WhereQuery;
};
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;

View File

@@ -12,6 +12,18 @@ export type { TAppFlowTaskSchema } from "./flows-schema";
export class AppFlows extends Module<typeof flowsConfigSchema> {
private flows: Record<string, Flow> = {};
getSchema() {
return flowsConfigSchema;
}
private getFlowInfo(flow: Flow) {
return {
...flow.toJSON(),
tasks: flow.tasks.length,
connections: flow.connections
};
}
override async build() {
//console.log("building flows", this.config);
const flows = transformObject(this.config.flows, (flowConfig, name) => {
@@ -67,15 +79,10 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
this.setBuilt();
}
getSchema() {
return flowsConfigSchema;
}
private getFlowInfo(flow: Flow) {
override toJSON() {
return {
...flow.toJSON(),
tasks: flow.tasks.length,
connections: flow.connections
...this.config,
flows: transformObject(this.flows, (flow) => flow.toJSON())
};
}
}

View File

@@ -62,7 +62,7 @@ export const flowSchema = Type.Object(
{
trigger: Type.Union(Object.values(triggerSchemaObject)),
tasks: Type.Optional(StringRecord(Type.Union(Object.values(taskSchemaObject)))),
connections: Type.Optional(StringRecord(connectionSchema, { default: {} })),
connections: Type.Optional(StringRecord(connectionSchema)),
start_task: Type.Optional(Type.String()),
responding_task: Type.Optional(Type.String())
},

View File

@@ -162,8 +162,8 @@ export class Flow {
trigger: this.trigger.toJSON(),
tasks: Object.fromEntries(this.tasks.map((t) => [t.name, t.toJSON()])),
connections: Object.fromEntries(this.connections.map((c) => [c.id, c.toJSON()])),
start_task: this.startTask.name,
responding_task: this.respondingTask ? this.respondingTask.name : null
start_task: this.startTask?.name,
responding_task: this.respondingTask?.name
};
}

View File

@@ -1,4 +1,4 @@
import { uuid } from "core/utils";
import { objectCleanEmpty, uuid } from "core/utils";
import { get } from "lodash-es";
import type { Task, TaskResult } from "./Task";
@@ -34,14 +34,14 @@ export class TaskConnection {
}
toJSON() {
return {
return objectCleanEmpty({
source: this.source.name,
target: this.target.name,
config: {
...this.config,
condition: this.config.condition?.toJSON()
}
};
});
}
}

View File

@@ -53,6 +53,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
index(media).on(["path"], true).on(["reference"]);
})
);
this.setBuilt();
} catch (e) {
console.error(e);
throw new Error(

View File

@@ -1,5 +1,5 @@
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi";
import type { FileWithPath } from "ui/modules/media/components/dropzone/file-selector";
import type { FileWithPath } from "ui/elements/media/file-selector";
export type MediaApiOptions = BaseModuleApiOptions & {};

View File

@@ -1,7 +1,8 @@
import type { TObject, TString } from "@sinclair/typebox";
import { type Constructor, Registry } from "core";
export { MIME_TYPES } from "./storage/mime-types";
//export { MIME_TYPES } from "./storage/mime-types";
export { guess as guessMimeType } from "./storage/mime-types-tiny";
export {
Storage,
type StorageAdapter,
@@ -19,7 +20,7 @@ import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/Stora
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
export * as StorageEvents from "./storage/events";
export { type FileUploadedEventData } from "./storage/events";
export type { FileUploadedEventData } from "./storage/events";
export * from "./utils";
type ClassThatImplements<T> = Constructor<T> & { prototype: T };

View File

@@ -10,7 +10,7 @@ export function getExtension(filename: string): string | undefined {
export function getRandomizedFilename(file: File, length?: number): string;
export function getRandomizedFilename(file: string, length?: number): string;
export function getRandomizedFilename(file: File | string, length = 16): string {
const filename = file instanceof File ? file.name : file;
const filename = typeof file === "string" ? file : file.name;
if (typeof filename !== "string") {
console.error("Couldn't extract filename from", file);

View File

@@ -3,9 +3,18 @@ import type { Guard } from "auth";
import { SchemaObject } from "core";
import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils";
import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data";
import {
type Connection,
type EntityIndex,
type EntityManager,
type Field,
FieldPrototype,
make,
type em as prototypeEm
} from "data";
import { Entity } from "data";
import type { Hono } from "hono";
import { isEqual } from "lodash-es";
export type ServerEnv = {
Variables: {
@@ -146,28 +155,33 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
}
protected ensureEntity(entity: Entity) {
const instance = this.ctx.em.entity(entity.name, true);
// check fields
if (!this.ctx.em.hasEntity(entity.name)) {
if (!instance) {
this.ctx.em.addEntity(entity);
this.ctx.flags.sync_required = true;
return;
}
const instance = this.ctx.em.entity(entity.name);
// if exists, check all fields required are there
// @todo: check if the field also equal
for (const field of instance.fields) {
const _field = entity.field(field.name);
if (!_field) {
entity.addField(field);
for (const field of entity.fields) {
const instanceField = instance.field(field.name);
if (!instanceField) {
instance.addField(field);
this.ctx.flags.sync_required = true;
} else {
const changes = this.setEntityFieldConfigs(field, instanceField);
if (changes > 0) {
this.ctx.flags.sync_required = true;
}
}
}
// replace entity (mainly to keep the ensured type)
this.ctx.em.__replaceEntity(
new Entity(entity.name, entity.fields, instance.config, entity.type)
new Entity(instance.name, instance.fields, instance.config, entity.type)
);
}
@@ -184,4 +198,35 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return schema;
}
protected setEntityFieldConfigs(
parent: Field,
child: Field,
props: string[] = ["hidden", "fillable", "required"]
) {
let changes = 0;
for (const prop of props) {
if (!isEqual(child.config[prop], parent.config[prop])) {
child.config[prop] = parent.config[prop];
changes++;
}
}
return changes;
}
protected replaceEntityField(
_entity: string | Entity,
field: Field | string,
_newField: Field | FieldPrototype
) {
const entity = this.ctx.em.entity(_entity);
const name = typeof field === "string" ? field : field.name;
const newField =
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
// ensure keeping vital config
this.setEntityFieldConfigs(entity.field(name)!, newField);
entity.__replaceField(name, newField);
}
}

View File

@@ -39,6 +39,13 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
} as Options;
}
/**
* used for SWR invalidation of basepath
*/
key(): string {
return this.options.basepath ?? "";
}
protected getUrl(path: string) {
const basepath = this.options.basepath ?? "";
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");

View File

@@ -88,6 +88,7 @@ export type ModuleManagerOptions = {
};
type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
@@ -236,10 +237,10 @@ export class ModuleManager {
private async fetch(): Promise<ConfigTable> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet
return await withDisabledConsole(async () => {
const startTime = performance.now();
const result = await withDisabledConsole(async () => {
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
@@ -251,9 +252,16 @@ export class ModuleManager {
throw BkndError.with("no config");
}
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear();
return result as ConfigTable;
return result as unknown as ConfigTable;
}, ["log", "error", "warn"]);
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id
})
.clear();
return result;
}
async save() {
@@ -329,6 +337,9 @@ export class ModuleManager {
}
}
// re-apply configs to all modules (important for system entities)
this.setConfigs(configs);
// @todo: cleanup old versions?
this.logger.clear();
@@ -387,6 +398,7 @@ export class ModuleManager {
}
private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
try {
// setting "noEmit" to true, to not force listeners to update

View File

@@ -44,6 +44,11 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
hono.get(
"/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),

View File

@@ -1,7 +1,10 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import { useApi } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced";
@@ -10,6 +13,7 @@ type BkndContext = {
schema: ModuleSchemas;
config: ModuleConfigs;
permissions: string[];
hasSecrets: boolean;
requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>;
app: AppReduced;
@@ -32,7 +36,9 @@ export function BkndProvider({
const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>();
const [local_version, set_local_version] = useState(0);
const api = useApi();
async function reloadSchema() {
@@ -49,15 +55,11 @@ export function BkndProvider({
if (!res.ok) {
if (errorShown.current) return;
errorShown.current = true;
/*notifications.show({
title: "Failed to fetch schema",
// @ts-ignore
message: body.error,
color: "red",
position: "top-right",
autoClose: false,
withCloseButton: true
});*/
setError(true);
return;
} else if (error) {
setError(false);
}
const schema = res.ok
@@ -80,6 +82,7 @@ export function BkndProvider({
setSchema(schema);
setWithSecrets(_includeSecrets);
setFetched(true);
set_local_version((v) => v + 1);
});
}
@@ -96,9 +99,24 @@ export function BkndProvider({
if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ api, setSchema, reloadSchema });
const hasSecrets = withSecrets && !error;
return (
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
<BkndContext.Provider
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }}
key={local_version}
>
{error && (
<Alert.Exception className="gap-2">
<IconAlertHexagon />
You attempted to load system configuration with secrets without having proper
permission.
<a href={schema.config.server.admin.basepath || "/"}>
<Button variant="red">Reload</Button>
</a>
</Alert.Exception>
)}
{children}
</BkndContext.Provider>
);

View File

@@ -12,7 +12,6 @@ export type ClientProviderProps = {
};
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
@@ -31,6 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e);
}
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return (

View File

@@ -1,5 +1,5 @@
import type { Api } from "Api";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
import { useApi } from "ui/client";
@@ -27,12 +27,19 @@ export const useApiQuery = <
};
};
export const useInvalidate = () => {
export const useInvalidate = (options?: { exact?: boolean }) => {
const mutate = useSWRConfig().mutate;
const api = useApi();
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => {
if (!arg) return async () => mutate("");
return mutate(typeof arg === "string" ? arg : arg(api).key());
return async (arg?: string | ((api: Api) => FetchPromise<any> | ModuleApi<any>)) => {
let key = "";
if (typeof arg === "string") {
key = arg;
} else if (typeof arg === "function") {
key = arg(api).key();
}
if (options?.exact) return mutate(key);
return mutate((k) => typeof k === "string" && k.startsWith(key));
};
};

View File

@@ -1,6 +1,6 @@
import type { DB, PrimaryFieldType } from "core";
import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data";
import type { EntityData, RepoQuery, RepoQueryIn } from "data";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr";
import { type Api, useApi } from "ui/client";
@@ -22,15 +22,6 @@ export class UseEntityApiError<Payload = any> extends Error {
}
}
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
@@ -49,7 +40,7 @@ export const useEntity = <
}
return res;
},
read: async (query: Partial<RepoQuery> = {}) => {
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}"`);
@@ -88,7 +79,7 @@ export function makeKey(
api: ModuleApi,
entity: string,
id?: PrimaryFieldType,
query?: Partial<RepoQuery>
query?: RepoQueryIn
) {
return (
"/" +
@@ -105,11 +96,11 @@ export const useEntityQuery = <
>(
entity: Entity,
id?: Id,
query?: Partial<RepoQuery>,
query?: RepoQueryIn,
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => {
const api = useApi().data;
const key = makeKey(api, entity, id, query);
const key = makeKey(api, entity as string, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query);
@@ -121,7 +112,7 @@ export const useEntityQuery = <
});
const mutateAll = async () => {
const entityKey = makeKey(api, entity);
const entityKey = makeKey(api, entity as string);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true
});
@@ -167,7 +158,7 @@ export async function mutateEntityCache<
return prev;
}
const entityKey = makeKey(api, entity);
const entityKey = makeKey(api, entity as string);
return mutate(
(key) => typeof key === "string" && key.startsWith(entityKey),

View File

@@ -73,23 +73,3 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
verify
};
};
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();
const api = useApi(options?.baseUrl);
useEffect(() => {
(async () => {
const res = await api.auth.strategies();
//console.log("res", res);
if (res.res.ok) {
setData(res.body);
}
})();
}, [options?.baseUrl]);
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
};

View File

@@ -12,6 +12,7 @@ import {
} from "data/data-schema";
import { useBknd } from "ui/client/bknd";
import type { TSchemaActions } from "ui/client/schema/actions";
import { bkndModals } from "ui/modals";
export function useBkndData() {
const { config, app, schema, actions: bkndActions } = useBknd();
@@ -62,7 +63,13 @@ export function useBkndData() {
}
};
const $data = {
entity: (name: string) => entities[name]
entity: (name: string) => entities[name],
modals,
system: (name: string) => ({
any: entities[name]?.type === "system",
users: name === config.auth.entity_name,
media: name === config.media.entity_name
})
};
return {
@@ -75,6 +82,35 @@ export function useBkndData() {
};
}
const modals = {
createAny: () => bkndModals.open(bkndModals.ids.dataCreate, {}),
createEntity: () =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "entity"],
initialState: { action: "entity" }
}),
createRelation: (rel: { source?: string; target?: string; type?: string }) =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "relation"],
initialState: {
action: "relation",
relations: {
create: [rel as any]
}
}
}),
createMedia: (entity?: string) =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "template-media"],
initialState: {
action: "template-media",
initial: {
entity
}
}
})
};
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
return {
add: async (name: string, field: TAppDataField) => {

View File

@@ -4,15 +4,15 @@ import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link";
const sizes = {
small: "px-2 py-1.5 rounded-md gap-1.5 text-sm",
default: "px-3 py-2.5 rounded-md gap-2.5",
large: "px-4 py-3 rounded-md gap-3 text-lg"
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
default: "px-3 py-2.5 rounded-md gap-1.5",
large: "px-4 py-3 rounded-md gap-2.5 text-lg"
};
const iconSizes = {
small: 15,
default: 18,
large: 22
small: 12,
default: 16,
large: 20
};
const styles = {

View File

@@ -10,9 +10,9 @@ export type IconType =
const styles = {
xs: { className: "p-0.5", size: 13 },
sm: { className: "p-0.5", size: 16 },
md: { className: "p-1", size: 20 },
lg: { className: "p-1.5", size: 24 }
sm: { className: "p-0.5", size: 15 },
md: { className: "p-1", size: 18 },
lg: { className: "p-1.5", size: 22 }
} as const;
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {

View File

@@ -6,16 +6,27 @@ export type AlertProps = ComponentPropsWithoutRef<"div"> & {
visible?: boolean;
title?: string;
message?: ReactNode | string;
children?: ReactNode;
};
const Base: React.FC<AlertProps> = ({ visible = true, title, message, className, ...props }) =>
const Base: React.FC<AlertProps> = ({
visible = true,
title,
message,
className,
children,
...props
}) =>
visible ? (
<div
{...props}
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
className={twMerge(
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
className
)}
>
{title && <b className="mr-2">{title}:</b>}
{message}
{message || children}
</div>
) : null;

View File

@@ -1,33 +1,33 @@
import { Button } from "../buttons/Button";
import { twMerge } from "tailwind-merge";
import { Button, type ButtonProps } from "../buttons/Button";
export type EmptyProps = {
Icon?: any;
title?: string;
description?: string;
buttonText?: string;
buttonOnClick?: () => void;
primary?: ButtonProps;
secondary?: ButtonProps;
className?: string;
};
export const Empty: React.FC<EmptyProps> = ({
Icon = undefined,
title = undefined,
description = "Check back later my friend.",
buttonText,
buttonOnClick
primary,
secondary,
className
}) => (
<div className="flex flex-col h-full w-full justify-center items-center">
<div className={twMerge("flex flex-col h-full w-full justify-center items-center", className)}>
<div className="flex flex-col gap-3 items-center max-w-80">
{Icon && <Icon size={48} className="opacity-50" stroke={1} />}
<div className="flex flex-col gap-1">
{title && <h3 className="text-center text-lg font-bold">{title}</h3>}
<p className="text-center text-primary/60">{description}</p>
</div>
{buttonText && (
<div className="mt-1.5">
<Button variant="primary" onClick={buttonOnClick}>
{buttonText}
</Button>
</div>
)}
<div className="mt-1.5 flex flex-row gap-2">
{secondary && <Button variant="default" {...secondary} />}
{primary && <Button variant="primary" {...primary} />}
</div>
</div>
</div>
);

View File

@@ -1,7 +1,24 @@
import { IconLockAccessOff } from "@tabler/icons-react";
import { Empty, type EmptyProps } from "./Empty";
const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />;
const NotAllowed = (props: Partial<EmptyProps>) => <Empty title="Not Allowed" {...props} />;
const MissingPermission = ({
what,
...props
}: Partial<EmptyProps> & {
what?: string;
}) => (
<Empty
Icon={IconLockAccessOff}
title="Missing Permission"
description={`You're not allowed to access ${what ?? "this"}.`}
{...props}
/>
);
export const Message = {
NotFound
NotFound,
NotAllowed,
MissingPermission
};

View File

@@ -0,0 +1,29 @@
import { Switch } from "@mantine/core";
import { forwardRef, useEffect, useState } from "react";
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
const [checked, setChecked] = useState(Boolean(props.value));
useEffect(() => {
setChecked(Boolean(props.value));
}, [props.value]);
function handleCheck(e) {
setChecked(e.target.checked);
props.onChange?.(e.target.checked);
}
return (
<div className="flex flex-row">
<Switch
ref={ref}
checked={checked}
onChange={handleCheck}
disabled={props.disabled}
id={props.id}
/>
</div>
);
}
);

View File

@@ -1,11 +1,10 @@
import { Switch } from "@mantine/core";
import { getBrowser } from "core/utils";
import type { Field } from "data";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event";
import { IconButton } from "../buttons/IconButton";
import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
error,
@@ -131,17 +130,6 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
}
return (
<div className="flex flex-row">
<Switch
ref={ref}
checked={checked}
onChange={handleCheck}
disabled={props.disabled}
id={props.id}
/>
</div>
);
/*return (
<div className="h-11 flex items-center">
<input
{...props}
@@ -153,7 +141,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
disabled={props.disabled}
/>
</div>
);*/
);
}
);

View File

@@ -0,0 +1,17 @@
import { BooleanInputMantine } from "./BooleanInputMantine";
import { DateInput, Input, Textarea } from "./components";
export const formElementFactory = (element: string, props: any) => {
switch (element) {
case "date":
return DateInput;
case "boolean":
return BooleanInputMantine;
case "textarea":
return Textarea;
default:
return Input;
}
};
export * from "./components";

View File

@@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & {
schema: RJSFSchema | Schema;
uiSchema?: any;
direction?: "horizontal" | "vertical";
onChange?: (value: any) => void;
onChange?: (value: any, isValid: () => boolean) => void;
};
export type JsonSchemaFormRef = {
formData: () => any;
validateForm: () => boolean;
silentValidate: () => boolean;
cancel: () => void;
};
@@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData));
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
onChange?.(clean);
setValue(clean);
onChange?.(clean, () => isValid(clean));
};
const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0;
useImperativeHandle(
ref,
() => ({
formData: () => value,
validateForm: () => formRef.current!.validateForm(),
silentValidate: () => isValid(value),
cancel: () => formRef.current!.reset()
}),
[value]

View File

@@ -1,7 +1,14 @@
import { useClickOutside } from "@mantine/hooks";
import { Fragment, type ReactElement, cloneElement, useState } from "react";
import { clampNumber } from "core/utils";
import {
type ComponentPropsWithoutRef,
Fragment,
type ReactElement,
cloneElement,
useState
} from "react";
import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event";
import { useEvent } from "ui/hooks/use-event";
export type DropdownItem =
| (() => JSX.Element)
@@ -14,26 +21,33 @@ export type DropdownItem =
[key: string]: any;
};
export type DropdownClickableChild = ReactElement<{ onClick: () => void }>;
export type DropdownProps = {
className?: string;
openEvent?: "onClick" | "onContextMenu";
defaultOpen?: boolean;
title?: string | ReactElement;
dropdownWrapperProps?: Omit<ComponentPropsWithoutRef<"div">, "style">;
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
hideOnEmpty?: boolean;
items: (DropdownItem | undefined | boolean)[];
itemsClassName?: string;
children: ReactElement<{ onClick: () => void }>;
children: DropdownClickableChild;
onClickItem?: (item: DropdownItem) => void;
renderItem?: (
item: DropdownItem,
props: { key: number; onClick: () => void }
) => ReactElement<{ onClick: () => void }>;
) => DropdownClickableChild;
};
export function Dropdown({
children,
defaultOpen = false,
position = "bottom-start",
openEvent = "onClick",
position: initialPosition = "bottom-start",
dropdownWrapperProps,
items,
title,
hideOnEmpty = true,
onClickItem,
renderItem,
@@ -41,19 +55,58 @@ export function Dropdown({
className
}: DropdownProps) {
const [open, setOpen] = useState(defaultOpen);
const [position, setPosition] = useState(initialPosition);
const clickoutsideRef = useClickOutside(() => setOpen(false));
const menuItems = items.filter(Boolean) as DropdownItem[];
const [_offset, _setOffset] = useState(0);
const toggle = useEvent((delay: number = 50) =>
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
);
const onClickHandler = openEvent === "onClick" ? toggle : undefined;
const onContextMenuHandler = useEvent((e) => {
if (openEvent !== "onContextMenu") return;
e.preventDefault();
if (open) {
toggle(0);
setTimeout(() => {
setPosition(initialPosition);
_setOffset(0);
}, 10);
return;
}
// minimal popper impl, get pos and boundaries
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {};
// only if boundaries gien
if (left > 0 && right > 0) {
const safe = clampNumber(x, left, right);
// if pos less than half, go left
if (x < (left + right) / 2) {
setPosition("bottom-start");
_setOffset(safe);
} else {
setPosition("bottom-end");
_setOffset(right - safe);
}
} else {
setPosition(initialPosition);
_setOffset(0);
}
toggle();
});
const offset = 4;
const dropdownStyle = {
"bottom-start": { top: "100%", left: 0, marginTop: offset },
"bottom-end": { right: 0, top: "100%", marginTop: offset },
"bottom-start": { top: "100%", left: _offset, marginTop: offset },
"bottom-end": { right: _offset, top: "100%", marginTop: offset },
"top-start": { bottom: "100%", marginBottom: offset },
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
"top-end": { bottom: "100%", right: _offset, marginBottom: offset }
}[position];
const internalOnClickItem = useEvent((item) => {
@@ -94,13 +147,25 @@ export function Dropdown({
));
return (
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
{cloneElement(children as any, { onClick: toggle })}
<div
role="dropdown"
className={twMerge("relative flex", className)}
ref={clickoutsideRef}
onContextMenu={onContextMenuHandler}
>
{cloneElement(children as any, { onClick: onClickHandler })}
{open && (
<div
className="absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full"
{...dropdownWrapperProps}
className={twMerge(
"absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full",
dropdownWrapperProps?.className
)}
style={dropdownStyle}
>
{title && (
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
)}
{menuItems.map((item, i) =>
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
)}

View File

@@ -10,6 +10,7 @@ import {
export type TStepsProps = {
children: any;
initialPath?: string[];
initialState?: any;
lastBack?: () => void;
[key: string]: any;
};
@@ -19,13 +20,14 @@ type TStepContext<T = any> = {
stepBack: () => void;
close: () => void;
state: T;
path: string[];
setState: Dispatch<SetStateAction<T>>;
};
const StepContext = createContext<TStepContext>(undefined as any);
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
const [state, setState] = useState<any>({});
export function Steps({ children, initialPath = [], initialState = {}, lastBack }: TStepsProps) {
const [state, setState] = useState<any>(initialState);
const [path, setPath] = useState<string[]>(initialPath);
const steps: any[] = Children.toArray(children).filter(
(child: any) => child.props.disabled !== true
@@ -46,7 +48,7 @@ export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0];
return (
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}>
<StepContext.Provider value={{ nextStep, stepBack, state, path, setState, close: lastBack! }}>
{current}
</StepContext.Provider>
);

View File

@@ -1,13 +1,13 @@
import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx";
import { type TSchema, Type, Value } from "core/utils";
import { Form, type Validator } from "json-schema-form-react";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy";
import { SocialLink } from "ui/modules/auth/SocialLink";
import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string;
@@ -86,7 +86,7 @@ export function AuthForm({
schema={schema}
validator={validator}
validationMode="change"
className={twMerge("flex flex-col gap-3 w-full", className)}
className={clsx("flex flex-col gap-3 w-full", className)}
>
{({ errors, submitting }) => (
<>

View File

@@ -1,8 +1,6 @@
import type { ReactNode } from "react";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { AuthForm } from "ui/modules/auth/AuthForm";
import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm";
export type AuthScreenProps = {
method?: "POST" | "GET";
@@ -18,13 +16,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{typeof logo !== "undefined" ? (
logo
) : (
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
)}
{logo ? logo : null}
{typeof intro !== "undefined" ? (
intro
) : (

View File

@@ -0,0 +1,9 @@
import { AuthForm } from "./AuthForm";
import { AuthScreen } from "./AuthScreen";
import { SocialLink } from "./SocialLink";
export const Auth = {
Screen: AuthScreen,
Form: AuthForm,
SocialLink: SocialLink
};

View File

@@ -0,0 +1,23 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react";
import { useApi } from "ui/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();
const api = useApi(options?.baseUrl);
useEffect(() => {
(async () => {
const res = await api.auth.strategies();
//console.log("res", res);
if (res.res.ok) {
setData(res.body);
}
})();
}, [options?.baseUrl]);
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
};

View File

@@ -1,2 +1,2 @@
export { Auth } from "ui/modules/auth/index";
export { Auth } from "./auth";
export * from "./media";

View File

@@ -1,15 +0,0 @@
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
export const Media = {
Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized
};
export type {
PreviewComponentProps,
FileState,
DropzoneProps,
DropzoneRenderProps
} from "ui/modules/media/components/dropzone/Dropzone";
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";

View File

@@ -1,16 +1,11 @@
import type { RepoQuery } from "data";
import type { RepoQuery, RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import {
Dropzone,
type DropzoneProps,
type DropzoneRenderProps,
type FileState
} from "ui/modules/media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "ui/modules/media/helper";
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element;
@@ -20,7 +15,7 @@ export type DropzoneContainerProps = {
id: number;
field: string;
};
query?: Partial<RepoQuery>;
query?: RepoQueryIn;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
Partial<DropzoneProps>;

View File

@@ -4,7 +4,7 @@
* MIT License (2020 Roland Groza)
*/
import { MIME_TYPES } from "media";
import { guess } from "media/storage/mime-types-tiny";
const FILES_TO_IGNORE = [
// Thumbnail cache files for macOS and Windows
@@ -47,10 +47,8 @@ function withMimeType(file: FileWithPath) {
console.log("withMimeType", name, hasExtension);
if (hasExtension && !file.type) {
const ext = name.split(".").pop()!.toLowerCase();
const type = MIME_TYPES.get(ext);
console.log("withMimeType:in", ext, type);
const type = guess(name);
console.log("guessed", type);
if (type) {
Object.defineProperty(file, "type", {

View File

@@ -1,5 +1,5 @@
import type { MediaFieldSchema } from "media/AppMedia";
import type { FileState } from "./components/dropzone/Dropzone";
import type { FileState } from "./Dropzone";
export function mediaItemToFileState(
item: MediaFieldSchema,

View File

@@ -0,0 +1,15 @@
import { PreviewWrapperMemoized } from "./Dropzone";
import { DropzoneContainer } from "./DropzoneContainer";
export const Media = {
Dropzone: DropzoneContainer,
Preview: PreviewWrapperMemoized
};
export type {
PreviewComponentProps,
FileState,
DropzoneProps,
DropzoneRenderProps
} from "./Dropzone";
export type { DropzoneContainerProps } from "./DropzoneContainer";

View File

@@ -0,0 +1,3 @@
export function twMerge(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
export function useEffectOnce(effect: () => void | (() => void | undefined), deps: any[]): void {
const hasRunRef = useRef(false);
const savedDepsRef = useRef<any[] | undefined>(deps);
useEffect(() => {
const depsChanged = !hasRunRef.current || !areDepsEqual(savedDepsRef.current, deps);
if (depsChanged) {
hasRunRef.current = true;
savedDepsRef.current = deps;
return effect();
}
}, [deps]);
}
function areDepsEqual(prevDeps: any[] | undefined, nextDeps: any[]): boolean {
if (prevDeps && prevDeps.length === 0 && nextDeps.length === 0) {
return true;
}
if (!prevDeps && nextDeps.length === 0) {
return true;
}
if (!prevDeps || !nextDeps || prevDeps.length !== nextDeps.length) {
return false;
}
return prevDeps.every((dep, index) => Object.is(dep, nextDeps[index]));
}

View File

@@ -191,7 +191,7 @@ export const SidebarLink = <E extends React.ElementType = "a">({
className={twMerge(
"flex flex-row px-4 py-2.5 items-center gap-2",
!disabled &&
"cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 link",
"cursor-pointer rounded-md [&.active]:bg-primary/10 [&.active]:hover:bg-primary/15 [&.active]:font-medium hover:bg-primary/5 focus:bg-primary/5 link",
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
className
)}

View File

@@ -1,6 +1,6 @@
import type { PrimaryFieldType } from "core";
import { encodeSearch } from "core/utils";
import { useLocation } from "wouter";
import { useLocation, useRouter } from "wouter";
import { useBknd } from "../client/BkndProvider";
export const routes = {
@@ -55,6 +55,7 @@ export function withAbsolute(url: string) {
export function useNavigate() {
const [location, navigate] = useLocation();
const router = useRouter();
const { app } = useBknd();
const basepath = app.getAdminConfig().basepath;
return [
@@ -69,6 +70,7 @@ export function useNavigate() {
transition?: boolean;
}
| { reload: true }
| { target: string }
) => {
const wrap = (fn: () => void) => {
fn();
@@ -81,9 +83,15 @@ export function useNavigate() {
};
wrap(() => {
if (options && "reload" in options) {
window.location.href = url;
return;
if (options) {
if ("reload" in options) {
window.location.href = url;
return;
} else if ("target" in options) {
const _url = window.location.origin + basepath + router.base + url;
window.open(_url, options.target);
return;
}
}
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;

View File

@@ -1,211 +1,74 @@
@import "./components/form/json-schema/styles.css";
@import "@xyflow/react/dist/style.css";
@import "@mantine/core/styles.css";
@import "@mantine/notifications/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html.fixed,
html.fixed body {
top: 0;
left: 0;
height: 100%;
width: 100%;
position: fixed;
overflow: hidden;
overscroll-behavior-x: contain;
touch-action: none;
}
#bknd-admin,
.bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */
--color-background: 250 250 250; /* zinc-50 */
--color-muted: 228 228 231; /* ? */
--color-darkest: 0 0 0; /* black */
--color-lightest: 255 255 255; /* white */
&.dark {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
@mixin light {
--mantine-color-body: rgb(250 250 250);
}
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
html,
body {
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior-y: none;
}
#bknd-admin {
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
::selection {
@apply bg-muted;
}
::selection {
@apply bg-muted;
}
input {
&::selection {
@apply bg-primary/15;
}
}
input {
&::selection {
@apply bg-primary/15;
}
}
}
body,
#bknd-admin {
@apply flex flex-1 flex-col h-dvh w-dvw;
@apply flex flex-1 flex-col h-dvh w-dvw;
}
@layer components {
.link {
@apply transition-colors active:translate-y-px;
}
.link {
@apply transition-colors active:translate-y-px;
}
.img-responsive {
@apply max-h-full w-auto;
}
.img-responsive {
@apply max-h-full w-auto;
}
/**
* debug classes
*/
.bordered-red {
@apply border-2 border-red-500;
}
/**
* debug classes
*/
.bordered-red {
@apply border-2 border-red-500;
}
.bordered-green {
@apply border-2 border-green-500;
}
.bordered-green {
@apply border-2 border-green-500;
}
.bordered-blue {
@apply border-2 border-blue-500;
}
.bordered-blue {
@apply border-2 border-blue-500;
}
.bordered-violet {
@apply border-2 border-violet-500;
}
.bordered-violet {
@apply border-2 border-violet-500;
}
.bordered-yellow {
@apply border-2 border-yellow-500;
}
}
@layer utilities {
}
/* Hide scrollbar for Chrome, Safari and Opera */
.app-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.app-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
div[data-radix-scroll-area-viewport] > div:first-child {
display: block !important;
min-width: 100% !important;
max-width: 100%;
}
/* hide calendar icon on inputs */
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="date"]::-webkit-calendar-picker-indicator {
display: none;
}
/* cm */
.cm-editor {
display: flex;
flex: 1;
}
.animate-fade-in {
animation: fadeInAnimation 200ms ease;
}
@keyframes fadeInAnimation {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
input[readonly]::placeholder,
input[disabled]::placeholder {
opacity: 0.1;
}
.react-flow__pane,
.react-flow__renderer,
.react-flow__node,
.react-flow__edge {
cursor: inherit !important;
.drag-handle {
cursor: grab;
}
}
.react-flow .react-flow__edge path,
.react-flow__connectionline path {
stroke-width: 2;
}
.mantine-TextInput-wrapper input {
font-family: inherit;
line-height: 1;
}
.cm-editor {
background: transparent;
}
.cm-editor.cm-focused {
outline: none;
}
.flex-animate {
transition: flex-grow 0.2s ease, background-color 0.2s ease;
}
.flex-initial {
flex: 0 1 auto;
}
.flex-open {
flex: 1 1 0;
.bordered-yellow {
@apply border-2 border-yellow-500;
}
}
#bknd-admin,
.bknd-admin {
/* Chrome, Edge, and Safari */
& *::-webkit-scrollbar {
@apply w-1;
&:horizontal {
@apply h-px;
}
}
/* Chrome, Edge, and Safari */
& *::-webkit-scrollbar {
@apply w-1;
&:horizontal {
@apply h-px;
}
}
& *::-webkit-scrollbar-track {
@apply bg-transparent w-1;
}
& *::-webkit-scrollbar-track {
@apply bg-transparent w-1;
}
& *::-webkit-scrollbar-thumb {
@apply bg-primary/25;
}
& *::-webkit-scrollbar-thumb {
@apply bg-primary/25;
}
}

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import * as ReactDOM from "react-dom/client";
import Admin from "./Admin";
import "./main.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>

View File

@@ -0,0 +1,22 @@
import type { ContextModalProps } from "@mantine/modals";
import type { ReactNode } from "react";
export function OverlayModal({
context,
id,
innerProps: { content }
}: ContextModalProps<{ content?: ReactNode }>) {
return content;
}
OverlayModal.defaultTitle = undefined;
OverlayModal.modalProps = {
withCloseButton: false,
classNames: {
size: "md",
root: "bknd-admin",
content: "text-center justify-center",
title: "font-bold !text-md",
body: "py-3 px-5 gap-4 flex flex-col"
}
};

View File

@@ -7,21 +7,31 @@ import {
} from "ui/components/form/json-schema";
import type { ContextModalProps } from "@mantine/modals";
import { Alert } from "ui/components/display/Alert";
type Props = JsonSchemaFormProps & {
onSubmit?: (data: any) => void | Promise<void>;
autoCloseAfterSubmit?: boolean;
onSubmit?: (
data: any,
context: {
close: () => void;
}
) => void | Promise<void>;
};
export function SchemaFormModal({
context,
id,
innerProps: { schema, uiSchema, onSubmit }
innerProps: { schema, uiSchema, onSubmit, autoCloseAfterSubmit }
}: ContextModalProps<Props>) {
const [valid, setValid] = useState(false);
const formRef = useRef<JsonSchemaFormRef>(null);
const [submitting, setSubmitting] = useState(false);
const was_submitted = useRef(false);
const [error, setError] = useState<string>();
function handleChange(data) {
const valid = formRef.current?.validateForm() ?? false;
function handleChange(data, isValid) {
const valid = isValid();
console.log("Data changed", data, valid);
setValid(valid);
}
@@ -30,29 +40,45 @@ export function SchemaFormModal({
context.closeModal(id);
}
async function handleClickAdd() {
await onSubmit?.(formRef.current?.formData());
handleClose();
async function handleSubmit() {
was_submitted.current = true;
if (!formRef.current?.validateForm()) {
return;
}
setSubmitting(true);
await onSubmit?.(formRef.current?.formData(), {
close: handleClose,
setError
});
setSubmitting(false);
if (autoCloseAfterSubmit !== false) {
handleClose();
}
}
return (
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col">
<JsonSchemaForm
tagName="form"
ref={formRef}
schema={schema}
uiSchema={uiSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={handleChange}
onSubmit={handleClickAdd}
/>
<div className="flex flex-row justify-end gap-2">
<Button onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleClickAdd} disabled={!valid}>
Create
</Button>
<>
{error && <Alert.Exception message={error} />}
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col">
<JsonSchemaForm
tagName="form"
ref={formRef}
schema={schema}
uiSchema={uiSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={handleChange}
onSubmit={handleSubmit}
/>
<div className="flex flex-row justify-end gap-2">
<Button onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit} disabled={!valid || submitting}>
Create
</Button>
</div>
</div>
</div>
</>
);
}
@@ -63,7 +89,7 @@ SchemaFormModal.modalProps = {
root: "bknd-admin",
header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px",
content: "rounded-lg select-none",
title: "font-bold !text-md",
title: "!font-bold !text-md",
body: "!p-0"
}
};

View File

@@ -1,7 +1,8 @@
import type { ModalProps } from "@mantine/core";
import { ModalsProvider, modals as mantineModals } from "@mantine/modals";
import { transformObject } from "core/utils";
import { modals as $modals, ModalsProvider, closeModal, openContextModal } from "@mantine/modals";
import type { ComponentProps } from "react";
import { OverlayModal } from "ui/modals/debug/OverlayModal";
import { CreateModal } from "ui/modules/data/components/schema/create-modal/CreateModal";
import { DebugModal } from "./debug/DebugModal";
import { SchemaFormModal } from "./debug/SchemaFormModal";
import { TestModal } from "./debug/TestModal";
@@ -9,7 +10,9 @@ import { TestModal } from "./debug/TestModal";
const modals = {
test: TestModal,
debug: DebugModal,
form: SchemaFormModal
form: SchemaFormModal,
overlay: OverlayModal,
dataCreate: CreateModal
};
declare module "@mantine/modals" {
@@ -33,25 +36,29 @@ function open<Modal extends keyof typeof modals>(
) {
const title = _title ?? modals[modal].defaultTitle ?? undefined;
const cmpModalProps = modals[modal].modalProps ?? {};
return mantineModals.openContextModal({
const props = {
title,
...modalProps,
...cmpModalProps,
modal,
innerProps
});
};
openContextModal(props);
return {
close: () => close(modal),
closeAll: $modals.closeAll
};
}
function close<Modal extends keyof typeof modals>(modal: Modal) {
return mantineModals.close(modal);
return closeModal(modal);
}
export const bkndModals = {
ids: transformObject(modals, (key) => key) as unknown as Record<
keyof typeof modals,
keyof typeof modals
>,
ids: Object.fromEntries(Object.keys(modals).map((key) => [key, key])) as {
[K in keyof typeof modals]: K;
},
open,
close,
closeAll: mantineModals.closeAll
closeAll: $modals.closeAll
};

View File

@@ -0,0 +1,53 @@
import { useApi, useInvalidate } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals";
export function useCreateUserModal() {
const api = useApi();
const { config } = useBkndAuth();
const invalidate = useInvalidate();
const [navigate] = useNavigate();
const open = async () => {
const loading = bkndModals.open("overlay", {
content: "Loading..."
});
const schema = await api.auth.actionSchema("password", "create");
loading.closeAll(); // currently can't close by id...
bkndModals.open(
"form",
{
schema,
uiSchema: {
password: {
"ui:widget": "password"
}
},
autoCloseAfterSubmit: false,
onSubmit: async (data, ctx) => {
console.log("submitted:", data, ctx);
const res = await api.auth.action("password", "create", data);
console.log(res);
if (res.ok) {
// invalidate all data
invalidate();
navigate(routes.data.entity.edit(config.entity_name, res.data.id));
ctx.close();
} else if ("error" in res) {
ctx.setError(res.error);
} else {
ctx.setError("Unknown error");
}
}
},
{
title: "Create User"
}
);
};
return { open };
}

View File

@@ -1,9 +0,0 @@
import { AuthForm } from "ui/modules/auth/AuthForm";
import { AuthScreen } from "ui/modules/auth/AuthScreen";
import { SocialLink } from "ui/modules/auth/SocialLink";
export const Auth = {
Screen: AuthScreen,
Form: AuthForm,
SocialLink: SocialLink
};

View File

@@ -34,7 +34,11 @@ export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
const field = getField(property)!;
_value = field.getValue(value, "table");
} catch (e) {
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e);
console.warn(
"Couldn't render value",
{ value, property, entity, select, columns, ...props },
e
);
}
return <CellValue value={_value} property={property} />;

View File

@@ -10,7 +10,7 @@ import {
TbToggleLeft
} from "react-icons/tb";
type TFieldSpec = {
export type TFieldSpec = {
type: string;
label: string;
icon: any;

View File

@@ -9,6 +9,7 @@ import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { Popover } from "ui/components/overlay/Popover";
import { Link } from "ui/components/wouter/Link";
import { routes } from "ui/lib/routes";
import { useLocation } from "wouter";
import { EntityTable } from "../EntityTable";
@@ -82,7 +83,9 @@ export function EntityRelationalFormField({
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<Formy.Label htmlFor={fieldApi.name}>
{field.getLabel({ fallback: false }) ?? entity.label}
</Formy.Label>
<div
data-disabled={fetching || disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
@@ -152,9 +155,11 @@ export function EntityRelationalFormField({
);
})}
</div>
<Button IconLeft={TbEye} onClick={handleViewItem} size="small">
View
</Button>
<Link to={routes.data.entity.edit(entity.name, _value.id as any)}>
<Button IconLeft={TbEye} size="small">
View
</Button>
</Link>
</>
) : (
<div className="pl-2">- Select -</div>

View File

@@ -1,15 +1,9 @@
import { type Static, StringEnum, StringIdentifier, Type, transformObject } from "core/utils";
import { FieldClassMap } from "data";
import type { ModalProps } from "@mantine/core";
import type { ContextModalProps } from "@mantine/modals";
import { type Static, StringEnum, StringIdentifier, Type } from "core/utils";
import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, useState } from "react";
import {
Modal2,
type Modal2Ref,
ModalBody,
ModalFooter,
ModalTitle
} from "ui/components/modal/Modal2";
import { useState } from "react";
import { type Modal2Ref, ModalBody, ModalFooter, ModalTitle } from "ui/components/modal/Modal2";
import { Step, Steps, useStepContext } from "ui/components/steps/Steps";
import { StepCreate } from "ui/modules/data/components/schema/create-modal/step.create";
import { StepEntity } from "./step.entity";
@@ -45,6 +39,7 @@ export type TFieldCreate = Static<typeof createFieldSchema>;
const createModalSchema = Type.Object(
{
action: schemaAction,
initial: Type.Optional(Type.Any()),
entities: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(entitySchema))
@@ -67,48 +62,59 @@ const createModalSchema = Type.Object(
);
export type TCreateModalSchema = Static<typeof createModalSchema>;
export const CreateModal = forwardRef<CreateModalRef>(function CreateModal(props, ref) {
const [path, setPath] = useState<string[]>([]);
export function CreateModal({
context,
id,
innerProps: { initialPath = [], initialState }
}: ContextModalProps<{ initialPath?: string[]; initialState?: TCreateModalSchema }>) {
const [path, setPath] = useState<string[]>(initialPath);
console.log("...", initialPath, initialState);
function close() {
// @ts-ignore
ref?.current?.close();
context.closeModal(id);
}
return (
<Modal2 ref={ref}>
<Steps path={path} lastBack={close}>
<Step id="select">
<ModalTitle path={["Create New"]} onClose={close} />
<StepSelect />
</Step>
<Step id="entity" path={["action"]}>
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
<StepEntity />
</Step>
<Step id="entity-fields" path={["action", "entity"]}>
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
<StepEntityFields />
</Step>
<Step id="relation" path={["action"]}>
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
<StepRelation />
</Step>
<Step id="create" path={["action"]}>
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
<StepCreate />
</Step>
<Steps path={path} lastBack={close} initialPath={initialPath} initialState={initialState}>
<Step id="select">
<ModalTitle path={["Create New"]} onClose={close} />
<StepSelect />
</Step>
<Step id="entity" path={["action"]}>
<ModalTitle path={["Create New", "Entity"]} onClose={close} />
<StepEntity />
</Step>
<Step id="entity-fields" path={["action", "entity"]}>
<ModalTitle path={["Create New", "Entity", "Fields"]} onClose={close} />
<StepEntityFields />
</Step>
<Step id="relation" path={["action"]}>
<ModalTitle path={["Create New", "Relation"]} onClose={close} />
<StepRelation />
</Step>
<Step id="create" path={["action"]}>
<ModalTitle path={["Create New", "Creation"]} onClose={close} />
<StepCreate />
</Step>
{/* Templates */}
{Templates.map(([Component, meta]) => (
<Step key={meta.id} id={meta.id} path={["action"]}>
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
<Component />
</Step>
))}
</Steps>
</Modal2>
{/* Templates */}
{Templates.map(([Component, meta]) => (
<Step key={meta.id} id={meta.id} path={["action"]}>
<ModalTitle path={["Create New", "Template", meta.title]} onClose={close} />
<Component />
</Step>
))}
</Steps>
);
});
}
CreateModal.defaultTitle = undefined;
CreateModal.modalProps = {
withCloseButton: false,
size: "xl",
padding: 0,
classNames: {
root: "bknd-admin"
}
} satisfies Partial<ModalProps>;
export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema };

View File

@@ -8,8 +8,8 @@ import {
} from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { useEffect, useState } from "react";
import { TbCirclesRelation, TbSettings } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { IconButton, type IconType } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
@@ -26,6 +26,7 @@ export function StepCreate() {
const [states, setStates] = useState<(boolean | string)[]>([]);
const [submitting, setSubmitting] = useState(false);
const $data = useBkndData();
const b = useBknd();
const items: ActionItem[] = [];
if (state.entities?.create) {
@@ -74,6 +75,10 @@ export function StepCreate() {
try {
const res = await item.run();
setStates((prev) => [...prev, res]);
if (res !== true) {
// make sure to break out
break;
}
} catch (e) {
setStates((prev) => [...prev, (e as any).message]);
}
@@ -90,7 +95,8 @@ export function StepCreate() {
states.every((s) => s === true)
);
if (items.length === states.length && states.every((s) => s === true)) {
close();
b.actions.reload().then(close);
//close();
} else {
setSubmitting(false);
}
@@ -144,12 +150,14 @@ const SummaryItem: React.FC<SummaryItemProps> = ({
}) => {
const [expanded, handlers] = useDisclosure(initialExpanded);
const error = typeof state !== "undefined" && state !== true;
const done = state === true;
return (
<div
className={twMerge(
"flex flex-col border border-muted rounded bg-background mb-2",
error && "bg-red-500/20"
error && "bg-red-500/20",
done && "bg-green-500/20"
)}
>
<div className="flex flex-row gap-4 px-2 py-2 items-center">

View File

@@ -9,12 +9,15 @@ import {
registerCustomTypeboxKinds
} from "core/utils";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
import { type ReactNode, useEffect } from "react";
import { type ReactNode, startTransition, useEffect } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { TbRefresh } from "react-icons/tb";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useStepContext } from "ui/components/steps/Steps";
import { useEvent } from "ui/hooks/use-event";
import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal";
// @todo: check if this could become an issue
@@ -63,7 +66,7 @@ type ComponentCtx<T extends FieldValues = FieldValues> = {
export function StepRelation() {
const { config } = useBknd();
const entities = config.data.entities;
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { nextStep, stepBack, state, path, setState } = useStepContext<TCreateModalSchema>();
const {
register,
handleSubmit,
@@ -93,6 +96,22 @@ export function StepRelation() {
}
}
const flip = useEvent(() => {
const { source, target } = data;
if (source && target) {
setValue("source", target);
setValue("target", source);
} else {
if (source) {
setValue("target", source);
setValue("source", null as any);
} else {
setValue("source", target);
setValue("target", null as any);
}
}
});
return (
<>
<form onSubmit={handleSubmit(handleNext)}>
@@ -109,14 +128,23 @@ export function StepRelation() {
disabled: data.target === name
}))}
/>
<MantineSelect
control={control}
name="type"
onChange={() => setValue("config", {})}
label="Relation Type"
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
allowDeselect={false}
/>
<div className="flex flex-col gap-1">
<MantineSelect
control={control}
name="type"
onChange={() => setValue("config", {})}
label="Relation Type"
data={Relations.map((r) => ({ value: r.type, label: r.label }))}
allowDeselect={false}
/>
{data.type && (
<div className="flex justify-center mt-1">
<Button size="small" IconLeft={TbRefresh} onClick={flip}>
Flip entities
</Button>
</div>
)}
</div>
<MantineSelect
control={control}
allowDeselect={false}
@@ -146,7 +174,7 @@ export function StepRelation() {
onClick: handleNext
}}
prev={{ onClick: stepBack }}
debug={{ state, data }}
debug={{ state, path, data }}
/>
</form>
</>

View File

@@ -12,7 +12,7 @@ import {
import Templates from "./templates/register";
export function StepSelect() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { nextStep, stepBack, state, path, setState } = useStepContext<TCreateModalSchema>();
const selected = state.action ?? null;
function handleSelect(action: TSchemaAction) {
@@ -74,6 +74,7 @@ export function StepSelect() {
}}
prev={{ onClick: stepBack }}
prevLabel="Cancel"
debug={{ state, path }}
/>
</>
);

View File

@@ -9,6 +9,7 @@ import {
transformObject
} from "core/utils";
import type { MediaFieldConfig } from "media/MediaField";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useBknd } from "ui/client/bknd";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
@@ -31,18 +32,19 @@ const schema = Type.Object({
type TCreateModalMediaSchema = Static<typeof schema>;
export function TemplateMediaComponent() {
const { stepBack, setState, state, nextStep } = useStepContext<TCreateModalSchema>();
const { stepBack, setState, state, path, nextStep } = useStepContext<TCreateModalSchema>();
const {
register,
handleSubmit,
formState: { isValid },
setValue,
formState: { isValid, errors },
watch,
control
} = useForm({
mode: "onChange",
resolver: typeboxResolver(schema),
defaultValues: Default(schema, {}) as TCreateModalMediaSchema
defaultValues: Default(schema, state.initial ?? {}) as TCreateModalMediaSchema
});
const [forbidden, setForbidden] = useState<boolean>(false);
const { config } = useBknd();
const media_enabled = config.media.enabled ?? false;
@@ -51,13 +53,16 @@ export function TemplateMediaComponent() {
name !== media_entity ? entity : undefined
);
const data = watch();
const forbidden_field_names = Object.keys(config.data.entities?.[data.entity]?.fields ?? {});
useEffect(() => {
setForbidden(forbidden_field_names.includes(data.name));
}, [forbidden_field_names, data.name]);
async function handleCreate() {
if (isValid) {
console.log("data", data);
if (isValid && !forbidden) {
const { field, relation } = convert(media_entity, data);
console.log("state", { field, relation });
setState((prev) => ({
...prev,
fields: { create: [field] },
@@ -120,6 +125,13 @@ export function TemplateMediaComponent() {
data.entity ? data.entity : "the entity"
}.`}
{...register("name")}
error={
errors.name?.message
? errors.name?.message
: forbidden
? `Property "${data.name}" already exists on entity ${data.entity}`
: undefined
}
/>
</div>
{/*<p>step template media</p>
@@ -129,12 +141,12 @@ export function TemplateMediaComponent() {
<ModalFooter
next={{
type: "submit",
disabled: !isValid || !media_enabled
disabled: !isValid || !media_enabled || forbidden
}}
prev={{
onClick: stepBack
}}
debug={{ state, data }}
debug={{ state, path, data }}
/>
</form>
</>

Some files were not shown because too many files have changed in this diff Show More