Merge pull request #253 from bknd-io/feat/ui-readonly-and-fixes

ui: reflect readonly mode by hiding controls + various small styling fixes
This commit is contained in:
dswbx
2025-09-05 17:10:55 +02:00
committed by GitHub
18 changed files with 153 additions and 74 deletions

View File

@@ -88,7 +88,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full",
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
disabledOrReadonly && "bg-muted/50 text-primary/50",
!disabledOrReadonly &&
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",

View File

@@ -88,6 +88,7 @@ const FieldImpl = ({
}, [inputProps?.defaultValue]);
const disabled = firstDefined(
ctx.readOnly,
inputProps?.disabled,
props.disabled,
schema.readOnly,

View File

@@ -61,6 +61,7 @@ export type FormContext<Data> = {
options: FormOptions;
root: string;
_formStateAtom: PrimitiveAtom<FormState<Data>>;
readOnly: boolean;
};
const FormContext = createContext<FormContext<any>>(undefined!);
@@ -81,6 +82,7 @@ export function Form<
hiddenSubmit = true,
ignoreKeys = [],
options = {},
readOnly = false,
...props
}: Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
schema: Schema;
@@ -93,6 +95,7 @@ export function Form<
hiddenSubmit?: boolean;
options?: FormOptions;
initialValues?: Schema extends JSONSchema ? FromSchema<Schema> : never;
readOnly?: boolean;
}) {
const [schema, initial] = omitSchema(_schema, ignoreKeys, _initialValues);
const lib = useMemo(() => new Draft2019(schema), [JSON.stringify(schema)]);
@@ -190,8 +193,9 @@ export function Form<
options,
root: "",
path: "",
readOnly,
}),
[schema, initialValues, options],
[schema, initialValues, options, readOnly],
) as any;
return (

View File

@@ -339,15 +339,15 @@ export const SectionHeaderLink = <E extends React.ElementType = "a">({
<Tag
{...props}
className={twMerge(
"hover:bg-primary/5 flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg",
"flex flex-row items-center justify-center gap-2.5 px-5 h-12 leading-none font-medium text-primary/80 rounded-tr-lg rounded-tl-lg cursor-pointer z-2",
active
? "bg-background hover:bg-background text-primary border border-muted border-b-0"
: "link",
? "bg-primary/3 text-primary border border-muted border-b-0"
: "link hover:bg-primary/2",
badge && "pr-4",
className,
)}
>
{children}
<span className="truncate">{children}</span>
{badge ? (
<span className="px-3 py-1 rounded-full font-mono bg-primary/5 text-sm leading-none">
{badge}
@@ -365,8 +365,8 @@ export type SectionHeaderTabsProps = {
};
export const SectionHeaderTabs = ({ title, items }: SectionHeaderTabsProps) => {
return (
<SectionHeader className="mt-10 border-t pl-3 pb-0 items-end">
<div className="flex flex-row items-center gap-6 -mb-px">
<SectionHeader className="mt-10 border-t border-t-muted pl-3 pb-0 items-end overflow-x-scroll app-scrollbar relative after:inset-0 after:z-1">
<div className="flex flex-row items-center gap-6 relative">
{title && (
<SectionHeaderTitle className="pl-2 hidden md:block">{title}</SectionHeaderTitle>
)}

View File

@@ -232,7 +232,7 @@ const PopoverTable = ({
data={container ?? []}
entity={entity}
select={query.select}
total={container.meta?.count}
total={container.body.meta?.count}
page={query.page}
onClickRow={onClickRow}
onClickPage={onClickPage}

View File

@@ -8,7 +8,9 @@ import { s } from "bknd/utils";
import { cloneSchema } from "core/utils/schema";
const schema = s.object({
name: entitySchema.properties.name,
name: s.string({
pattern: /^[a-z][a-zA-Z_]+$/,
}),
config: entitySchema.properties.config.partial().optional(),
});
type Schema = s.Static<typeof schema>;

View File

@@ -26,6 +26,7 @@ function AuthRolesEditInternal({ params }) {
const roleName = params.role;
const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
const { readonly } = useBknd();
async function handleUpdate() {
console.log("data", formRef.current?.isValid());
@@ -57,7 +58,7 @@ function AuthRolesEditInternal({ params }) {
absolute: true,
}),
},
{
!readonly && {
label: "Delete",
onClick: handleDelete,
destructive: true,
@@ -67,9 +68,11 @@ function AuthRolesEditInternal({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
!readonly && (
<Button variant="primary" onClick={handleUpdate}>
Update
</Button>
)
</>
}
className="pl-3"

View File

@@ -11,10 +11,12 @@ import { Button } from "../../components/buttons/Button";
import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
import { useBknd } from "ui/client/bknd";
export function AuthRolesList() {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const { readonly } = useBknd();
const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({
@@ -30,6 +32,7 @@ export function AuthRolesList() {
}
function openCreateModal() {
if (readonly) return;
bkndModals.open(
"form",
{
@@ -59,9 +62,11 @@ export function AuthRolesList() {
<>
<AppShell.SectionHeader
right={
<Button variant="primary" onClick={openCreateModal}>
Create new
</Button>
!readonly && (
<Button variant="primary" onClick={openCreateModal}>
Create new
</Button>
)
}
>
Roles & Permissions

View File

@@ -52,6 +52,7 @@ const formConfig = {
function AuthSettingsInternal() {
const { config, schema: _schema, actions, $auth } = useBkndAuth();
const { readonly } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
schema.properties.jwt.required = ["alg"];
@@ -61,7 +62,13 @@ function AuthSettingsInternal() {
}
return (
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Form
schema={schema}
initialValues={config as any}
onSubmit={onSubmit}
{...formConfig}
readOnly={readonly}
>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
@@ -73,13 +80,15 @@ function AuthSettingsInternal() {
<AppShell.SectionHeader
className="pl-4"
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
!readonly && (
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
)
}
>
Settings

View File

@@ -60,6 +60,7 @@ const formOptions = {
};
function AuthStrategiesListInternal() {
const { readonly } = useBknd();
const $auth = useBkndAuth();
const config = $auth.config.strategies;
const schema = $auth.schema.properties.strategies;
@@ -80,6 +81,7 @@ function AuthStrategiesListInternal() {
initialValues={config}
onSubmit={handleSubmit}
options={formOptions}
readOnly={readonly}
>
<Subscribe
selector={(state) => ({
@@ -92,13 +94,15 @@ function AuthStrategiesListInternal() {
<AppShell.SectionHeader
className="pl-4"
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
!readonly && (
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
)
}
>
Strategies

View File

@@ -112,18 +112,27 @@ const EntityLinkList = ({
suggestCreate = false,
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
const { $data } = useBkndData();
const { readonly } = useBknd();
const navigate = useRouteNavigate();
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;
if (suggestCreate) {
if (readonly) {
return <Empty className="py-10" description="No entities created." />;
}
return (
<Empty
className="py-10"
description="Create your first entity to get started."
secondary={{
children: "Create entity",
onClick: () => $data.modals.createEntity(),
}}
/>
);
}
return null;
}
function handleClick(entity: Entity) {
@@ -163,7 +172,7 @@ const EntityLinkList = ({
href={href}
className="justify-between items-center"
>
{entity.label}
<span className="truncate">{entity.label}</span>
{isLinkActive(href, 1) && (
<Button

View File

@@ -17,6 +17,7 @@ import { bkndModals } from "ui/modals";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { notifications } from "@mantine/notifications";
export function DataEntityUpdate({ params }) {
return <DataEntityUpdateImpl params={params} key={params.entity} />;
@@ -58,14 +59,22 @@ function DataEntityUpdateImpl({ params }) {
async function onSubmitted(changeSet?: EntityData) {
//return;
if (!changeSet) {
goBack();
notifications.show({
title: `Updating ${entity?.label}`,
message: "No changes to update",
color: "yellow",
});
return;
}
try {
await $q.update(changeSet);
if (error) setError(null);
goBack();
notifications.show({
title: `Updating ${entity?.label}`,
message: `Successfully updated ID ${entityId}`,
color: "green",
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update");
}
@@ -76,6 +85,11 @@ function DataEntityUpdateImpl({ params }) {
try {
await $q._delete();
if (error) setError(null);
notifications.show({
title: `Deleting ${entity?.label}`,
message: `Successfully deleted ID ${entityId}`,
color: "green",
});
goBack();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete");
@@ -233,7 +247,7 @@ function EntityDetailRelations({
return {
as: "button",
type: "button",
label: ucFirst(other.reference),
label: ucFirst(other.entity.label),
onClick: () => handleClick(relation),
active: selected?.other(entity).reference === other.reference,
badge: relation.type(),

View File

@@ -12,6 +12,7 @@ import { routes, useNavigate } from "ui/lib/routes";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { s } from "bknd/utils";
import { notifications } from "@mantine/notifications";
export function DataEntityCreate({ params }) {
const { $data } = useBkndData();
@@ -39,10 +40,18 @@ export function DataEntityCreate({ params }) {
if (!changeSet) return;
try {
await $q.create(changeSet);
const result = await $q.create(changeSet);
if (error) setError(null);
// @todo: navigate to created?
goBack();
if (result.id) {
notifications.show({
title: `Creating ${entity?.label}`,
message: `Successfully created with ID ${result.id}`,
color: "green",
});
navigate(routes.data.entity.edit(params.entity, result.id));
} else {
goBack();
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create");
}
@@ -79,8 +88,12 @@ export function DataEntityCreate({ params }) {
/>
</>
}
className="pl-3"
>
<Breadcrumbs2 backTo={backHref} path={[{ label: entity.label }, { label: "Create" }]} />
<Breadcrumbs2
backTo={backHref}
path={[{ label: entity.label, href: backHref }, { label: "Create" }]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
{error && (

View File

@@ -169,6 +169,8 @@ function EntityCreateButton({ entity }: { entity: Entity }) {
media: b.app.config.media.entity_name,
};
if (system.users === entity.name) {
if (b.readonly) return null;
return (
<Button onClick={createUserModal.open} variant="primary">
New User

View File

@@ -40,6 +40,7 @@ const formConfig = {
function MediaSettingsInternal() {
const { config, schema: _schema, actions } = useBkndMedia();
const { readonly } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
schema.if = { properties: { enabled: { const: true } } };
@@ -53,7 +54,13 @@ function MediaSettingsInternal() {
return (
<>
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Form
schema={schema}
initialValues={config as any}
onSubmit={onSubmit}
{...formConfig}
readOnly={readonly}
>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
@@ -64,13 +71,15 @@ function MediaSettingsInternal() {
{({ dirty, errors, submitting }) => (
<AppShell.SectionHeader
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
!readonly && (
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
)
}
>
Settings
@@ -132,6 +141,7 @@ const AdapterIcon = ({ type }: { type: string }) => {
function Adapters() {
const ctx = AnyOf.useContext();
const { readonly } = useBknd();
return (
<Formy.Group>
@@ -150,6 +160,7 @@ function Adapters() {
"flex flex-row items-center justify-center gap-3 border",
ctx.selected === i && "border-primary",
)}
disabled={readonly}
>
<div>
<AdapterIcon type={schema.properties.type.const} />

View File

@@ -107,8 +107,8 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
return;
});
const deleteAllowed = options?.allowDelete?.(config) ?? true;
const editAllowed = options?.allowEdit?.(config) ?? true;
const deleteAllowed = (options?.allowDelete?.(config) ?? true) && !readonly;
const editAllowed = (options?.allowEdit?.(config) ?? true) && !readonly;
const showAlert = options?.showAlert?.(config) ?? undefined;
console.log("--setting", { schema, config, prefix, path, exclude });
@@ -120,14 +120,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
const onToggleEdit = useEvent(() => {
if (!editAllowed || readonly) return;
if (!editAllowed) return;
setEditing((prev) => !prev);
//formRef.current?.cancel();
});
const onSave = useEvent(async () => {
if (!editAllowed || !editing || readonly) return;
if (!editAllowed || !editing) return;
if (formRef.current?.validateForm()) {
setSubmitting(true);
@@ -215,14 +215,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
>
<IconButton Icon={TbSettings} />
</Dropdown>
<Button onClick={onToggleEdit} disabled={!editAllowed || readonly}>
<Button onClick={onToggleEdit} disabled={!editAllowed}>
{editing ? "Cancel" : "Edit"}
</Button>
{editing && (
<Button
variant="primary"
onClick={onSave}
disabled={submitting || !editAllowed || readonly}
disabled={submitting || !editAllowed}
>
{submitting ? "Save..." : "Save"}
</Button>

View File

@@ -30,7 +30,7 @@ export const SettingNewModal = ({
const [location, navigate] = useLocation();
const [formSchema, setFormSchema] = useState(schema);
const [submitting, setSubmitting] = useState(false);
const { actions } = useBknd();
const { actions, readonly } = useBknd();
const [opened, { open, close }] = useDisclosure(false);
const isGeneratedKey = generateKey !== undefined;
const isStaticGeneratedKey = typeof generateKey === "string";
@@ -98,15 +98,17 @@ export const SettingNewModal = ({
return (
<>
<div className="flex flex-row">
{isAnyOf ? (
<Dropdown position="top-start" items={anyOfItems} itemsClassName="gap-3">
<Button>Add new</Button>
</Dropdown>
) : (
<Button onClick={open}>Add new</Button>
)}
</div>
{!readonly && (
<div className="flex flex-row">
{isAnyOf ? (
<Dropdown position="top-start" items={anyOfItems} itemsClassName="gap-3">
<Button>Add new</Button>
</Dropdown>
) : (
<Button onClick={open}>Add new</Button>
)}
</div>
)}
<Modal
open={opened}

View File

@@ -67,7 +67,7 @@ export const DataSettings = ({
schema,
config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
const { app } = useBknd();
const { app, readonly } = useBknd();
const prefix = app.getAbsolutePath("settings");
const entities = Object.keys(config.entities ?? {});
@@ -105,7 +105,7 @@ export const DataSettings = ({
options={{
showAlert: (config: any) => {
// it's weird, but after creation, the config is not set (?)
if (config?.type === "primary") {
if (config?.type === "primary" && !readonly) {
return "Modifying the primary field may result in strange behaviors.";
}
return;
@@ -137,7 +137,7 @@ export const DataSettings = ({
config={config.entities?.[entity] as any}
options={{
showAlert: (config: any) => {
if (config.type === "system") {
if (config.type === "system" && !readonly) {
return "Modifying the system entities may result in strange behaviors.";
}
return;