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

@@ -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>
</>

View File

@@ -29,7 +29,11 @@ export function useEntityForm({
onSubmitAsync: async ({ value }): Promise<any> => {
try {
//console.log("validating", value, entity.isValidData(value, action));
entity.isValidData(value, action, true);
entity.isValidData(value, action, {
explain: true,
// unknown will later be removed in getChangeSet
ignoreUnknown: true
});
return undefined;
} catch (e) {
//console.log("---validation error", e);

View File

@@ -1,7 +1,18 @@
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { Auth } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { AuthScreen } from "ui/modules/auth/AuthScreen";
export function AuthLogin() {
useBrowserTitle(["Login"]);
return <AuthScreen action="login" />;
return (
<Auth.Screen
action="login"
logo={
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
}
/>
);
}

View File

@@ -1,10 +1,10 @@
import { notifications } from "@mantine/notifications";
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
@@ -12,7 +12,11 @@ import { routes, useNavigate } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
export function AuthRolesEdit(props) {
useBknd({ withSecrets: true });
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Roles & Permissions" />;
}
return <AuthRolesEditInternal {...props} />;
}
@@ -28,14 +32,9 @@ function AuthRolesEditInternal({ params }) {
if (!formRef.current?.isValid()) return;
const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data);
/*notifications.show({
id: `role-${roleName}-update`,
position: "top-right",
title: success ? "Update success" : "Update failed",
message: success ? "Role updated successfully" : "Failed to update role",
color: !success ? "red" : undefined
});*/
if (success) {
navigate(routes.auth.roles.list());
}
}
async function handleDelete() {

View File

@@ -90,9 +90,16 @@ const renderValue = ({ value, property }) => {
}
if (property === "permissions") {
const max = 3;
let permissions = value || [];
const count = permissions.length;
if (count > max) {
permissions = [...permissions.slice(0, max), `+${count - max}`];
}
return (
<div className="flex flex-row gap-1">
{[...(value || [])].map((p, i) => (
{permissions.map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"

View File

@@ -1,9 +1,20 @@
import { SegmentedControl } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import { SegmentedControl, Tooltip } from "@mantine/core";
import {
IconAlignJustified,
IconCirclesRelation,
IconDatabase,
IconExternalLink,
IconPhoto,
IconPlus,
IconSettings
} from "@tabler/icons-react";
import type { Entity, TEntityType } from "data";
import { TbDatabasePlus } 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 } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Dropdown, type DropdownClickableChild } from "ui/components/overlay/Dropdown";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -11,9 +22,7 @@ import { routes, useNavigate } from "ui/lib/routes";
export function DataRoot({ children }) {
// @todo: settings routes should be centralized
const {
app: { entities }
} = useBknd();
const { entities, $data } = useBkndData();
const entityList: Record<TEntityType, Entity[]> = {
regular: [],
generated: [],
@@ -22,7 +31,7 @@ export function DataRoot({ children }) {
const [navigate] = useNavigate();
const context = window.location.href.match(/\/schema/) ? "schema" : "data";
for (const entity of entities) {
for (const entity of Object.values(entities)) {
entityList[entity.getType()].push(entity);
}
@@ -52,14 +61,19 @@ export function DataRoot({ children }) {
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<SegmentedControl
data={[
{ value: "data", label: "Data" },
{ value: "schema", label: "Schema" }
]}
value={context}
onChange={handleSegmentChange}
/>
<>
<SegmentedControl
data={[
{ value: "data", label: "Data" },
{ value: "schema", label: "Schema" }
]}
value={context}
onChange={handleSegmentChange}
/>
<Tooltip label="New Entity">
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
</Tooltip>
</>
}
>
Entities
@@ -70,7 +84,7 @@ export function DataRoot({ children }) {
<SearchInput placeholder="Search entities" />
</div>*/}
<EntityLinkList entities={entityList.regular} context={context} />
<EntityLinkList entities={entityList.regular} context={context} suggestCreate />
<EntityLinkList entities={entityList.system} context={context} title="System" />
<EntityLinkList
entities={entityList.generated}
@@ -88,9 +102,22 @@ export function DataRoot({ children }) {
const EntityLinkList = ({
entities,
title,
context
}: { entities: Entity[]; title?: string; context: "data" | "schema" }) => {
if (entities.length === 0) return null;
context,
suggestCreate = false
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
const { $data } = useBkndData();
if (entities.length === 0) {
return suggestCreate ? (
<Empty
className="py-10"
description="Create your first entity to get started."
secondary={{
children: "Create entity",
onClick: () => $data.modals.createEntity()
}}
/>
) : null;
}
return (
<nav
@@ -107,21 +134,98 @@ const EntityLinkList = ({
? routes.data.entity.list(entity.name)
: routes.data.schema.entity(entity.name);
return (
<AppShell.SidebarLink key={entity.name} as={Link} href={href}>
{entity.label}
</AppShell.SidebarLink>
<EntityContextMenu key={entity.name} entity={entity}>
<AppShell.SidebarLink as={Link} href={href}>
{entity.label}
</AppShell.SidebarLink>
</EntityContextMenu>
);
})}
</nav>
);
};
const EntityContextMenu = ({
entity,
children,
enabled = true
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
if (!enabled) return children;
const [navigate] = useNavigate();
const { $data } = useBkndData();
// get href from children (single item)
const href = (children as any).props.href;
const separator = () => <div className="h-px my-1 w-full bg-primary/5" />;
return (
<Dropdown
className="flex flex-col w-full"
dropdownWrapperProps={{
className: "min-w-fit"
}}
title={entity.label + " Actions"}
items={[
href && {
icon: IconExternalLink,
label: "Open in tab",
onClick: () => navigate(href, { target: "_blank" })
},
separator,
!$data.system(entity.name).any && {
icon: IconPlus,
label: "Create new",
onClick: () => navigate(routes.data.entity.create(entity.name))
},
{
icon: IconDatabase,
label: "List entries",
onClick: () => navigate(routes.data.entity.list(entity.name))
},
separator,
{
icon: IconAlignJustified,
label: "Manage fields",
onClick: () => navigate(routes.data.schema.entity(entity.name))
},
{
icon: IconCirclesRelation,
label: "Add relation",
onClick: () =>
$data.modals.createRelation({
target: entity.name,
type: "n:1"
})
},
!$data.system(entity.name).media && {
icon: IconPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name)
},
separator,
{
icon: IconSettings,
label: "Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
})
}
]}
openEvent="onContextMenu"
position="bottom-start"
>
{children}
</Dropdown>
);
};
export function DataEmpty() {
useBrowserTitle(["Data"]);
const [navigate] = useNavigate();
const { $data } = useBkndData();
function handleButtonClick() {
//navigate(routes.settings.path(["data", "entities"]), { absolute: true });
navigate(routes.data.schema.root());
}
@@ -130,8 +234,14 @@ export function DataEmpty() {
Icon={IconDatabase}
title="No entity selected"
description="Please select an entity from the left sidebar or create a new one to continue."
buttonText="Go to schema"
buttonOnClick={handleButtonClick}
secondary={{
children: "Go to schema",
onClick: handleButtonClick
}}
primary={{
children: "Create entity",
onClick: $data.modals.createEntity
}}
/>
);
}

View File

@@ -6,6 +6,7 @@ import { useApiQuery, useEntityQuery } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -18,7 +19,11 @@ import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
export function DataEntityUpdate({ params }) {
const { $data, relations } = useBkndData();
const entity = $data.entity(params.entity as string)!;
const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const entityId = Number.parseInt(params.id as string);
const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate();
@@ -36,7 +41,9 @@ export function DataEntityUpdate({ params }) {
with: local_relation_refs
},
{
revalidateOnFocus: false
keepPreviousData: false,
revalidateOnFocus: false,
shouldRetryOnError: false
}
);
@@ -81,8 +88,15 @@ export function DataEntityUpdate({ params }) {
onSubmitted
});
const makeKey = (key: string | number = "") =>
`${params.entity.name}_${entityId}_${String(key)}`;
if (!data && !$q.isLoading) {
return (
<Message.NotFound
description={`Entity "${params.entity}" with ID "${entityId}" doesn't exist.`}
/>
);
}
const makeKey = (key: string | number = "") => `${entity.name}_${entityId}_${String(key)}`;
const fieldsDisabled = $q.isLoading || $q.isValidating || Form.state.isSubmitting;
@@ -234,7 +248,7 @@ function EntityDetailInner({
const other = relation.other(entity);
const [navigate] = useNavigate();
const search: Partial<RepoQuery> = {
const search = {
select: other.entity.getSelect(undefined, "table"),
limit: 10,
offset: 0

View File

@@ -2,8 +2,9 @@ import { Type } from "core/utils";
import type { EntityData } from "data";
import { useState } from "react";
import { useEntityMutate } from "ui/client";
import { useBknd } from "ui/client/BkndProvider";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { Message } from "ui/components/display/Message";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -13,8 +14,14 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
export function DataEntityCreate({ params }) {
const { app } = useBknd();
const entity = app.entity(params.entity as string)!;
const { $data } = useBkndData();
const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
} else if (entity.type !== "regular") {
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
}
const [error, setError] = useState<string | null>(null);
useBrowserTitle(["Data", entity.label, "Create"]);
@@ -43,7 +50,7 @@ export function DataEntityCreate({ params }) {
const { Form, handleSubmit } = useEntityForm({
action: "create",
entity,
entity: entity,
initialData: search.value,
onSubmitted
});

View File

@@ -1,7 +1,9 @@
import { Type } from "core/utils";
import { querySchema } from "data";
import { type Entity, querySchema } from "data";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useApi, useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
@@ -11,6 +13,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
// @todo: migrate to Typebox
@@ -29,23 +32,28 @@ const PER_PAGE_OPTIONS = [5, 10, 25];
export function DataEntityList({ params }) {
const { $data } = useBkndData();
const entity = $data.entity(params.entity as string)!;
const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate();
const search = useSearch(searchSchema, {
select: entity?.getSelect(undefined, "table") ?? [],
sort: entity?.getDefaultSort()
select: undefined,
sort: undefined
});
const $q = useApiQuery(
(api) =>
api.data.readMany(entity.name, {
api.data.readMany(entity?.name as any, {
select: search.value.select,
limit: search.value.perPage,
offset: (search.value.page - 1) * search.value.perPage,
sort: search.value.sort
sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`
}),
{
enabled: !!entity,
revalidateOnFocus: true,
keepPreviousData: true
}
@@ -75,14 +83,10 @@ export function DataEntityList({ params }) {
search.set("perPage", perPage);
}
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const isUpdating = $q.isLoading && $q.isValidating;
return (
<>
<Fragment key={entity.name}>
<AppShell.SectionHeader
right={
<>
@@ -90,6 +94,14 @@ export function DataEntityList({ params }) {
items={[
{
label: "Settings",
onClick: () => navigate(routes.data.schema.entity(entity.name))
},
{
label: "Data Schema",
onClick: () => navigate(routes.data.schema.root())
},
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
@@ -100,14 +112,7 @@ export function DataEntityList({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
<Button
onClick={() => {
navigate(routes.data.entity.create(entity.name));
}}
variant="primary"
>
Create new
</Button>
<EntityCreateButton entity={entity} />
</>
}
>
@@ -126,7 +131,7 @@ export function DataEntityList({ params }) {
<EntityTable2
data={data ?? null}
entity={entity}
/*select={search.value.select}*/
select={search.value.select}
onClickRow={handleClickRow}
page={search.value.page}
sort={search.value.sort}
@@ -140,6 +145,40 @@ export function DataEntityList({ params }) {
</div>
</div>
</AppShell.Scrollable>
</>
</Fragment>
);
}
function EntityCreateButton({ entity }: { entity: Entity }) {
const b = useBknd();
const createUserModal = useCreateUserModal();
const [navigate] = useNavigate();
if (!entity) return null;
if (entity.type !== "regular") {
const system = {
users: b.app.config.auth.entity_name,
media: b.app.config.media.entity_name
};
if (system.users === entity.name) {
return (
<Button onClick={createUserModal.open} variant="primary">
New User
</Button>
);
}
return null;
}
return (
<Button
onClick={() => {
navigate(routes.data.entity.create(entity.name));
}}
variant="primary"
>
Create new
</Button>
);
}

View File

@@ -8,23 +8,32 @@ import { isDebug } from "core";
import type { Entity } from "data";
import { cloneDeep } from "lodash-es";
import { useRef, useState } from "react";
import { TbDots } from "react-icons/tb";
import {
TbCirclesRelation,
TbDatabasePlus,
TbDots,
TbPhoto,
TbPlus,
TbSitemap
} from "react-icons/tb";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Message } from "ui/components/display/Message";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { Link } from "ui/components/wouter/Link";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
export function DataSchemaEntity({ params }) {
const { $data } = useBkndData();
const [value, setValue] = useState("fields");
const fieldsRef = useRef<EntityFieldsFormRef>(null);
function toggle(value) {
return () => setValue(value);
@@ -32,6 +41,9 @@ export function DataSchemaEntity({ params }) {
const [navigate] = useNavigate();
const entity = $data.entity(params.entity as string)!;
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
return (
<>
@@ -41,7 +53,14 @@ export function DataSchemaEntity({ params }) {
<Dropdown
items={[
{
label: "Settings",
label: "Data",
onClick: () =>
navigate(routes.data.root() + routes.data.entity.list(entity.name), {
absolute: true
})
},
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true
@@ -52,14 +71,46 @@ export function DataSchemaEntity({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () =>
$data.modals.createRelation({
target: entity.name,
type: "n:1"
})
},
{
icon: TbPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name)
},
() => <div className="h-px my-1 w-full bg-primary/5" />,
{
icon: TbDatabasePlus,
label: "Create Entity",
onClick: () => $data.modals.createEntity()
}
]}
position="bottom-end"
>
<Button IconRight={TbPlus}>Add</Button>
</Dropdown>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
<div className="flex flex-row gap-4">
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
<Link to="/" className="invisible md:visible">
<Button IconLeft={TbSitemap}>Overview</Button>
</Link>
</div>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
@@ -74,12 +125,11 @@ export function DataSchemaEntity({ params }) {
<Empty
title="Relations"
description="This will soon be available here. Meanwhile, check advanced settings."
buttonText="Advanced Settings"
buttonOnClick={() =>
navigate(routes.settings.path(["data", "relations"]), {
absolute: true
})
}
primary={{
children: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "relations"]), { absolute: true })
}}
/>
</AppShell.SectionHeaderAccordionItem>
<AppShell.SectionHeaderAccordionItem
@@ -91,12 +141,13 @@ export function DataSchemaEntity({ params }) {
<Empty
title="Indices"
description="This will soon be available here. Meanwhile, check advanced settings."
buttonText="Advanced Settings"
buttonOnClick={() =>
navigate(routes.settings.path(["data", "indices"]), {
absolute: true
})
}
primary={{
children: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "indices"]), {
absolute: true
})
}}
/>
</AppShell.SectionHeaderAccordionItem>
</div>
@@ -111,7 +162,7 @@ const Fields = ({
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0);
const { actions } = useBkndData();
const { actions, $data } = useBkndData();
const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null);
async function handleUpdate() {
@@ -144,7 +195,30 @@ const Fields = ({
{submitting && (
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
)}
<EntityFieldsForm fields={initialFields} ref={ref} key={String(updates)} sortable />
<EntityFieldsForm
fields={initialFields}
ref={ref}
key={String(updates)}
sortable
additionalFieldTypes={fieldSpecs
.filter((f) => ["relation", "media"].includes(f.type))
.map((i) => ({
...i,
onClick: () => {
switch (i.type) {
case "relation":
$data.modals.createRelation({
target: entity.name,
type: "n:1"
});
break;
case "media":
$data.modals.createMedia(entity.name);
break;
}
}
}))}
/>
{isDebug() && (
<div>

View File

@@ -1,10 +1,7 @@
import { Suspense, lazy, useRef } from "react";
import {
CreateModal,
type CreateModalRef
} from "ui/modules/data/components/schema/create-modal/CreateModal";
import { Button } from "../../components/buttons/Button";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { Suspense, lazy } from "react";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import * as AppShell from "ui/layouts/AppShell/AppShell";
const DataSchemaCanvas = lazy(() =>
import("ui/modules/data/components/canvas/DataSchemaCanvas").then((m) => ({
@@ -13,18 +10,12 @@ const DataSchemaCanvas = lazy(() =>
);
export function DataSchemaIndex() {
const createModalRef = useRef<CreateModalRef>(null);
const { $data } = useBkndData();
return (
<>
<CreateModal ref={createModalRef} />
<AppShell.SectionHeader
right={
<Button
type="button"
variant="primary"
onClick={() => createModalRef.current?.open()}
>
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
Create new
</Button>
}

View File

@@ -25,7 +25,7 @@ import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitc
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
import { Popover } from "ui/components/overlay/Popover";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
const fieldsSchemaObject = originalFieldsSchemaObject;
@@ -45,7 +45,6 @@ type TFieldsFormSchema = Static<typeof schema>;
const fieldTypes = Object.keys(fieldsSchemaObject);
const defaultType = fieldTypes[0];
const blank_field = { name: "", field: { type: defaultType, config: {} } } as TFieldSchema;
const commonProps = ["label", "description", "required", "fillable", "hidden", "virtual"];
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
@@ -53,6 +52,13 @@ function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps);
}
export type EntityFieldsFormProps = {
fields: TAppDataEntityFields;
onChange?: (formData: TAppDataEntityFields) => void;
sortable?: boolean;
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
};
export type EntityFieldsFormRef = {
getValues: () => TFieldsFormSchema;
getData: () => TAppDataEntityFields;
@@ -60,146 +66,156 @@ export type EntityFieldsFormRef = {
reset: () => void;
};
export const EntityFieldsForm = forwardRef<
EntityFieldsFormRef,
{
fields: TAppDataEntityFields;
onChange?: (formData: TAppDataEntityFields) => void;
sortable?: boolean;
}
>(function EntityFieldsForm({ fields: _fields, sortable, ...props }, ref) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
name,
field
}));
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
name,
field
}));
const {
control,
formState: { isValid, errors },
getValues,
watch,
register,
setValue,
setError,
reset
} = useForm({
mode: "all",
resolver: typeboxResolver(schema),
defaultValues: {
fields: entityFields
} as TFieldsFormSchema
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "fields"
});
const {
control,
formState: { isValid, errors },
getValues,
watch,
register,
setValue,
setError,
reset
} = useForm({
mode: "all",
resolver: typeboxResolver(schema),
defaultValues: {
fields: entityFields
} as TFieldsFormSchema
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "fields"
});
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
return Object.fromEntries(
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
);
}
useEffect(() => {
if (props?.onChange) {
console.log("----set");
watch((data: any) => {
console.log("---calling");
props?.onChange?.(toCleanValues(data));
});
function toCleanValues(formData: TFieldsFormSchema): TAppDataEntityFields {
return Object.fromEntries(
formData.fields.map((field) => [field.name, objectCleanEmpty(field.field)])
);
}
}, []);
useImperativeHandle(ref, () => ({
reset,
getValues: () => getValues(),
getData: () => {
return toCleanValues(getValues());
},
isValid: () => isValid
}));
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
const newField = {
name: "",
new: true,
field: {
type: _type,
config: {}
useEffect(() => {
if (props?.onChange) {
console.log("----set");
watch((data: any) => {
console.log("---calling");
props?.onChange?.(toCleanValues(data));
});
}
};
append(newField);
}
}, []);
const formProps = {
watch,
register,
setValue,
getValues,
control,
setError
};
return (
<>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{sortable ? (
<SortableList
data={fields}
key={fields.length}
onReordered={move}
extractId={(item) => item.id}
disableIndices={[0]}
renderItem={({ dnd, ...props }, index) => (
<EntityFieldMemo
key={props.id}
field={props as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
dnd={dnd}
useImperativeHandle(ref, () => ({
reset,
getValues: () => getValues(),
getData: () => {
return toCleanValues(getValues());
},
isValid: () => isValid
}));
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
const newField = {
name: "",
new: true,
field: {
type: _type,
config: {}
}
};
append(newField);
}
const formProps = {
watch,
register,
setValue,
getValues,
control,
setError
};
return (
<>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{sortable ? (
<SortableList
data={fields}
key={fields.length}
onReordered={move}
extractId={(item) => item.id}
disableIndices={[0]}
renderItem={({ dnd, ...props }, index) => (
<EntityFieldMemo
key={props.id}
field={props as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
dnd={dnd}
/>
)}
/>
) : (
<div>
{fields.map((field, index) => (
<EntityField
key={field.id}
field={field as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
/>
))}
</div>
)}
<Popover
className="flex flex-col w-full"
target={({ toggle }) => (
<SelectType
additionalFieldTypes={additionalFieldTypes}
onSelected={toggle}
onSelect={(type) => {
handleAppend(type as any);
}}
/>
)}
/>
) : (
<div>
{fields.map((field, index) => (
<EntityField
key={field.id}
field={field as any}
index={index}
form={formProps}
errors={errors}
remove={remove}
/>
))}
</div>
)}
<Popover
className="flex flex-col w-full"
target={({ toggle }) => (
<SelectType
onSelect={(type) => {
handleAppend(type as any);
toggle();
}}
/>
)}
>
<Button className="justify-center">Add Field</Button>
</Popover>
>
<Button className="justify-center">Add Field</Button>
</Popover>
</div>
</div>
</div>
</div>
</>
);
});
</>
);
}
);
const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
const types = fieldSpecs.filter((s) => s.addable !== false);
const SelectType = ({
onSelect,
additionalFieldTypes = [],
onSelected
}: {
onSelect: (type: string) => void;
additionalFieldTypes?: (TFieldSpec & { onClick?: () => void })[];
onSelected?: () => void;
}) => {
const types: (TFieldSpec & { onClick?: () => void })[] = fieldSpecs.filter(
(s) => s.addable !== false
);
if (additionalFieldTypes) {
types.push(...additionalFieldTypes);
}
return (
<div className="flex flex-row gap-2 justify-center flex-wrap">
@@ -208,7 +224,14 @@ const SelectType = ({ onSelect }: { onSelect: (type: string) => void }) => {
key={type.type}
IconLeft={type.icon}
variant="ghost"
onClick={() => onSelect(type.type)}
onClick={() => {
if (type.addable !== false) {
onSelect(type.type);
} else {
type.onClick?.();
}
onSelected?.();
}}
>
{type.label}
</Button>

View File

@@ -55,8 +55,10 @@ export function FlowsEmpty() {
title="No flow selected"
description="Please select a flow from the left sidebar or create a new one
to continue."
buttonText="Create Flow"
buttonOnClick={() => navigate(app.getSettingsPath(["flows"]))}
primary={{
children: "Create Flow",
onClick: () => navigate(app.getSettingsPath(["flows"]))
}}
/>
</main>
</>

View File

@@ -20,8 +20,10 @@ export function MediaRoot({ children }) {
Icon={IconPhoto}
title="Media not enabled"
description="Please enable media in the settings to continue."
buttonText="Manage Settings"
buttonOnClick={() => navigate(app.getSettingsPath(["media"]))}
primary={{
children: "Manage Settings",
onClick: () => navigate(app.getSettingsPath(["media"]))
}}
/>
);
}

View File

@@ -1,17 +1,18 @@
import { IconHome } from "@tabler/icons-react";
import { useEffect } from "react";
import { useAuth } from "ui/client";
import { useEffectOnce } from "ui/hooks/use-effect";
import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title";
import * as AppShell from "../layouts/AppShell/AppShell";
import { useNavigate } from "../lib/routes";
export const Root = ({ children }) => {
const { verify } = useAuth();
const { verify, user } = useAuth();
useEffect(() => {
useEffectOnce(() => {
verify();
}, []);
}, [user?.id]);
return (
<AppShell.Root>

View File

@@ -149,8 +149,9 @@ export function Setting<Schema extends TObject = any>({
console.log("save:success", success);
if (success) {
if (options?.reloadOnSave) {
window.location.reload();
//await actions.reload();
//window.location.reload();
await actions.reload();
setSubmitting(false);
}
} else {
setSubmitting(false);
@@ -175,7 +176,10 @@ export function Setting<Schema extends TObject = any>({
<Empty
title="Not found"
description={`Configuration at path ${path.join(".")} doesn't exist.`}
buttonText="Go back"
primary={{
children: "Go back",
onClick: () => goBack()
}}
/>
);
}

View File

@@ -2,11 +2,12 @@ import { IconSettings } from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { useBknd } from "ui/client/bknd";
import { Empty } from "ui/components/display/Empty";
import { Message } from "ui/components/display/Message";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Route, Switch } from "wouter";
import { Setting } from "./components/Setting";
import { Setting, type SettingProps } from "./components/Setting";
import { AuthSettings } from "./routes/auth.settings";
import { DataSettings } from "./routes/data.settings";
import { FlowsSettings } from "./routes/flows.settings";
@@ -44,7 +45,9 @@ function SettingsSidebar() {
}
export default function SettingsRoutes() {
useBknd({ withSecrets: true });
const b = useBknd({ withSecrets: true });
if (!b.hasSecrets) return <Message.MissingPermission what="the settings" />;
return (
<>
<SettingsSidebar />
@@ -117,13 +120,24 @@ const SettingRoutesRoutes = () => {
<ServerSettings schema={schema.server} config={config.server} />
<DataSettings schema={schema.data} config={config.data} />
<AuthSettings schema={schema.auth} config={config.auth} />
<FallbackRoutes module="media" schema={schema} config={config} uiSchema={uiSchema.media} />
<FallbackRoutes
module="media"
schema={schema}
config={config}
uiSchema={uiSchema.media}
options={{ reloadOnSave: true }}
/>
<FlowsSettings schema={schema.flows} config={config.flows} />
</>
);
};
const FallbackRoutes = ({ module, schema, config, ...settingProps }) => {
const FallbackRoutes = ({
module,
schema,
config,
...settingProps
}: SettingProps<any> & { module: string }) => {
const { app } = useBknd();
const basepath = app.getAdminConfig();
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");

134
app/src/ui/styles.css Normal file
View File

@@ -0,0 +1,134 @@
@import "./main.css";
@import "./components/form/json-schema/styles.css";
@import "@xyflow/react/dist/style.css";
@import "@mantine/core/styles.css";
@import "@mantine/notifications/styles.css";
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;
}
/* 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;
}