mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/release/0.10' into feat/add-postgres-and-prepare-others
# Conflicts: # app/package.json # bun.lock
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { getRuntimeKey, isNode } from "core/utils";
|
||||
import { isNode } from "core/utils";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||
cleanRequest?: { searchParams?: string[] };
|
||||
};
|
||||
|
||||
type NextjsContext = {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
let app: App;
|
||||
let building: boolean = false;
|
||||
|
||||
export async function getApp(config: NextjsBkndConfig) {
|
||||
export async function getApp<Args extends NextjsContext = NextjsContext>(
|
||||
config: NextjsBkndConfig,
|
||||
args?: Args,
|
||||
) {
|
||||
if (building) {
|
||||
while (building) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
@@ -19,7 +26,7 @@ export async function getApp(config: NextjsBkndConfig) {
|
||||
|
||||
building = true;
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
app = await createFrameworkApp(config, args);
|
||||
await app.build();
|
||||
}
|
||||
building = false;
|
||||
@@ -52,7 +59,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await getApp(config);
|
||||
app = await getApp(config, { env: process.env ?? {} });
|
||||
}
|
||||
const request = getCleanRequest(req, cleanRequest);
|
||||
return app.fetch(request);
|
||||
|
||||
@@ -30,7 +30,11 @@ export async function attachServeStatic(app: any, platform: Platform) {
|
||||
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
|
||||
}
|
||||
|
||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||
export async function startServer(
|
||||
server: Platform,
|
||||
app: any,
|
||||
options: { port: number; open?: boolean },
|
||||
) {
|
||||
const port = options.port;
|
||||
console.log(`Using ${server} serve`);
|
||||
|
||||
@@ -55,7 +59,9 @@ export async function startServer(server: Platform, app: any, options: { port: n
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
console.info("Server listening on", url);
|
||||
await open(url);
|
||||
if (options.open) {
|
||||
await open(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigPath(filePath?: string) {
|
||||
|
||||
@@ -47,6 +47,7 @@ export const run: CliCommand = (program) => {
|
||||
.choices(PLATFORMS)
|
||||
.default(isBun ? "bun" : "node"),
|
||||
)
|
||||
.addOption(new Option("--no-open", "don't open browser window on start"))
|
||||
.action(action);
|
||||
};
|
||||
|
||||
@@ -110,6 +111,7 @@ async function action(options: {
|
||||
dbUrl?: string;
|
||||
dbToken?: string;
|
||||
server: Platform;
|
||||
open?: boolean;
|
||||
}) {
|
||||
colorizeConsole(console);
|
||||
const configFilePath = await getConfigPath(options.config);
|
||||
@@ -145,5 +147,5 @@ async function action(options: {
|
||||
});
|
||||
}
|
||||
|
||||
await startServer(options.server, app, { port: options.port });
|
||||
await startServer(options.server, app, { port: options.port, open: options.open });
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DebugLogger {
|
||||
|
||||
const now = performance.now();
|
||||
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
|
||||
const indents = " ".repeat(this._context.length);
|
||||
const indents = " ".repeat(Math.max(this._context.length - 1, 0));
|
||||
const context =
|
||||
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
|
||||
console.log(indents, context, time, ...args);
|
||||
|
||||
@@ -118,3 +118,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function slugify(str: string): string {
|
||||
return (
|
||||
String(str)
|
||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
|
||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||
.trim() // trim leading or trailing whitespace
|
||||
.toLowerCase() // convert to lowercase
|
||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // remove consecutive hyphens
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
indices: _indices = {},
|
||||
} = this.config;
|
||||
|
||||
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
|
||||
|
||||
const entities = transformObject(_entities, (entityConfig, name) => {
|
||||
return constructEntity(name, entityConfig);
|
||||
});
|
||||
@@ -60,7 +58,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
);
|
||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||
|
||||
this.ctx.logger.clear();
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const __bknd = entity(TABLE_NAME, {
|
||||
export const __bknd = entity(TABLE_NAME, {
|
||||
version: number().required(),
|
||||
type: enumm({ enum: ["config", "diff", "backup"] }).required(),
|
||||
json: jsonSchema({ schema: configJsonSchema }).required(),
|
||||
@@ -170,6 +170,8 @@ export class ModuleManager {
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log("booted with", this._booted_with);
|
||||
|
||||
this.createModules(initial);
|
||||
}
|
||||
|
||||
@@ -218,7 +220,8 @@ export class ModuleManager {
|
||||
|
||||
private repo() {
|
||||
return this.__em.repo(__bknd, {
|
||||
silent: !debug_modules,
|
||||
// to prevent exceptions when table doesn't exist
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +274,7 @@ export class ModuleManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async fetch(): Promise<ConfigTable> {
|
||||
private async fetch(): Promise<ConfigTable | undefined> {
|
||||
this.logger.context("fetch").log("fetching");
|
||||
const startTime = performance.now();
|
||||
|
||||
@@ -285,7 +288,7 @@ export class ModuleManager {
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("error fetching").clear();
|
||||
throw BkndError.with("no config");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger
|
||||
@@ -305,6 +308,7 @@ export class ModuleManager {
|
||||
|
||||
try {
|
||||
const state = await this.fetch();
|
||||
if (!state) throw new BkndError("save: no config found");
|
||||
this.logger.log("fetched version", state.version);
|
||||
|
||||
if (state.version !== version) {
|
||||
@@ -321,11 +325,11 @@ export class ModuleManager {
|
||||
json: configs,
|
||||
});
|
||||
} else {
|
||||
this.logger.log("version matches");
|
||||
this.logger.log("version matches", state.version);
|
||||
|
||||
// clean configs because of Diff() function
|
||||
const diffs = diff(state.json, clone(configs));
|
||||
this.logger.log("checking diff", diffs);
|
||||
this.logger.log("checking diff", [diffs.length]);
|
||||
|
||||
if (diff.length > 0) {
|
||||
// store diff
|
||||
@@ -380,78 +384,6 @@ export class ModuleManager {
|
||||
return this;
|
||||
}
|
||||
|
||||
private async migrate() {
|
||||
const state = {
|
||||
success: false,
|
||||
migrated: false,
|
||||
version: {
|
||||
before: this.version(),
|
||||
after: this.version(),
|
||||
},
|
||||
};
|
||||
this.logger.context("migrate").log("migrating?", this.version(), CURRENT_VERSION);
|
||||
|
||||
if (this.version() < CURRENT_VERSION) {
|
||||
state.version.before = this.version();
|
||||
|
||||
this.logger.log("there are migrations, verify version");
|
||||
// sync __bknd table
|
||||
await this.syncConfigTable();
|
||||
|
||||
// modules must be built before migration
|
||||
this.logger.log("building modules");
|
||||
await this.buildModules({ graceful: true });
|
||||
this.logger.log("modules built");
|
||||
|
||||
try {
|
||||
const state = await this.fetch();
|
||||
if (state.version !== this.version()) {
|
||||
// @todo: potentially drop provided config and use database version
|
||||
throw new Error(
|
||||
`Given version (${this.version()}) and fetched version (${state.version}) do not match.`,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`);
|
||||
}
|
||||
|
||||
this.logger.log("now migrating");
|
||||
let version = this.version();
|
||||
let configs: any = this.configs();
|
||||
//console.log("migrating with", version, configs);
|
||||
if (Object.keys(configs).length === 0) {
|
||||
throw new Error("No config to migrate");
|
||||
}
|
||||
|
||||
const [_version, _configs] = await migrate(version, configs, {
|
||||
db: this.db,
|
||||
});
|
||||
version = _version;
|
||||
configs = _configs;
|
||||
|
||||
this._version = version;
|
||||
state.version.after = version;
|
||||
state.migrated = true;
|
||||
this.ctx().flags.sync_required = true;
|
||||
|
||||
this.logger.log("setting configs");
|
||||
this.createModules(configs);
|
||||
await this.buildModules();
|
||||
|
||||
this.logger.log("migrated to", version);
|
||||
$console.log("Migrated config from", state.version.before, "to", state.version.after);
|
||||
|
||||
await this.save();
|
||||
} else {
|
||||
this.logger.log("no migrations needed");
|
||||
}
|
||||
|
||||
state.success = true;
|
||||
this.logger.clear();
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private setConfigs(configs: ModuleConfigs): void {
|
||||
this.logger.log("setting configs");
|
||||
objectEach(configs, (config, key) => {
|
||||
@@ -469,66 +401,66 @@ export class ModuleManager {
|
||||
|
||||
async build(opts?: { fetch?: boolean }) {
|
||||
this.logger.context("build").log("version", this.version());
|
||||
this.logger.log("booted with", this._booted_with);
|
||||
|
||||
// if no config provided, try fetch from db
|
||||
if (this.version() === 0 || opts?.fetch === true) {
|
||||
if (this.version() === 0) {
|
||||
this.logger.context("no version").log("version is 0");
|
||||
} else {
|
||||
this.logger.context("force fetch").log("force fetch");
|
||||
if (opts?.fetch) {
|
||||
this.logger.log("force fetch");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.fetch();
|
||||
const result = await this.fetch();
|
||||
|
||||
// if no version, and nothing found, go with initial
|
||||
if (!result) {
|
||||
this.logger.log("nothing in database, go initial");
|
||||
await this.setupInitial();
|
||||
} else {
|
||||
this.logger.log("db has", result.version);
|
||||
// set version and config from fetched
|
||||
this._version = result.version;
|
||||
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
await this.syncConfigTable();
|
||||
}
|
||||
|
||||
if (this.options?.trustFetched === true) {
|
||||
this.logger.log("trusting fetched config (mark)");
|
||||
mark(result.json);
|
||||
}
|
||||
|
||||
this.setConfigs(result.json);
|
||||
} catch (e: any) {
|
||||
this.logger.clear(); // fetch couldn't clear
|
||||
// if version doesn't match, migrate before building
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
this.logger.log("now migrating");
|
||||
|
||||
this.logger.context("error handler").log("fetch failed", e.message);
|
||||
await this.syncConfigTable();
|
||||
|
||||
// we can safely build modules, since config version is up to date
|
||||
// it's up to date because we use default configs (no fetch result)
|
||||
this._version = CURRENT_VERSION;
|
||||
await this.syncConfigTable();
|
||||
const state = await this.buildModules();
|
||||
if (!state.saved) {
|
||||
await this.save();
|
||||
const version_before = this.version();
|
||||
const [_version, _configs] = await migrate(version_before, result.json, {
|
||||
db: this.db,
|
||||
});
|
||||
|
||||
this._version = _version;
|
||||
this.ctx().flags.sync_required = true;
|
||||
|
||||
this.logger.log("migrated to", _version);
|
||||
$console.log("Migrated config from", version_before, "to", this.version());
|
||||
|
||||
this.createModules(_configs);
|
||||
await this.buildModules();
|
||||
} else {
|
||||
this.logger.log("version is current", this.version());
|
||||
this.createModules(result.json);
|
||||
await this.buildModules();
|
||||
}
|
||||
|
||||
// run initial setup
|
||||
await this.setupInitial();
|
||||
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
this.logger.clear();
|
||||
}
|
||||
|
||||
// migrate to latest if needed
|
||||
this.logger.log("check migrate");
|
||||
const migration = await this.migrate();
|
||||
if (migration.success && migration.migrated) {
|
||||
this.logger.log("skipping build after migration");
|
||||
} else {
|
||||
this.logger.log("trigger build modules");
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
throw new Error(
|
||||
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
|
||||
);
|
||||
}
|
||||
this.logger.log("current version is up to date", this.version());
|
||||
await this.buildModules();
|
||||
}
|
||||
|
||||
this.logger.log("done");
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -589,6 +521,14 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
protected async setupInitial() {
|
||||
this.logger.context("initial").log("start");
|
||||
this._version = CURRENT_VERSION;
|
||||
await this.syncConfigTable();
|
||||
const state = await this.buildModules();
|
||||
if (!state.saved) {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
...this.ctx(),
|
||||
// disable events for initial setup
|
||||
@@ -601,6 +541,7 @@ export class ModuleManager {
|
||||
|
||||
// run first boot event
|
||||
await this.options?.onFirstBoot?.();
|
||||
this.logger.clear();
|
||||
}
|
||||
|
||||
mutateConfigSafe<Module extends keyof Modules>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { _jsonp, transformObject } from "core/utils";
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import { set } from "lodash-es";
|
||||
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
||||
|
||||
export type MigrationContext = {
|
||||
db: Kysely<any>;
|
||||
@@ -91,6 +92,17 @@ export const migrations: Migration[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// remove admin config
|
||||
version: 9,
|
||||
up: async (config) => {
|
||||
const { admin, ...server } = config.server;
|
||||
return {
|
||||
...config,
|
||||
server,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { config, isDebug } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { html } from "hono/html";
|
||||
import { Fragment } from "hono/jsx";
|
||||
import { css, Style } from "hono/css";
|
||||
import { Controller } from "modules/Controller";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppTheme } from "modules/server/AppServer";
|
||||
|
||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||
|
||||
@@ -73,7 +73,6 @@ export class AdminController extends Controller {
|
||||
const obj = {
|
||||
user: c.get("auth")?.user,
|
||||
logout_route: this.withBasePath(authRoutes.logout),
|
||||
color_scheme: configs.server.admin.color_scheme,
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
if (!html) {
|
||||
@@ -183,14 +182,13 @@ export class AdminController extends Controller {
|
||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||
}
|
||||
|
||||
const theme = configs.server.admin.color_scheme ?? "light";
|
||||
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* dnd complains otherwise */}
|
||||
{html`<!DOCTYPE html>`}
|
||||
<html lang="en" class={theme}>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
@@ -229,10 +227,9 @@ export class AdminController extends Controller {
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="loading" style={style(theme)}>
|
||||
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}>
|
||||
Initializing...
|
||||
</span>
|
||||
<Style />
|
||||
<div id="loading" className={wrapperStyle}>
|
||||
<span className={loaderStyle}>Initializing...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script
|
||||
@@ -248,31 +245,27 @@ export class AdminController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: AppTheme) => {
|
||||
const base = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
"-webkit-font-smoothing": "antialiased",
|
||||
"-moz-osx-font-smoothing": "grayscale",
|
||||
};
|
||||
const styles = {
|
||||
light: {
|
||||
color: "rgb(9,9,11)",
|
||||
backgroundColor: "rgb(250,250,250)",
|
||||
},
|
||||
dark: {
|
||||
color: "rgb(250,250,250)",
|
||||
backgroundColor: "rgb(30,31,34)",
|
||||
},
|
||||
};
|
||||
const wrapperStyle = css`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: rgb(9,9,11);
|
||||
background-color: rgb(250,250,250);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: rgb(250,250,250);
|
||||
background-color: rgb(30,31,34);
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...styles[theme === "light" ? "light" : "dark"],
|
||||
};
|
||||
};
|
||||
const loaderStyle = css`
|
||||
opacity: 0.3;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
`;
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { Exception } from "core";
|
||||
import { Exception, isDebug } from "core";
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import { cors } from "hono/cors";
|
||||
import { Module } from "modules/Module";
|
||||
|
||||
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
const appThemes = ["dark", "light", "system"] as const;
|
||||
export type AppTheme = (typeof appThemes)[number];
|
||||
|
||||
export const serverConfigSchema = Type.Object(
|
||||
{
|
||||
admin: Type.Object(
|
||||
{
|
||||
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
|
||||
color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
|
||||
logo_return_path: Type.Optional(
|
||||
Type.String({
|
||||
default: "/",
|
||||
description: "Path to return to after *clicking* the logo",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ default: {}, additionalProperties: false },
|
||||
),
|
||||
cors: Type.Object(
|
||||
{
|
||||
origin: Type.String({ default: "*" }),
|
||||
@@ -43,12 +28,6 @@ export const serverConfigSchema = Type.Object(
|
||||
|
||||
export type AppServerConfig = Static<typeof serverConfigSchema>;
|
||||
|
||||
/*declare global {
|
||||
interface Request {
|
||||
cf: IncomingRequestCfProperties;
|
||||
}
|
||||
}*/
|
||||
|
||||
export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
//private admin_html?: string;
|
||||
|
||||
@@ -102,6 +81,12 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
return c.json(err.toJSON(), err.code as any);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (isDebug()) {
|
||||
return c.json({ error: err.message, stack: err.stack }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: err.message }, 500);
|
||||
});
|
||||
this.setBuilt();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import React from "react";
|
||||
import { BkndProvider, useBknd } from "ui/client/bknd";
|
||||
import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { ClientProvider, type ClientProviderProps } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { BkndModalsProvider } from "./modals";
|
||||
@@ -15,7 +13,7 @@ import { Routes } from "./routes";
|
||||
export type BkndAdminProps = {
|
||||
baseUrl?: string;
|
||||
withProvider?: boolean | ClientProviderProps;
|
||||
config?: ModuleConfigs["server"]["admin"];
|
||||
config?: BkndAdminOptions;
|
||||
};
|
||||
|
||||
export default function Admin({
|
||||
@@ -24,7 +22,7 @@ export default function Admin({
|
||||
config,
|
||||
}: BkndAdminProps) {
|
||||
const Component = (
|
||||
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}>
|
||||
<BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
|
||||
<AdminInternal />
|
||||
</BkndProvider>
|
||||
);
|
||||
@@ -46,7 +44,6 @@ function AdminInternal() {
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||
<Notifications position="top-right" />
|
||||
<FlashMessage />
|
||||
<BkndModalsProvider>
|
||||
<Routes />
|
||||
</BkndModalsProvider>
|
||||
|
||||
@@ -4,7 +4,13 @@ import { createContext, startTransition, useContext, useEffect, useRef, useState
|
||||
import { useApi } from "ui/client";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
import type { AppTheme } from "ui/client/use-theme";
|
||||
|
||||
export type BkndAdminOptions = {
|
||||
logo_return_path?: string;
|
||||
basepath?: string;
|
||||
theme?: AppTheme;
|
||||
};
|
||||
type BkndContext = {
|
||||
version: number;
|
||||
schema: ModuleSchemas;
|
||||
@@ -14,7 +20,7 @@ type BkndContext = {
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
adminOverride?: ModuleConfigs["server"]["admin"];
|
||||
options: BkndAdminOptions;
|
||||
fallback: boolean;
|
||||
};
|
||||
|
||||
@@ -29,19 +35,21 @@ enum Fetching {
|
||||
|
||||
export function BkndProvider({
|
||||
includeSecrets = false,
|
||||
adminOverride,
|
||||
options,
|
||||
children,
|
||||
fallback = null,
|
||||
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick<
|
||||
BkndContext,
|
||||
"adminOverride"
|
||||
>) {
|
||||
}: {
|
||||
includeSecrets?: boolean;
|
||||
children: any;
|
||||
fallback?: React.ReactNode;
|
||||
options?: BkndAdminOptions;
|
||||
}) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [error, setError] = useState<boolean>();
|
||||
const errorShown = useRef<boolean>();
|
||||
const errorShown = useRef<boolean>(false);
|
||||
const fetching = useRef<Fetching>(Fetching.None);
|
||||
const [local_version, set_local_version] = useState(0);
|
||||
const api = useApi();
|
||||
@@ -93,19 +101,14 @@ export function BkndProvider({
|
||||
fallback: true,
|
||||
} as any);
|
||||
|
||||
if (adminOverride) {
|
||||
newSchema.config.server.admin = {
|
||||
...newSchema.config.server.admin,
|
||||
...adminOverride,
|
||||
};
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setSchema(newSchema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
fetching.current = Fetching.None;
|
||||
document.startViewTransition(() => {
|
||||
setSchema(newSchema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
fetching.current = Fetching.None;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,13 +123,13 @@ export function BkndProvider({
|
||||
}, []);
|
||||
|
||||
if (!fetched || !schema) return fallback;
|
||||
const app = new AppReduced(schema?.config as any);
|
||||
const app = new AppReduced(schema?.config as any, options);
|
||||
const actions = getSchemaActions({ api, setSchema, reloadSchema });
|
||||
const hasSecrets = withSecrets && !error;
|
||||
|
||||
return (
|
||||
<BkndContext.Provider
|
||||
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }}
|
||||
value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
|
||||
key={local_version}
|
||||
>
|
||||
{/*{error && (
|
||||
@@ -151,3 +154,12 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useBkndOptions(): BkndAdminOptions {
|
||||
const ctx = useContext(BkndContext);
|
||||
return (
|
||||
ctx.options ?? {
|
||||
basepath: "/",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||
import { isDebug } from "core";
|
||||
import type { AppTheme } from "modules/server/AppServer";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||
@@ -62,7 +61,6 @@ export const useBaseUrl = () => {
|
||||
type BkndWindowContext = {
|
||||
user?: TApiUser;
|
||||
logout_route: string;
|
||||
color_scheme?: AppTheme;
|
||||
};
|
||||
export function useBkndWindowContext(): BkndWindowContext {
|
||||
if (typeof window !== "undefined" && window.__BKND__) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { BkndProvider, useBknd } from "./BkndProvider";
|
||||
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";
|
||||
|
||||
@@ -29,13 +29,3 @@ export function useBkndSystem() {
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
export function useBkndSystemTheme() {
|
||||
const $sys = useBkndSystem();
|
||||
|
||||
return {
|
||||
theme: $sys.theme,
|
||||
set: $sys.actions.theme.set,
|
||||
toggle: () => $sys.actions.theme.toggle(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
import type { AppTheme } from "modules/server/AppServer";
|
||||
import { useBkndWindowContext } from "ui/client/ClientProvider";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { create } from "zustand";
|
||||
import { combine, persist } from "zustand/middleware";
|
||||
|
||||
const themes = ["dark", "light", "system"] as const;
|
||||
export type AppTheme = (typeof themes)[number];
|
||||
|
||||
const themeStore = create(
|
||||
persist(
|
||||
combine({ theme: null as AppTheme | null }, (set) => ({
|
||||
setTheme: (theme: AppTheme | any) => {
|
||||
if (themes.includes(theme)) {
|
||||
document.startViewTransition(() => {
|
||||
set({ theme });
|
||||
});
|
||||
}
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "bknd-admin-theme",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useTheme(fallback: AppTheme = "system") {
|
||||
const b = useBknd();
|
||||
const winCtx = useBkndWindowContext();
|
||||
const theme_state = themeStore((state) => state.theme);
|
||||
const theme_set = themeStore((state) => state.setTheme);
|
||||
|
||||
// 1. override
|
||||
// 2. config
|
||||
// 3. winCtx
|
||||
// 4. fallback
|
||||
// 5. default
|
||||
const override = b?.adminOverride?.color_scheme;
|
||||
const config = b?.config.server.admin.color_scheme;
|
||||
const win = winCtx.color_scheme;
|
||||
// 2. local storage
|
||||
// 3. fallback
|
||||
// 4. default
|
||||
const override = b?.options?.theme;
|
||||
const prefersDark =
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const theme = override ?? config ?? win ?? fallback;
|
||||
const theme = override ?? theme_state ?? fallback;
|
||||
|
||||
return {
|
||||
theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme,
|
||||
value: theme,
|
||||
themes,
|
||||
setTheme: theme_set,
|
||||
state: theme_state,
|
||||
prefersDark,
|
||||
override,
|
||||
config,
|
||||
win,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { App } from "App";
|
||||
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
import type { BkndAdminOptions } from "ui/client/BkndProvider";
|
||||
|
||||
export type AppType = ReturnType<App["toJSON"]>;
|
||||
|
||||
@@ -15,7 +16,10 @@ export class AppReduced {
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _flows: Flow[] = [];
|
||||
|
||||
constructor(protected appJson: AppType) {
|
||||
constructor(
|
||||
protected appJson: AppType,
|
||||
protected _options: BkndAdminOptions = {},
|
||||
) {
|
||||
//console.log("received appjson", appJson);
|
||||
|
||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||
@@ -62,18 +66,21 @@ export class AppReduced {
|
||||
return this.appJson;
|
||||
}
|
||||
|
||||
getAdminConfig() {
|
||||
return this.appJson.server.admin;
|
||||
get options() {
|
||||
return {
|
||||
basepath: "",
|
||||
logo_return_path: "/",
|
||||
...this._options,
|
||||
};
|
||||
}
|
||||
|
||||
getSettingsPath(path: string[] = []): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
|
||||
return [base, ...path].join("/");
|
||||
}
|
||||
|
||||
getAbsolutePath(path?: string): string {
|
||||
const { basepath } = this.getAdminConfig();
|
||||
const { basepath } = this.options;
|
||||
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState } from "react";
|
||||
export type AppTheme = "light" | "dark" | string;
|
||||
|
||||
export function useSetTheme(initialTheme: AppTheme = "light") {
|
||||
const [theme, _setTheme] = useState(initialTheme);
|
||||
|
||||
const $html = document.querySelector("#bknd-admin")!;
|
||||
function setTheme(newTheme: AppTheme) {
|
||||
$html?.classList.remove("dark", "light");
|
||||
$html?.classList.add(newTheme);
|
||||
_setTheme(newTheme);
|
||||
|
||||
// @todo: just a quick switcher config update test
|
||||
fetch("/api/system/config/patch/server/admin", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ color_scheme: newTheme }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("theme updated", data);
|
||||
});
|
||||
}
|
||||
|
||||
return { theme, setTheme };
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const styles = {
|
||||
outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80",
|
||||
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
|
||||
subtlered:
|
||||
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link",
|
||||
"dark:text-red-700 text-red-700 dark:hover:bg-red-900 dark:hover:text-red-200 bg-transparent hover:bg-red-50 link",
|
||||
};
|
||||
|
||||
export type BaseProps = {
|
||||
@@ -51,7 +51,7 @@ const Base = ({
|
||||
}: BaseProps) => ({
|
||||
...props,
|
||||
className: twMerge(
|
||||
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
|
||||
"flex flex-row flex-nowrap items-center !font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
|
||||
sizes[size ?? "default"],
|
||||
styles[variant ?? "default"],
|
||||
props.className,
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type MiniMapProps,
|
||||
ReactFlow,
|
||||
type ReactFlowProps,
|
||||
ReactFlowProvider,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: JSX.Element | ReactNode;
|
||||
children?: Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
};
|
||||
@@ -38,7 +36,7 @@ export function Canvas({
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export type CodeEditorProps = ReactCodeMirrorProps & {
|
||||
_extensions?: Partial<{
|
||||
@@ -17,8 +16,7 @@ export default function CodeEditor({
|
||||
_extensions = {},
|
||||
...props
|
||||
}: CodeEditorProps) {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
const { theme } = useTheme();
|
||||
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
|
||||
? {
|
||||
...(typeof basicSetup === "object" ? basicSetup : {}),
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function ArrayFieldTemplate<
|
||||
{items.map(
|
||||
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
|
||||
const newChildren = cloneElement(children, {
|
||||
// @ts-ignore
|
||||
...children.props,
|
||||
name: undefined,
|
||||
title: undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
cloneElement,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -11,7 +12,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export type DropdownItem =
|
||||
| (() => JSX.Element)
|
||||
| (() => ReactNode)
|
||||
| {
|
||||
label: string | ReactElement;
|
||||
icon?: any;
|
||||
|
||||
@@ -7,10 +7,10 @@ import type { ComponentPropsWithoutRef } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import { type TSchema, Value } from "core/utils";
|
||||
import type { Validator } from "json-schema-form-react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
@@ -46,6 +46,7 @@ export function AuthForm({
|
||||
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||
...props
|
||||
}: LoginFormProps) {
|
||||
const { theme } = useTheme();
|
||||
const basepath = auth?.basepath ?? "/api/auth";
|
||||
const password = {
|
||||
action: `${basepath}/password/${action}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DB } from "core";
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
memo,
|
||||
useEffect,
|
||||
@@ -27,7 +28,7 @@ export type FileState = {
|
||||
export type FileStateWithData = FileState & { data: DB["media"] };
|
||||
|
||||
export type DropzoneRenderProps = {
|
||||
wrapperRef: RefObject<HTMLDivElement>;
|
||||
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||
inputProps: ComponentPropsWithRef<"input">;
|
||||
state: {
|
||||
files: FileState[];
|
||||
@@ -59,7 +60,7 @@ export type DropzoneProps = {
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
children?: (props: DropzoneRenderProps) => ReactNode;
|
||||
};
|
||||
|
||||
function handleUploadError(e: unknown) {
|
||||
@@ -459,7 +460,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
|
||||
export type PreviewComponentProps = {
|
||||
file: FileState;
|
||||
fallback?: (props: { file: FileState }) => JSX.Element;
|
||||
fallback?: (props: { file: FileState }) => ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onTouchStart?: () => void;
|
||||
@@ -486,7 +487,7 @@ type PreviewProps = {
|
||||
handleUpload: (file: FileState) => Promise<void>;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
};
|
||||
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => {
|
||||
const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
|
||||
const dropdownItems = [
|
||||
["initial", "uploaded"].includes(file.state) && {
|
||||
label: "Delete",
|
||||
|
||||
@@ -48,7 +48,7 @@ export const NavLink = <E extends React.ElementType = "a">({
|
||||
<Tag
|
||||
{...otherProps}
|
||||
className={twMerge(
|
||||
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link",
|
||||
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link transition-colors",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
@@ -80,7 +80,7 @@ export function Main({ children }) {
|
||||
data-shell="main"
|
||||
className={twMerge(
|
||||
"flex flex-col flex-grow w-1 flex-shrink-1",
|
||||
sidebar.open && "max-w-[calc(100%-350px)]",
|
||||
sidebar.open && "md:max-w-[calc(100%-350px)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import { useMemo } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { Link, useLocation } from "wouter";
|
||||
@@ -7,7 +6,7 @@ import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
|
||||
type Breadcrumb = {
|
||||
label: string | JSX.Element;
|
||||
label: string | Element;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "react-icons/tb";
|
||||
import { useAuth, useBkndWindowContext } from "ui/client";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -24,6 +23,7 @@ import { useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
import { autoFormatString } from "core/utils";
|
||||
|
||||
export function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
@@ -114,7 +114,7 @@ function SidebarToggler() {
|
||||
export function Header({ hasSidebar = true }) {
|
||||
const { app } = useBknd();
|
||||
const { theme } = useTheme();
|
||||
const { logo_return_path = "/" } = app.getAdminConfig();
|
||||
const { logo_return_path = "/" } = app.options;
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -142,7 +142,7 @@ export function Header({ hasSidebar = true }) {
|
||||
}
|
||||
|
||||
function UserMenu() {
|
||||
const { adminOverride, config } = useBknd();
|
||||
const { config, options } = useBknd();
|
||||
const auth = useAuth();
|
||||
const [navigate] = useNavigate();
|
||||
const { logout_route } = useBkndWindowContext();
|
||||
@@ -173,7 +173,7 @@ function UserMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!adminOverride) {
|
||||
if (!options.theme) {
|
||||
items.push(() => <UserMenuThemeToggler />);
|
||||
}
|
||||
|
||||
@@ -193,17 +193,15 @@ function UserMenu() {
|
||||
}
|
||||
|
||||
function UserMenuThemeToggler() {
|
||||
const { theme, toggle } = useBkndSystemTheme();
|
||||
const { value, themes, setTheme } = useTheme();
|
||||
return (
|
||||
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
|
||||
<SegmentedControl
|
||||
withItemsBorders={false}
|
||||
className="w-full"
|
||||
data={[
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
value={theme}
|
||||
onChange={toggle}
|
||||
data={themes.map((t) => ({ value: t, label: autoFormatString(t) }))}
|
||||
value={value}
|
||||
onChange={setTheme}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -107,8 +107,11 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
||||
}),
|
||||
}),
|
||||
Tabs: Tabs.extend({
|
||||
classNames: (theme, props) => ({
|
||||
tab: "data-[active=true]:border-primary",
|
||||
vars: (theme, props) => ({
|
||||
// https://mantine.dev/styles/styles-api/
|
||||
root: {
|
||||
"--tabs-color": "border-primary",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Menu: Menu.extend({
|
||||
|
||||
@@ -49,8 +49,7 @@ export function withQuery(url: string, query: object) {
|
||||
|
||||
export function withAbsolute(url: string) {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
return `~/${basepath}/${url}`.replace(/\/+/g, "/");
|
||||
return app.getAbsolutePath(url);
|
||||
}
|
||||
|
||||
export function useRouteNavigate() {
|
||||
@@ -65,7 +64,7 @@ export function useNavigate() {
|
||||
const [location, navigate] = useLocation();
|
||||
const router = useRouter();
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
const basepath = app.options.basepath;
|
||||
return [
|
||||
(
|
||||
url: string,
|
||||
@@ -121,7 +120,6 @@ export function useGoBack(
|
||||
},
|
||||
) {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
const [navigate] = useNavigate();
|
||||
const referrer = document.referrer;
|
||||
const history_length = window.history.length;
|
||||
@@ -142,9 +140,7 @@ export function useGoBack(
|
||||
} else {
|
||||
//console.log("used fallback");
|
||||
if (typeof fallback === "string") {
|
||||
const _fallback = options?.absolute
|
||||
? `~/${basepath}${fallback}`.replace(/\/+/g, "/")
|
||||
: fallback;
|
||||
const _fallback = options?.absolute ? app.getAbsolutePath(fallback) : fallback;
|
||||
//console.log("fallback", _fallback);
|
||||
|
||||
if (options?.native) {
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
#bknd-admin.dark,
|
||||
.dark .bknd-admin,
|
||||
.bknd-admin.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 */
|
||||
}
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
#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 */
|
||||
--color-primary: rgb(9 9 11); /* zinc-950 */
|
||||
--color-background: rgb(250 250 250); /* zinc-50 */
|
||||
--color-muted: rgb(228 228 231); /* ? */
|
||||
--color-darkest: rgb(0 0 0); /* black */
|
||||
--color-lightest: rgb(255 255 255); /* white */
|
||||
|
||||
@mixin light {
|
||||
--mantine-color-body: rgb(250 250 250);
|
||||
@@ -32,6 +22,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dark,
|
||||
.dark .bknd-admin /* currently used for elements, drop after making headless */,
|
||||
#bknd-admin.dark,
|
||||
.bknd-admin.dark {
|
||||
--color-primary: rgb(250 250 250); /* zinc-50 */
|
||||
--color-background: rgb(30 31 34);
|
||||
--color-muted: rgb(47 47 52);
|
||||
--color-darkest: rgb(255 255 255); /* white */
|
||||
--color-lightest: rgb(24 24 27); /* black */
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-primary: var(--color-primary);
|
||||
--color-background: var(--color-background);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-darkest: var(--color-darkest);
|
||||
--color-lightest: var(--color-lightest);
|
||||
}
|
||||
|
||||
#bknd-admin {
|
||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||
|
||||
@@ -51,37 +60,12 @@ body,
|
||||
@apply flex flex-1 flex-col h-dvh w-dvw;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.link {
|
||||
@apply transition-colors active:translate-y-px;
|
||||
}
|
||||
.link {
|
||||
@apply active:translate-y-px;
|
||||
}
|
||||
|
||||
.img-responsive {
|
||||
@apply max-h-full w-auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* debug classes
|
||||
*/
|
||||
.bordered-red {
|
||||
@apply border-2 border-red-500;
|
||||
}
|
||||
|
||||
.bordered-green {
|
||||
@apply border-2 border-green-500;
|
||||
}
|
||||
|
||||
.bordered-blue {
|
||||
@apply border-2 border-blue-500;
|
||||
}
|
||||
|
||||
.bordered-violet {
|
||||
@apply border-2 border-violet-500;
|
||||
}
|
||||
|
||||
.bordered-yellow {
|
||||
@apply border-2 border-yellow-500;
|
||||
}
|
||||
.img-responsive {
|
||||
@apply max-h-full w-auto;
|
||||
}
|
||||
|
||||
#bknd-admin,
|
||||
|
||||
@@ -4,11 +4,19 @@ import Admin from "./Admin";
|
||||
import "./main.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Admin withProvider />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
function render() {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Admin withProvider />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
if ("startViewTransition" in document) {
|
||||
document.startViewTransition(render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
|
||||
// REGISTER ERROR OVERLAY
|
||||
const showOverlay = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FieldApi, FormApi } from "@tanstack/react-form";
|
||||
import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
JsonSchemaField,
|
||||
RelationField,
|
||||
} from "data";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
@@ -20,13 +22,18 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
// simplify react form types 🤦
|
||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
||||
// biome-ignore format: ...
|
||||
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
Form: FormApi<any>;
|
||||
Form: FormApi;
|
||||
className?: string;
|
||||
action: "create" | "update";
|
||||
};
|
||||
@@ -42,7 +49,6 @@ export function EntityForm({
|
||||
action,
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
console.log("data", { data, fields });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -132,7 +138,7 @@ type EntityFormFieldProps<
|
||||
T extends keyof JSX.IntrinsicElements = "input",
|
||||
F extends Field = Field,
|
||||
> = ComponentProps<T> & {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
fieldApi: TFieldApi;
|
||||
field: F;
|
||||
action: "create" | "update";
|
||||
data?: EntityData;
|
||||
@@ -215,7 +221,7 @@ function EntityMediaFormField({
|
||||
entityId,
|
||||
disabled,
|
||||
}: {
|
||||
formApi: FormApi<any>;
|
||||
formApi: FormApi;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
@@ -223,7 +229,7 @@ function EntityMediaFormField({
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
const value = formApi.useStore((state) => {
|
||||
const value = useStore(formApi.store, (state) => {
|
||||
const val = state.values[field.name];
|
||||
if (!val || typeof val === "undefined") return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
@@ -253,7 +259,7 @@ function EntityJsonFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: JsonField }) {
|
||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
@@ -289,7 +295,7 @@ function EntityEnumFormField({
|
||||
fieldApi,
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: FieldApi<any, any>; field: EnumField }) {
|
||||
}: { fieldApi: TFieldApi; field: EnumField }) {
|
||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
fieldApi.handleChange(e.target.value);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { layoutWithDagre } from "ui/components/canvas/layouts";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { EntityTableNode } from "./EntityTableNode";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
|
||||
return Object.entries(entities ?? {}).map(([name, entity]) => {
|
||||
@@ -69,7 +69,7 @@ export function DataSchemaCanvas() {
|
||||
const {
|
||||
config: { data },
|
||||
} = useBknd();
|
||||
const { theme } = useBkndSystemTheme();
|
||||
const { theme } = useTheme();
|
||||
const nodes = entitiesToNodes(data.entities);
|
||||
const edges = relationsToEdges(data.relations).map((e) => ({
|
||||
...e,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import type { EntityData, JsonSchemaField } from "data";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import type { TFieldApi } from "ui/modules/data/components/EntityForm";
|
||||
|
||||
export function EntityJsonSchemaFormField({
|
||||
fieldApi,
|
||||
@@ -11,7 +11,7 @@ export function EntityJsonSchemaFormField({
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
fieldApi: TFieldApi;
|
||||
field: JsonSchemaField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getHotkeyHandler, useHotkeys } from "@mantine/hooks";
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { EntityData, RelationField } from "data";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,10 +11,11 @@ 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, type EntityTableProps } from "../EntityTable";
|
||||
import type { EntityTableProps } from "../EntityTable";
|
||||
import type { ResponseObject } from "modules/ModuleApi";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||
import type { TFieldApi } from "ui/modules/data/components/EntityForm";
|
||||
|
||||
// @todo: allow clear if not required
|
||||
export function EntityRelationalFormField({
|
||||
@@ -25,7 +25,7 @@ export function EntityRelationalFormField({
|
||||
disabled,
|
||||
tabIndex,
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
fieldApi: TFieldApi;
|
||||
field: RelationField;
|
||||
data?: EntityData;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IconType } from "react-icons";
|
||||
import { TemplateMediaComponent, TemplateMediaMeta } from "./media";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type StepTemplate = {
|
||||
id: string;
|
||||
@@ -8,8 +9,6 @@ export type StepTemplate = {
|
||||
Icon: IconType;
|
||||
};
|
||||
|
||||
const Templates: [() => JSX.Element, StepTemplate][] = [
|
||||
[TemplateMediaComponent, TemplateMediaMeta],
|
||||
];
|
||||
const Templates: [() => ReactNode, StepTemplate][] = [[TemplateMediaComponent, TemplateMediaMeta]];
|
||||
|
||||
export default Templates;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Alert } from "ui/components/display/Alert";
|
||||
* @constructor
|
||||
*/
|
||||
export function FlashMessage() {
|
||||
const [flash, setFlash] = useState<any>();
|
||||
const [flash, setFlash] = useState<ReturnType<typeof getFlashMessage>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { Entity, EntityData, EntityRelation, RepoQuery } from "data";
|
||||
import type { Entity, EntityData, EntityRelation } from "data";
|
||||
import { Fragment, useState } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery, useEntityQuery } from "ui/client";
|
||||
|
||||
@@ -70,7 +70,7 @@ export function StepCreate() {
|
||||
name: "",
|
||||
trigger: "manual",
|
||||
mode: "async",
|
||||
},
|
||||
} as Static<typeof schema>,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ import { Dropdown } from "../../components/overlay/Dropdown";
|
||||
import { useFlow } from "../../container/use-flows";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { SectionHeader } from "../../layouts/AppShell/AppShell";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export function FlowEdit({ params }) {
|
||||
const { app } = useBknd();
|
||||
const { color_scheme: theme } = app.getAdminConfig();
|
||||
const { basepath } = app.getAdminConfig();
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const { theme } = useTheme();
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
const [location, navigate] = useLocation();
|
||||
const [execution, setExecution] = useState<Execution>();
|
||||
const [selectedNodes, setSelectedNodes] = useState<Node[]>([]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import React, { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
@@ -9,6 +9,7 @@ import FlowRoutes from "./flows";
|
||||
import MediaRoutes from "./media";
|
||||
import { Root, RootEmpty } from "./root";
|
||||
import SettingsRoutes from "./settings";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
@@ -16,11 +17,11 @@ const TestRoutes = lazy(() => import("./test"));
|
||||
export function Routes() {
|
||||
const { app } = useBknd();
|
||||
const { theme } = useTheme();
|
||||
const { basepath } = app.getAdminConfig();
|
||||
|
||||
return (
|
||||
<div id="bknd-admin" className={theme + " antialiased"}>
|
||||
<Router base={basepath}>
|
||||
<FlashMessage />
|
||||
<Router base={app.options.basepath}>
|
||||
<Switch>
|
||||
<Route path="/auth/login" component={AuthLogin} />
|
||||
<Route path="/" nest>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ServerSettings } from "./routes/server.settings";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { version, schema, actions } = useBknd();
|
||||
const { version, schema, actions, app } = useBknd();
|
||||
useBrowserTitle(["Settings"]);
|
||||
|
||||
async function handleRefresh() {
|
||||
@@ -151,11 +151,10 @@ const FallbackRoutes = ({
|
||||
...settingProps
|
||||
}: SettingProps<any> & { module: string }) => {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig();
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
|
||||
return (
|
||||
<Route path={`/${module}`} nest>
|
||||
<Route path={module} nest>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/"
|
||||
|
||||
@@ -44,8 +44,7 @@ const uiSchema = {
|
||||
export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
const _s = useBknd();
|
||||
const _schema = cloneDeep(_unsafe_copy);
|
||||
const { basepath } = _s.app.getAdminConfig();
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const prefix = _s.app.getAbsolutePath("settings");
|
||||
|
||||
try {
|
||||
const user_entity = config.entity_name ?? "users";
|
||||
|
||||
@@ -68,8 +68,7 @@ export const DataSettings = ({
|
||||
config,
|
||||
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
|
||||
const { app } = useBknd();
|
||||
const basepath = app.getAdminConfig().basepath;
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
const entities = Object.keys(config.entities ?? {});
|
||||
|
||||
function fillEntities(schema: any, key: string = "entity") {
|
||||
|
||||
@@ -31,8 +31,7 @@ const uiSchema = {
|
||||
|
||||
export const FlowsSettings = ({ schema, config }) => {
|
||||
const { app } = useBknd();
|
||||
const { basepath } = app.getAdminConfig();
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
|
||||
function fillTasks(schema: any, flow: any, key: string) {
|
||||
const tasks = Object.keys(flow.tasks ?? {});
|
||||
|
||||
@@ -17,15 +17,11 @@ const uiSchema = {
|
||||
};
|
||||
|
||||
export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
const { app, adminOverride } = useBknd();
|
||||
const { basepath } = app.getAdminConfig();
|
||||
const { app } = useBknd();
|
||||
const _schema = cloneDeep(_unsafe_copy);
|
||||
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
|
||||
const prefix = app.getAbsolutePath("settings");
|
||||
|
||||
const schema = _schema;
|
||||
if (adminOverride) {
|
||||
schema.properties.admin.readOnly = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Route path="/server" nest>
|
||||
@@ -33,14 +29,6 @@ export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
path="/"
|
||||
component={() => (
|
||||
<Setting
|
||||
options={{
|
||||
showAlert: () => {
|
||||
if (adminOverride) {
|
||||
return "The admin settings are read-only as they are overriden. Remaining server configuration can be edited.";
|
||||
}
|
||||
return;
|
||||
},
|
||||
}}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
config={config}
|
||||
|
||||
@@ -16,7 +16,6 @@ import ModalTest from "../../routes/test/tests/modal-test";
|
||||
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
|
||||
import DropdownTest from "./tests/dropdown-test";
|
||||
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||
import FlowsTest from "./tests/flows-test";
|
||||
import JsonSchemaForm3 from "./tests/json-schema-form3";
|
||||
import JsonFormTest from "./tests/jsonform-test";
|
||||
@@ -27,9 +26,11 @@ import ReactFlowTest from "./tests/reactflow-test";
|
||||
import SchemaTest from "./tests/schema-test";
|
||||
import SortableTest from "./tests/sortable-test";
|
||||
import { SqlAiTest } from "./tests/sql-ai-test";
|
||||
import Themes from "./tests/themes";
|
||||
|
||||
const tests = {
|
||||
DropdownTest,
|
||||
Themes,
|
||||
ModalTest,
|
||||
JsonFormTest,
|
||||
FlowFormTest,
|
||||
@@ -42,7 +43,6 @@ const tests = {
|
||||
SqlAiTest,
|
||||
SortableTest,
|
||||
ReactHookErrors,
|
||||
EntityFieldsForm,
|
||||
FlowsTest,
|
||||
AppShellAccordionsTest,
|
||||
SwaggerTest,
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { Select, Switch, Tabs, TextInput, Textarea, Tooltip } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum, StringIdentifier, transformObject } from "core/utils";
|
||||
import { FieldClassMap } from "data";
|
||||
import { omit } from "lodash-es";
|
||||
import {
|
||||
type FieldArrayWithId,
|
||||
type FieldValues,
|
||||
type UseControllerProps,
|
||||
type UseFormReturn,
|
||||
useController,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { TbChevronDown, TbChevronUp, TbGripVertical, TbTrash } from "react-icons/tb";
|
||||
import { Button } from "../../../components/buttons/Button";
|
||||
import { IconButton } from "../../../components/buttons/IconButton";
|
||||
import { MantineSelect } from "../../../components/form/hook-form-mantine/MantineSelect";
|
||||
|
||||
const fieldSchemas = transformObject(omit(FieldClassMap, ["primary"]), (value) => value.schema);
|
||||
const fieldSchema = Type.Union(
|
||||
Object.entries(fieldSchemas).map(([type, schema]) =>
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Const(type),
|
||||
name: StringIdentifier,
|
||||
config: Type.Optional(schema),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const schema = Type.Object({
|
||||
fields: Type.Array(fieldSchema),
|
||||
});
|
||||
|
||||
const fieldSchema2 = Type.Object({
|
||||
type: StringEnum(Object.keys(fieldSchemas)),
|
||||
name: StringIdentifier,
|
||||
});
|
||||
|
||||
function specificFieldSchema(type: keyof typeof fieldSchemas) {
|
||||
return Type.Omit(fieldSchemas[type], [
|
||||
"label",
|
||||
"description",
|
||||
"required",
|
||||
"fillable",
|
||||
"hidden",
|
||||
"virtual",
|
||||
]);
|
||||
}
|
||||
|
||||
export default function EntityFieldsForm() {
|
||||
const {
|
||||
control,
|
||||
formState: { isValid, errors },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
watch,
|
||||
register,
|
||||
setValue,
|
||||
} = useForm({
|
||||
mode: "onTouched",
|
||||
resolver: typeboxResolver(schema),
|
||||
defaultValues: {
|
||||
fields: [{ type: "text", name: "", config: {} }],
|
||||
sort: { by: "-1", dir: "asc" },
|
||||
},
|
||||
});
|
||||
const defaultType = Object.keys(fieldSchemas)[0];
|
||||
const { fields, append, prepend, remove, swap, move, insert, update } = useFieldArray({
|
||||
control, // control props comes from useForm (optional: if you are using FormProvider)
|
||||
name: "fields", // unique name for your Field Array
|
||||
});
|
||||
|
||||
function handleAppend() {
|
||||
append({ type: "text", name: "", config: {} });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-8">
|
||||
{/*{fields.map((field, index) => (
|
||||
<EntityField
|
||||
key={field.id}
|
||||
field={field}
|
||||
index={index}
|
||||
form={{ watch, register, setValue, getValues, control }}
|
||||
defaultType={defaultType}
|
||||
remove={remove}
|
||||
/>
|
||||
))}*/}
|
||||
{fields.map((field, index) => (
|
||||
<EntityFieldForm key={field.id} value={field} index={index} update={update} />
|
||||
))}
|
||||
|
||||
<Button className="justify-center" onClick={handleAppend}>
|
||||
Add Field
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<pre>{JSON.stringify(watch(), null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityFieldForm({ update, index, value }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
mode: "onBlur",
|
||||
resolver: typeboxResolver(
|
||||
Type.Object({
|
||||
type: StringEnum(Object.keys(fieldSchemas)),
|
||||
name: Type.String({ minLength: 1, maxLength: 3 }),
|
||||
}),
|
||||
),
|
||||
defaultValues: value,
|
||||
});
|
||||
|
||||
function handleUpdate({ id, ...data }) {
|
||||
console.log("data", data);
|
||||
update(index, data);
|
||||
}
|
||||
|
||||
return (
|
||||
<form>
|
||||
<MantineSelect
|
||||
control={control}
|
||||
name="type"
|
||||
data={[...Object.keys(fieldSchemas), "test"]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="name"
|
||||
classNames={{ root: "w-full" }}
|
||||
{...register("name")}
|
||||
error={errors.name?.message as any}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityFieldController({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
}: UseControllerProps & {
|
||||
index: number;
|
||||
}) {
|
||||
const {
|
||||
field: { value, onChange: fieldOnChange, ...field },
|
||||
fieldState,
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
defaultValue,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
});
|
||||
|
||||
return <div>field</div>;
|
||||
}
|
||||
|
||||
function EntityField({
|
||||
field,
|
||||
index,
|
||||
form: { watch, register, setValue, getValues, control },
|
||||
remove,
|
||||
defaultType,
|
||||
}: {
|
||||
field: FieldArrayWithId;
|
||||
index: number;
|
||||
form: Pick<UseFormReturn<any>, "watch" | "register" | "setValue" | "getValues" | "control">;
|
||||
remove: (index: number) => void;
|
||||
defaultType: string;
|
||||
}) {
|
||||
const [opened, handlers] = useDisclosure(false);
|
||||
const prefix = `fields.${index}` as const;
|
||||
const name = watch(`${prefix}.name`);
|
||||
const enabled = name?.length > 0;
|
||||
const type = watch(`${prefix}.type`);
|
||||
//const config = watch(`${prefix}.config`);
|
||||
const selectFieldRegister = register(`${prefix}.type`);
|
||||
//console.log("type", type, specificFieldSchema(type as any));
|
||||
|
||||
function handleDelete(index: number) {
|
||||
return () => {
|
||||
if (name.length === 0) {
|
||||
remove(index);
|
||||
return;
|
||||
}
|
||||
window.confirm(`Sure to delete "${name}"?`) && remove(index);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="flex flex-col border border-muted rounded">
|
||||
<div className="flex flex-row gap-2 px-2 pt-1 pb-2">
|
||||
<div className="flex items-center">
|
||||
<IconButton Icon={TbGripVertical} className="mt-1" />
|
||||
</div>
|
||||
<div className="flex flex-row flex-grow gap-1">
|
||||
<div>
|
||||
<Select
|
||||
label="Type"
|
||||
data={[...Object.keys(fieldSchemas), "test"]}
|
||||
defaultValue={defaultType}
|
||||
onBlur={selectFieldRegister.onBlur}
|
||||
onChange={(value) => {
|
||||
setValue(`${prefix}.type`, value as any);
|
||||
setValue(`${prefix}.config`, {} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="name"
|
||||
classNames={{ root: "w-full" }}
|
||||
{...register(`fields.${index}.name`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end ">
|
||||
<div className="flex flex-row gap-1">
|
||||
<Tooltip label="Specify a property name to see options." disabled={enabled}>
|
||||
<Button
|
||||
IconRight={opened ? TbChevronUp : TbChevronDown}
|
||||
onClick={handlers.toggle}
|
||||
variant={opened ? "default" : "ghost"}
|
||||
disabled={!enabled}
|
||||
>
|
||||
Options
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<IconButton Icon={TbTrash} onClick={handleDelete(index)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*{enabled && opened && (
|
||||
<div className="flex flex-col border-t border-t-muted px-3 py-2">
|
||||
<Tabs defaultValue="general">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
<Tabs.Tab value="specific">Specific</Tabs.Tab>
|
||||
<Tabs.Tab value="visibility">Visiblity</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="general">
|
||||
<div className="flex flex-col gap-2 pt-3 pb-1" key={`${prefix}_${type}`}>
|
||||
<Switch
|
||||
label="Required"
|
||||
{...register(`${prefix}.config.required` as any)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Label"
|
||||
placeholder="Label"
|
||||
{...register(`${prefix}.config.label` as any)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
{...register(`${prefix}.config.description` as any)}
|
||||
/>
|
||||
<Switch label="Virtual" {...register(`${prefix}.config.virtual` as any)} />
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="specific">
|
||||
<div className="flex flex-col gap-2 pt-3 pb-1">
|
||||
<JsonSchemaForm
|
||||
key={type}
|
||||
schema={specificFieldSchema(type as any)}
|
||||
uiSchema={dataFieldsUiSchema.config}
|
||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||
onChange={(value) => {
|
||||
setValue(`${prefix}.config`, {
|
||||
...getValues([`fields.${index}.config`])[0],
|
||||
...value
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/src/ui/routes/test/tests/themes.tsx
Normal file
48
app/src/ui/routes/test/tests/themes.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
export default function Themes() {
|
||||
return (
|
||||
<div className="flex flex-col p-3 gap-4">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Button size="small">Small</Button>
|
||||
<Button>Default</Button>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="red">Red</Button>
|
||||
<Button variant="subtlered">Subtlered</Button>
|
||||
<Button size="large">Large</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Alert.Exception title="Exception">Alert.Exception</Alert.Exception>
|
||||
<Alert.Info title="Info">Alert.Info</Alert.Info>
|
||||
<Alert.Success title="Success">Alert.Success</Alert.Success>
|
||||
<Alert.Warning title="Warning">Alert.Warning</Alert.Warning>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 items-start">
|
||||
<Formy.Group>
|
||||
<Formy.Label>Input</Formy.Label>
|
||||
<Formy.Input placeholder="Input" />
|
||||
</Formy.Group>
|
||||
<Formy.Group>
|
||||
<Formy.Label>Checkbox</Formy.Label>
|
||||
<Formy.BooleanInput />
|
||||
</Formy.Group>
|
||||
<Formy.Group>
|
||||
<Formy.Label>Switch</Formy.Label>
|
||||
<Formy.Switch />
|
||||
</Formy.Group>
|
||||
<Formy.Group>
|
||||
<Formy.Label>Select</Formy.Label>
|
||||
<Formy.Select>
|
||||
<option value="" />
|
||||
<option value="1">Option 1</option>
|
||||
<option value="2">Option 2</option>
|
||||
</Formy.Select>
|
||||
</Formy.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ input[disabled]::placeholder {
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
background: transparent;
|
||||
/*background: transparent;*/
|
||||
}
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
|
||||
Reference in New Issue
Block a user