Files
bknd/app/src/ui/routes/data/data.$entity.$id.tsx

362 lines
12 KiB
TypeScript

import type { PrimaryFieldType } from "bknd";
import { ucFirst } from "bknd/utils";
import type { Entity, EntityData, EntityRelation } from "bknd";
import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "bknd/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";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
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";
import { useEntityAdminOptions } from "ui/options";
export function DataEntityUpdate({ params }) {
return <DataEntityUpdateImpl params={params} key={params.entity} />;
}
function DataEntityUpdateImpl({ params }) {
const { $data, relations } = useBkndData();
const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const entityId = params.id as PrimaryFieldType;
const [error, setError] = useState<string | null>(null);
const [navigate, _, _goBack] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
const targetRelations = relations.listableRelationsOf(entity);
const local_relation_refs = relations
.sourceRelationsOf(entity)
?.map((r) => r.other(entity).reference);
const $q = useEntityQuery(
entity.name,
entityId,
{
with: local_relation_refs,
},
{
keepPreviousData: false,
revalidateOnFocus: false,
shouldRetryOnError: false,
},
);
const options = useEntityAdminOptions(entity, "update", $q.data);
const backHref = routes.data.entity.list(entity.name);
const goBack = () => _goBack({ fallback: backHref });
async function onSubmitted(changeSet?: EntityData) {
//return;
if (!changeSet) {
notifications.show({
title: `Updating ${entity?.label}`,
message: "No changes to update",
color: "yellow",
});
return;
}
try {
await $q.update(changeSet);
if (error) setError(null);
notifications.show({
title: `Updating ${entity?.label}`,
message: `Successfully updated ID ${entityId}`,
color: "green",
});
// make sure form picks up the latest data
Form.reset();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update");
}
}
async function handleDelete() {
if (confirm("Are you sure to delete?")) {
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");
}
}
}
const data = $q.data;
const { Form, handleSubmit } = useEntityForm({
action: "update",
entity,
initialData: $q.data?.toJSON(),
onSubmitted,
});
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;
return (
<Fragment key={makeKey()}>
<AppShell.SectionHeader
right={
<>
<Dropdown
position="bottom-end"
items={[
...(options.actions?.context ?? []),
{
label: "Inspect",
onClick: () => {
bkndModals.open("debug", {
data: {
data: data as any,
entity: entity.toJSON(),
schema: entity.toSchema({ clean: true }),
form: Form.state.values,
state: Form.state,
},
});
},
},
{
label: "Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true,
}),
},
{
label: "Delete",
onClick: handleDelete,
destructive: true,
disabled: fieldsDisabled,
},
]}
>
<IconButton Icon={TbDots} />
</Dropdown>
{options.actions?.primary?.map(
(button, key) =>
button && <Button variant="primary" {...button} type="button" key={key} />,
)}
<Form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button
type="button"
onClick={Form.handleSubmit}
variant="primary"
tabIndex={entity.fields.length + 1}
disabled={!canSubmit || isSubmitting || fieldsDisabled}
>
Update
</Button>
)}
/>
</>
}
className="pl-3"
>
<Breadcrumbs2
backTo={backHref}
path={[{ label: entity.label, href: backHref }, { label: `Edit #${entityId}` }]}
/>
</AppShell.SectionHeader>
{$q.isLoading ? (
<div className="w-full h-full flex justify-center items-center font-mono opacity-30">
Loading...
</div>
) : (
<AppShell.Scrollable>
{options.header}
{error && (
<div className="flex flex-row dark:bg-red-950 bg-red-100 p-4">
<b className="mr-2">Update failed: </b> {error}
</div>
)}
<EntityForm
entity={entity}
entityId={entityId}
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={data ?? undefined}
Form={Form as any}
action="update"
className="flex flex-grow flex-col gap-3 p-3"
/>
{options.footer}
{targetRelations.length > 0 ? (
<EntityDetailRelations
id={entityId}
entity={entity}
relations={targetRelations}
/>
) : null}
</AppShell.Scrollable>
)}
</Fragment>
);
}
function EntityDetailRelations({
id,
entity,
relations,
}: {
id: PrimaryFieldType;
entity: Entity;
relations: EntityRelation[];
}) {
const [selected, setSelected] = useState<EntityRelation>(
// @ts-ignore
relations.length > 0 ? relations[0] : undefined,
);
function handleClick(relation: EntityRelation) {
setSelected(relation);
}
if (relations.length === 0) {
return null;
}
//console.log("selected", selected, relations[0].helper(entity.name).other.reference);
return (
<div className="flex flex-col max-w-full">
<AppShell.SectionHeaderTabs
title="Relations"
items={relations.map((relation) => {
const other = relation.other(entity);
return {
as: "button",
type: "button",
//label: ucFirst(other.entity.label),
label: ucFirst(other.reference),
onClick: () => handleClick(relation),
active: selected?.other(entity).reference === other.reference,
badge: relation.type(),
};
})}
/>
<div className="flex flex-grow flex-col gap-3 p-3">
<EntityDetailInner
id={id}
entity={entity}
relation={selected}
key={JSON.stringify(selected?.toJSON())}
/>
</div>
</div>
);
}
function EntityDetailInner({
id,
entity,
relation,
}: {
id: PrimaryFieldType;
entity: Entity;
relation: EntityRelation;
}) {
const other = relation.other(entity);
const [navigate] = useNavigate();
const [search, setSearch] = useState({
select: other.entity.getSelect(undefined, "table"),
sort: other.entity.getDefaultSort(),
limit: 10,
offset: 0,
});
// @todo: add custom key for invalidation
const $q = useApiQuery(
(api) =>
api.data.readManyByReference(entity.name, id, other.reference, {
...search,
limit: search.limit + 1 /* overfetch for softscan=false */,
}),
{
keepPreviousData: true,
revalidateOnFocus: true,
},
);
function handleClickRow(row: Record<string, any>) {
navigate(routes.data.entity.edit(other.entity.name, row.id));
}
let handleClickNew: any;
try {
if (other.entity.type !== "system") {
const ref = relation.getReferenceQuery(other.entity, id, other.reference);
handleClickNew = () => {
navigate(routes.data.entity.create(other.entity.name), {
query: ref.where,
});
};
}
} catch (e) {}
if (!$q.data) {
return null;
}
const isUpdating = $q.isValidating || $q.isLoading;
const meta = $q.data?.body.meta;
return (
<div
data-updating={isUpdating ? 1 : undefined}
className="transition-opacity data-[updating]:opacity-50"
>
<EntityTable2
select={search.select}
data={$q.data ?? null}
entity={other.entity}
sort={search.sort}
onClickRow={handleClickRow}
onClickNew={handleClickNew}
page={Math.floor(search.offset / search.limit) + 1}
total={meta?.count}
onClickPage={(page) => {
setSearch((s) => ({
...s,
offset: (page - 1) * s.limit,
}));
}}
/>
</div>
);
}