extended data canvas and init indices management

This commit is contained in:
dswbx
2025-05-03 17:58:58 +02:00
parent 6694c63990
commit 66959fe789
19 changed files with 440 additions and 159 deletions
+1 -1
View File
@@ -218,7 +218,7 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
* @param object * @param object
* @param sources * @param sources
*/ */
export function mergeObject(object, ...sources) { export function mergeObject<R = unknown>(object, ...sources): R {
for (const source of sources) { for (const source of sources) {
for (const [key, value] of Object.entries(source)) { for (const [key, value] of Object.entries(source)) {
if (value === undefined) { if (value === undefined) {
+2 -3
View File
@@ -6,6 +6,7 @@ import {
type EntityManager, type EntityManager,
constructEntity, constructEntity,
constructRelation, constructRelation,
constructIndex,
} from "data"; } from "data";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { DataController } from "./api/DataController"; import { DataController } from "./api/DataController";
@@ -35,9 +36,7 @@ export class AppData extends Module<typeof dataConfigSchema> {
); );
const indices = transformObject(_indices, (index, name) => { const indices = transformObject(_indices, (index, name) => {
const entity = _entity(index.entity)!; return constructIndex(index, _entity, name);
const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name);
}); });
for (const entity of Object.values(entities)) { for (const entity of Object.values(entities)) {
+1
View File
@@ -68,6 +68,7 @@ export const indicesSchema = tb.Type.Object(
additionalProperties: false, additionalProperties: false,
}, },
); );
export type TAppDataIndex = Static<(typeof indicesSchema)[number]>;
export const dataConfigSchema = tb.Type.Object( export const dataConfigSchema = tb.Type.Object(
{ {
+1 -1
View File
@@ -17,7 +17,7 @@ export {
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner"; export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
export { constructEntity, constructRelation } from "./schema/constructor"; export { constructEntity, constructRelation, constructIndex } from "./schema/constructor";
export const DatabaseEvents = { export const DatabaseEvents = {
...MutatorEvents, ...MutatorEvents,
+23 -2
View File
@@ -1,6 +1,13 @@
import { transformObject } from "core/utils"; import { transformObject } from "core/utils";
import { Entity, type Field } from "data"; import { Entity, EntityIndex, type Field } from "data";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; import {
FIELDS,
RELATIONS,
type TAppDataEntity,
type TAppDataField,
type TAppDataIndex,
type TAppDataRelation,
} from "data/data-schema";
export function constructEntity(name: string, entityConfig: TAppDataEntity) { export function constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
@@ -32,3 +39,17 @@ export function constructRelation(
relationConfig.config, relationConfig.config,
); );
} }
export function constructIndex(
indexConfig: TAppDataIndex,
resolver: (name: Entity | string) => Entity,
name: string,
) {
const entity = resolver(indexConfig.entity);
return new EntityIndex(
entity,
entity.fields.filter((f) => indexConfig.fields.includes(f.name)),
indexConfig.unique,
name,
);
}
@@ -70,6 +70,7 @@ export function useBkndData() {
}; };
const $data = { const $data = {
entity: (name: string) => entities[name], entity: (name: string) => entities[name],
indicesOf: (name: string) => app.indices.filter((i) => i.entity.name === name),
modals, modals,
system: (name: string) => ({ system: (name: string) => ({
any: entities[name]?.type === "system", any: entities[name]?.type === "system",
@@ -82,6 +83,7 @@ export function useBkndData() {
$data, $data,
entities, entities,
relations: app.relations, relations: app.relations,
indices: app.indices,
config: config.data, config: config.data,
schema: schema.data, schema: schema.data,
actions, actions,
+17 -1
View File
@@ -1,5 +1,12 @@
import type { App } from "App"; import type { App } from "App";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data"; import {
type Entity,
type EntityIndex,
type EntityRelation,
constructEntity,
constructRelation,
constructIndex,
} from "data";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
import type { BkndAdminOptions } from "ui/client/BkndProvider"; import type { BkndAdminOptions } from "ui/client/BkndProvider";
@@ -14,6 +21,7 @@ export class AppReduced {
// @todo: change to record // @todo: change to record
private _entities: Entity[] = []; private _entities: Entity[] = [];
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = [];
private _flows: Flow[] = []; private _flows: Flow[] = [];
constructor( constructor(
@@ -30,6 +38,10 @@ export class AppReduced {
return constructRelation(relation, this.entity.bind(this)); return constructRelation(relation, this.entity.bind(this));
}); });
this._indices = Object.entries(this.appJson.data.indices ?? {}).map(([name, index]) => {
return constructIndex(index, this.entity.bind(this), name);
});
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) { for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
// @ts-ignore // @ts-ignore
// @todo: fix constructing flow // @todo: fix constructing flow
@@ -58,6 +70,10 @@ export class AppReduced {
return new RelationAccessor(this._relations); return new RelationAccessor(this._relations);
} }
get indices(): EntityIndex[] {
return this._indices;
}
get flows(): Flow[] { get flows(): Flow[] {
return this._flows; return this._flows;
} }
+23 -5
View File
@@ -13,13 +13,15 @@ import {
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
type CanvasProps = ReactFlowProps & { export type CanvasProps = Omit<ReactFlowProps, "onNodesChange" | "onEdgesChange"> & {
externalProvider?: boolean; externalProvider?: boolean;
backgroundStyle?: "lines" | "dots"; backgroundStyle?: "lines" | "dots";
minimap?: boolean | MiniMapProps; minimap?: boolean | MiniMapProps;
children?: Element | ReactNode; children?: Element | ReactNode;
onDropNewNode?: (base: any) => any; onDropNewNode?: (base: any) => any;
onDropNewEdge?: (base: any) => any; onDropNewEdge?: (base: any) => any;
onNodesChange?: (changes: any) => void;
onEdgesChange?: (changes: any) => void;
}; };
export function Canvas({ export function Canvas({
@@ -33,8 +35,8 @@ export function Canvas({
onDropNewEdge, onDropNewEdge,
...props ...props
}: CanvasProps) { }: CanvasProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []); const [nodes, setNodes, _onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []); const [edges, setEdges, _onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -42,6 +44,22 @@ export function Canvas({
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isPointerPressed, setIsPointerPressed] = useState(false); const [isPointerPressed, setIsPointerPressed] = useState(false);
const onNodesChange = useCallback(
(changes) => {
_onNodesChange(changes);
props.onNodesChange?.(changes);
},
[_onNodesChange, props.onNodesChange],
);
const onEdgesChange = useCallback(
(changes) => {
_onEdgesChange(changes);
props.onEdgesChange?.(changes);
},
[_onEdgesChange, props.onEdgesChange],
);
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey) { if (event.metaKey) {
setIsCommandPressed(true); setIsCommandPressed(true);
@@ -173,8 +191,6 @@ export function Canvas({
snapToGrid snapToGrid
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodesConnectable={false} nodesConnectable={false}
/*panOnDrag={isSpacePressed}*/ /*panOnDrag={isSpacePressed}*/
panOnDrag={true} panOnDrag={true}
@@ -183,6 +199,8 @@ export function Canvas({
zoomOnDoubleClick={false} zoomOnDoubleClick={false}
selectionOnDrag={!isSpacePressed} selectionOnDrag={!isSpacePressed}
{...props} {...props}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
> >
{backgroundStyle === "lines" && ( {backgroundStyle === "lines" && (
<Background <Background
@@ -13,7 +13,7 @@ export function DefaultNode({ selected, children, className, ...props }: TDefaul
{...props} {...props}
className={twMerge( className={twMerge(
"relative w-80 shadow-lg rounded-lg bg-background", "relative w-80 shadow-lg rounded-lg bg-background",
selected && "outline outline-blue-500/25", selected && "ring-4 ring-blue-400/50",
className, className,
)} )}
> >
@@ -246,7 +246,6 @@ export const Switch = forwardRef<
props.disabled && "opacity-50 !cursor-not-allowed", props.disabled && "opacity-50 !cursor-not-allowed",
)} )}
onCheckedChange={(bool) => { onCheckedChange={(bool) => {
console.log("setting", bool);
props.onChange?.({ target: { value: bool } }); props.onChange?.({ target: { value: bool } });
}} }}
{...(props as any)} {...(props as any)}
@@ -272,7 +271,7 @@ export const Switch = forwardRef<
export const Select = forwardRef< export const Select = forwardRef<
HTMLSelectElement, HTMLSelectElement,
React.ComponentProps<"select"> & { React.ComponentProps<"select"> & {
options?: { value: string; label: string }[] | (string | number)[]; options?: { value: string; label: string; disabled?: boolean }[] | (string | number)[];
} }
>(({ children, options, ...props }, ref) => ( >(({ children, options, ...props }, ref) => (
<div className="flex w-full relative"> <div className="flex w-full relative">
@@ -297,7 +296,7 @@ export const Select = forwardRef<
return o; return o;
}) })
.map((opt) => ( .map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label} {opt.label}
</option> </option>
))} ))}
@@ -78,6 +78,7 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
return ( return (
<div key={itemPath} className="flex flex-row gap-2"> <div key={itemPath} className="flex flex-row gap-2">
<FieldComponent <FieldComponent
required={schema.minItems > 0}
name={itemPath} name={itemPath}
schema={subschema!} schema={subschema!}
value={value} value={value}
+6 -4
View File
@@ -28,10 +28,10 @@ export function useRoutePathState(_path?: string, identifier?: string) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
function toggle(_open?: boolean) { function toggle(_open?: boolean) {
const open = _open ?? !localActive; const open = _open ?? !active;
if (ctx) { if (ctx) {
ctx.setActiveIdentifier(identifier!); ctx.setActiveIdentifier(open ? identifier! : "");
} }
if (path) { if (path) {
@@ -58,7 +58,7 @@ export function useRoutePathState(_path?: string, identifier?: string) {
} }
type RoutePathStateContextType = { type RoutePathStateContextType = {
defaultIdentifier: string; defaultIdentifier?: string;
path: string; path: string;
activeIdentifier: string; activeIdentifier: string;
setActiveIdentifier: (identifier: string) => void; setActiveIdentifier: (identifier: string) => void;
@@ -72,7 +72,9 @@ export function RoutePathStateProvider({
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) { }: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
const segment = extractPathSegment(path); const segment = extractPathSegment(path);
const routeIdentifier = useParams()[segment]; const routeIdentifier = useParams()[segment];
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier); const [activeIdentifier, setActiveIdentifier] = useState(
routeIdentifier ?? defaultIdentifier ?? "",
);
return ( return (
<RoutePathStateContext.Provider <RoutePathStateContext.Provider
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }} value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
+1 -1
View File
@@ -10,7 +10,7 @@ import { useClipboard } from "@mantine/hooks";
import { ButtonLink } from "ui/components/buttons/Button"; import { ButtonLink } from "ui/components/buttons/Button";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media"; import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { JsonViewer } from "ui"; import { JsonViewer } from "ui/components/code/JsonViewer";
export type MediaInfoModalProps = { export type MediaInfoModalProps = {
file: FileState; file: FileState;
@@ -1,17 +1,43 @@
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react"; import { MarkerType, type Node, Position, ReactFlowProvider, useReactFlow } from "@xyflow/react";
import type { AppDataConfig, TAppDataEntity } from "data/data-schema"; import type { AppDataConfig, TAppDataEntity, TAppDataField } from "data/data-schema";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { Canvas } from "ui/components/canvas/Canvas"; import { Canvas, type CanvasProps } from "ui/components/canvas/Canvas";
import { layoutWithDagre } from "ui/components/canvas/layouts"; import { layoutWithDagre } from "ui/components/canvas/layouts";
import { Panels } from "ui/components/canvas/panels"; import { Panels } from "ui/components/canvas/panels";
import { EntityTableNode } from "./EntityTableNode"; import { EntityTableNode } from "./EntityTableNode";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { useCallback } from "react";
import { mergeObject, transformObject } from "core/utils";
export interface TCanvasEntityField extends TAppDataField {
name: string;
indexed?: boolean;
}
export interface TCanvasEntity extends TAppDataEntity {
label: string;
fields: Record<string, TCanvasEntityField>;
[key: string]: any;
}
function entitiesToNodes(
entities: AppDataConfig["entities"],
indices?: AppDataConfig["indices"],
): Node<TCanvasEntity>[] {
const indexed_fields = Object.entries(indices ?? {}).flatMap(([, index]) => index.fields);
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
return Object.entries(entities ?? {}).map(([name, entity]) => { return Object.entries(entities ?? {}).map(([name, entity]) => {
return { return {
id: name, id: name,
data: { label: name, ...entity }, data: {
...entity,
label: name,
fields: transformObject(entity.fields ?? {}, (f, name) => ({
...f,
name,
indexed: indexed_fields.includes(name),
})),
},
type: "entity", type: "entity",
dragHandle: ".drag-handle", dragHandle: ".drag-handle",
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
@@ -65,24 +91,29 @@ const nodeTypes = {
entity: EntityTableNode.Component, entity: EntityTableNode.Component,
} as const; } as const;
const getEdgeStyle = (theme: string) => ({
stroke: theme === "light" ? "#ccc" : "#666",
});
const getMarkerEndStyle = (theme: string) => ({
type: MarkerType.Arrow,
width: 20,
height: 20,
color: theme === "light" ? "#aaa" : "#777",
});
export function DataSchemaCanvas() { export function DataSchemaCanvas() {
const { const {
config: { data }, config: { data },
} = useBknd(); } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const nodes = entitiesToNodes(data.entities); const nodes = entitiesToNodes(data.entities, data.indices);
console.log(nodes);
const edges = relationsToEdges(data.relations).map((e) => ({ const edges = relationsToEdges(data.relations).map((e) => ({
...e, ...e,
style: { style: getEdgeStyle(theme),
stroke: theme === "light" ? "#ccc" : "#666",
},
type: "smoothstep", type: "smoothstep",
markerEnd: { markerEnd: getMarkerEndStyle(theme),
type: MarkerType.Arrow,
width: 20,
height: 20,
color: theme === "light" ? "#aaa" : "#777",
},
})); }));
const nodeLayout = layoutWithDagre({ const nodeLayout = layoutWithDagre({
@@ -107,7 +138,7 @@ export function DataSchemaCanvas() {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<Canvas <ActualCanvas
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@@ -119,7 +150,49 @@ export function DataSchemaCanvas() {
}} }}
> >
<Panels zoom minimap /> <Panels zoom minimap />
</Canvas> </ActualCanvas>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }
function toRecord(nodes: Node<TAppDataEntity>[]): Record<string, Node<TAppDataEntity>> {
return Object.fromEntries(nodes.map((n) => [n.id, n]));
}
function ActualCanvas(props: CanvasProps) {
const flow = useReactFlow();
const { theme } = useTheme();
const onNodesChange = useCallback((changes: any) => {
const nodes = mergeObject<Record<string, Node<TAppDataEntity>>>(
toRecord(flow.getNodes()),
toRecord(changes),
);
const selected = Object.values(nodes).filter((n) => n.selected);
const selected_names = selected.map((n) => n.id);
flow.setEdges((edges) =>
edges.map((edge) => {
if (selected_names.includes(edge.source) || selected_names.includes(edge.target)) {
return {
...edge,
animated: true,
style: { stroke: "#6495c6" },
markerEnd: {
...getMarkerEndStyle(theme),
color: "#6495c6",
},
};
}
return {
...edge,
style: getEdgeStyle(theme),
animated: false,
markerEnd: getMarkerEndStyle(theme),
};
}),
);
}, []);
return <Canvas {...props} onNodesChange={onNodesChange as any} />;
}
@@ -1,26 +1,21 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { TAppDataEntity } from "data/data-schema"; import type { TAppDataEntity } from "data/data-schema";
import { useState } from "react"; import { useEffect, useState } from "react";
import { TbDiamonds, TbKey } from "react-icons/tb"; import { TbBolt, TbDiamonds, TbKey } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode"; import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
import { useTheme } from "ui/client/use-theme";
import { useNavigate } from "ui/lib/routes";
import type { TCanvasEntity, TCanvasEntityField } from "./DataSchemaCanvas";
export type TableProps = { export type TableProps = {
name: string; name: string;
type?: string; type?: string;
fields: TableField[]; fields: TCanvasEntityField[];
};
export type TableField = {
name: string;
type: string;
primary?: boolean;
foreign?: boolean;
indexed?: boolean;
}; };
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) { function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
const [hovered, setHovered] = useState(false);
const { data } = props; const { data } = props;
const fields = props.data.fields ?? {}; const fields = props.data.fields ?? {};
const field_count = Object.keys(fields).length; const field_count = Object.keys(fields).length;
@@ -32,10 +27,11 @@ function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>
{Object.entries(fields).map(([name, field], index) => ( {Object.entries(fields).map(([name, field], index) => (
<TableRow <TableRow
key={index} key={index}
field={{ name, ...field }} field={field}
table={data.label} table={data.label}
index={index} index={index}
last={field_count === index + 1} last={field_count === index + 1}
selected={props.selected && ["relation", "primary"].includes(field.type)}
/> />
))} ))}
</div> </div>
@@ -53,13 +49,16 @@ const TableRow = ({
index, index,
onHover, onHover,
last, last,
selected,
}: { }: {
field: TableField; field: TCanvasEntityField;
table: string; table: string;
index: number; index: number;
last?: boolean; last?: boolean;
selected?: boolean;
onHover?: (hovered: boolean) => void; onHover?: (hovered: boolean) => void;
}) => { }) => {
const [navigate] = useNavigate();
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2; const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
const handles = true; const handles = true;
const handleId = `${table}:${field.name}`; const handleId = `${table}:${field.name}`;
@@ -67,10 +66,12 @@ const TableRow = ({
return ( return (
<div <div
className={twMerge( className={twMerge(
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto", "flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-pointer",
last && "rounded-bl-lg rounded-br-lg", last && "rounded-bl-lg rounded-br-lg",
selected && "bg-primary/5",
"hover:bg-primary/5", "hover:bg-primary/5",
)} )}
onClick={() => navigate(`/entity/${table}/fields/${field.name}`)}
> >
{handles && ( {handles && (
<Handle <Handle
@@ -86,7 +87,12 @@ const TableRow = ({
{field.type === "primary" && <TbKey className="text-yellow-700" />} {field.type === "primary" && <TbKey className="text-yellow-700" />}
{field.type === "relation" && <TbDiamonds className="text-sky-700" />} {field.type === "relation" && <TbDiamonds className="text-sky-700" />}
</div> </div>
<div className="flex flex-grow">{field.name}</div> <div className="flex flex-grow items-center gap-1">
<span className={field.config?.required ? "font-bold" : "opacity-90"}>
{field.name}
</span>{" "}
{field.indexed && <TbBolt className="text-warning-foreground/50" />}
</div>
<div className="flex opacity-60">{field.type}</div> <div className="flex opacity-60">{field.type}</div>
{handles && ( {handles && (
+1 -2
View File
@@ -1,6 +1,6 @@
import { isDebug } from "core"; import { isDebug } from "core";
import { autoFormatString } from "core/utils"; import { autoFormatString } from "core/utils";
import { type ChangeEvent, useState } from "react"; import type { ChangeEvent } from "react";
import { import {
TbAt, TbAt,
TbBrandAppleFilled, TbBrandAppleFilled,
@@ -13,7 +13,6 @@ import {
TbBrandX, TbBrandX,
TbSettings, TbSettings,
} from "react-icons/tb"; } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
-4
View File
@@ -83,10 +83,6 @@ export function DataRoot({ children }) {
</AppShell.SectionHeader> </AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}> <AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow py-3 gap-3"> <div className="flex flex-col flex-grow py-3 gap-3">
{/*<div className="pt-3 px-3">
<SearchInput placeholder="Search entities" />
</div>*/}
<EntityLinkList entities={entityList.regular} context={context} suggestCreate /> <EntityLinkList entities={entityList.regular} context={context} suggestCreate />
<EntityLinkList entities={entityList.system} context={context} title="System" /> <EntityLinkList entities={entityList.system} context={context} title="System" />
<EntityLinkList <EntityLinkList
+127 -97
View File
@@ -31,6 +31,8 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { extractSchema } from "../settings/utils/schema"; import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state"; import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
import { EntityIndicesForm } from "./forms/entity.indices.form";
import type { TAppDataIndex } from "data/data-schema";
export function DataSchemaEntity({ params }) { export function DataSchemaEntity({ params }) {
const { $data } = useBkndData(); const { $data } = useBkndData();
@@ -42,106 +44,100 @@ export function DataSchemaEntity({ params }) {
} }
return ( return (
<RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields"> <>
<AppShell.SectionHeader <RoutePathStateProvider
right={ path={`/entity/${entity.name}/:setting?`}
<> defaultIdentifier="fields"
<Dropdown
items={[
{
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,
}),
},
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
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"
> >
<div className="flex flex-row gap-4"> <AppShell.SectionHeader
<Breadcrumbs2 right={
path={[{ label: "Schema", href: "/" }, { label: entity.label }]} <>
backTo="/" <Dropdown
/> items={[
<Link to="/" className="hidden md:inline"> {
<Button IconLeft={TbSitemap}>Overview</Button> label: "Data",
</Link> onClick: () =>
</div> navigate(
</AppShell.SectionHeader> routes.data.root() + routes.data.entity.list(entity.name),
<div className="flex flex-col h-full" key={entity.name}> {
<Fields entity={entity} /> absolute: true,
},
),
},
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true,
}),
},
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
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"
>
<div className="flex flex-row gap-4">
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
<Link to="/" className="hidden md:inline">
<Button IconLeft={TbSitemap}>Overview</Button>
</Link>
</div>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} />
<BasicSettings entity={entity} /> <BasicSettings entity={entity} />
<AppShell.RouteAwareSectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
identifier="relations" identifier="relations"
title="Relations"
ActiveIcon={IconCirclesRelation}
>
<Empty
title="Relations" title="Relations"
description="This will soon be available here. Meanwhile, check advanced settings." ActiveIcon={IconCirclesRelation}
primary={{ >
children: "Advanced Settings", <Empty
onClick: () => title="Relations"
navigate(routes.settings.path(["data", "relations"]), { absolute: true }), description="This will soon be available here. Meanwhile, check advanced settings."
}} primary={{
/> children: "Advanced Settings",
</AppShell.RouteAwareSectionHeaderAccordionItem> onClick: () =>
<AppShell.RouteAwareSectionHeaderAccordionItem navigate(routes.settings.path(["data", "relations"]), {
identifier="indices" absolute: true,
title="Indices" }),
ActiveIcon={IconBolt} }}
> />
<Empty </AppShell.RouteAwareSectionHeaderAccordionItem>
title="Indices" <Indices entity={entity} />
description="This will soon be available here. Meanwhile, check advanced settings." </div>
primary={{ </RoutePathStateProvider>
children: "Advanced Settings", </>
onClick: () =>
navigate(routes.settings.path(["data", "indices"]), {
absolute: true,
}),
}}
/>
</AppShell.RouteAwareSectionHeaderAccordionItem>
</div>
</RoutePathStateProvider>
); );
} }
@@ -281,3 +277,37 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
</AppShell.RouteAwareSectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
); );
}; };
const Indices = ({ entity }: { entity: Entity }) => {
const [navigate] = useNavigate();
const [data, setData] = useState<Record<string, TAppDataIndex>>({});
const d = useBkndData();
const [submitting, setSubmitting] = useState(false);
async function handleUpdate() {
console.log("update", data);
return;
/*if (submitting) return;
setSubmitting(true);
await d.actions.entity.patch(entity.name).indices.set(data);
setSubmitting(false);*/
}
return (
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="indices"
title="Indices"
ActiveIcon={IconBolt}
renderHeaderRight={({ open }) =>
open ? (
<Button variant="primary" disabled={!open || submitting} onClick={handleUpdate}>
Update
</Button>
) : null
}
>
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
<EntityIndicesForm entity={entity} onChange={setData} />
</div>
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
};
@@ -0,0 +1,118 @@
import { TbBolt, TbTrash } from "react-icons/tb";
import type { Entity } from "data/entities";
import { IconButton } from "ui/components/buttons/IconButton";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { CollapsibleList } from "ui/components/list/CollapsibleList";
import { EntityIndex } from "data";
import { Button } from "ui/components/buttons/Button";
import { useEffect, useState } from "react";
import { JsonViewer } from "ui/components/code/JsonViewer";
import type { TAppDataIndex } from "data/data-schema";
import * as Formy from "ui/components/form/Formy";
export interface EntityIndicesFormProps {
entity: Entity;
onChange?: (indices: Record<string, TAppDataIndex>) => void;
}
export function EntityIndicesForm({ entity, onChange }: EntityIndicesFormProps) {
const { $data } = useBkndData();
const [indices, setIndices] = useState<EntityIndex[]>($data.indicesOf(entity.name));
const [create, setCreate] = useState<TAppDataIndex>({
entity: entity.name,
fields: [],
unique: false,
});
const indexed_fields = indices.flatMap((i) => i.fields).map((f) => f.name);
const required_fields = indices
.flatMap((i) => i.fields)
.filter((f) => f.isRequired())
.map((f) => f.name);
const fields = entity.fields.filter(
(f) => !["primary", "relation", "media"].includes(f.type) && !indexed_fields.includes(f.name),
);
useEffect(() => {
onChange?.(Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])));
}, [indices]);
function handleAdd() {
setIndices((prev) => [
...prev,
new EntityIndex(
entity,
create.fields.map((f) => entity.fields.find((f2) => f2.name === f)!),
create.unique,
),
]);
}
//console.log("indices", { indices, schema, config });
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{indices.map((index) => (
<CollapsibleList.Item key={index.name} title={index.name}>
<CollapsibleList.Preview
left={<TbBolt />}
right={<IconButton size="lg" Icon={TbTrash} />}
>
<span>{index.fields.map((f) => f.name).join(", ")}</span>
<span className="opacity-50">{index.name}</span>
</CollapsibleList.Preview>
</CollapsibleList.Item>
))}
</div>
<div className="flex flex-row gap-7 items-center">
<span className="font-bold">Add Index</span>
<div className="flex flex-row gap-2">
<Formy.Label className="opacity-70">Unique</Formy.Label>
<Formy.Switch
checked={create.unique}
size="sm"
onCheckedChange={(checked) => {
setCreate((prev) => ({
...prev,
unique: checked,
fields: prev.fields.some((f) => required_fields.includes(f))
? prev.fields
: [],
}));
}}
/>
</div>
<div className="flex flex-row flex-wrap gap-2 items-center">
<Formy.Label className="opacity-70">Field</Formy.Label>
<div className="min-w-0">
<Formy.Select
className="h-9 py-1.5 pl-3 pr-8"
options={fields.map((f) => ({
label: f.getLabel()!,
value: f.name,
disabled: create.unique && !required_fields.includes(f.name),
}))}
value={create.fields[0]}
onChange={(e) => {
setCreate((prev) => ({ ...prev, fields: [e.target.value] }));
}}
/>
</div>
</div>
<div className="flex flex-grow" />
<Button variant="primary" disabled={create.fields.length === 0} onClick={handleAdd}>
Add
</Button>
</div>
<div>
<JsonViewer
json={{
create,
data: Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])),
}}
expand={9}
/>
</div>
</div>
);
}