diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index a8469c7e..28db20cb 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -218,7 +218,7 @@ export function objectCleanEmpty(obj: Obj): * @param object * @param sources */ -export function mergeObject(object, ...sources) { +export function mergeObject(object, ...sources): R { for (const source of sources) { for (const [key, value] of Object.entries(source)) { if (value === undefined) { diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 10f491b5..f4d0fef2 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -6,6 +6,7 @@ import { type EntityManager, constructEntity, constructRelation, + constructIndex, } from "data"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; @@ -35,9 +36,7 @@ export class AppData extends Module { ); const indices = transformObject(_indices, (index, name) => { - const entity = _entity(index.entity)!; - const fields = index.fields.map((f) => entity.field(f)!); - return new EntityIndex(entity, fields, index.unique, name); + return constructIndex(index, _entity, name); }); for (const entity of Object.values(entities)) { diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 38f272f5..d8ce7008 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -68,6 +68,7 @@ export const indicesSchema = tb.Type.Object( additionalProperties: false, }, ); +export type TAppDataIndex = Static<(typeof indicesSchema)[number]>; export const dataConfigSchema = tb.Type.Object( { diff --git a/app/src/data/index.ts b/app/src/data/index.ts index eb3e8931..1e85ab62 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -17,7 +17,7 @@ export { export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner"; -export { constructEntity, constructRelation } from "./schema/constructor"; +export { constructEntity, constructRelation, constructIndex } from "./schema/constructor"; export const DatabaseEvents = { ...MutatorEvents, diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts index 1da566c5..7ec8a333 100644 --- a/app/src/data/schema/constructor.ts +++ b/app/src/data/schema/constructor.ts @@ -1,6 +1,13 @@ import { transformObject } from "core/utils"; -import { Entity, type Field } from "data"; -import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; +import { Entity, EntityIndex, type Field } from "data"; +import { + FIELDS, + RELATIONS, + type TAppDataEntity, + type TAppDataField, + type TAppDataIndex, + type TAppDataRelation, +} from "data/data-schema"; export function constructEntity(name: string, entityConfig: TAppDataEntity) { const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { @@ -32,3 +39,17 @@ export function constructRelation( 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, + ); +} diff --git a/app/src/ui/client/schema/data/use-bknd-data.ts b/app/src/ui/client/schema/data/use-bknd-data.ts index d9c4ca0a..865180e6 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -70,6 +70,7 @@ export function useBkndData() { }; const $data = { entity: (name: string) => entities[name], + indicesOf: (name: string) => app.indices.filter((i) => i.entity.name === name), modals, system: (name: string) => ({ any: entities[name]?.type === "system", @@ -82,6 +83,7 @@ export function useBkndData() { $data, entities, relations: app.relations, + indices: app.indices, config: config.data, schema: schema.data, actions, diff --git a/app/src/ui/client/utils/AppReduced.ts b/app/src/ui/client/utils/AppReduced.ts index 4da7b0d9..f2d4c5ca 100644 --- a/app/src/ui/client/utils/AppReduced.ts +++ b/app/src/ui/client/utils/AppReduced.ts @@ -1,5 +1,12 @@ 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 { Flow, TaskMap } from "flows"; import type { BkndAdminOptions } from "ui/client/BkndProvider"; @@ -14,6 +21,7 @@ export class AppReduced { // @todo: change to record private _entities: Entity[] = []; private _relations: EntityRelation[] = []; + private _indices: EntityIndex[] = []; private _flows: Flow[] = []; constructor( @@ -30,6 +38,10 @@ export class AppReduced { 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 ?? {})) { // @ts-ignore // @todo: fix constructing flow @@ -58,6 +70,10 @@ export class AppReduced { return new RelationAccessor(this._relations); } + get indices(): EntityIndex[] { + return this._indices; + } + get flows(): Flow[] { return this._flows; } diff --git a/app/src/ui/components/canvas/Canvas.tsx b/app/src/ui/components/canvas/Canvas.tsx index d23ba8f7..c11d3821 100644 --- a/app/src/ui/components/canvas/Canvas.tsx +++ b/app/src/ui/components/canvas/Canvas.tsx @@ -13,13 +13,15 @@ import { import { type ReactNode, useCallback, useEffect, useState } from "react"; import { useTheme } from "ui/client/use-theme"; -type CanvasProps = ReactFlowProps & { +export type CanvasProps = Omit & { externalProvider?: boolean; backgroundStyle?: "lines" | "dots"; minimap?: boolean | MiniMapProps; children?: Element | ReactNode; onDropNewNode?: (base: any) => any; onDropNewEdge?: (base: any) => any; + onNodesChange?: (changes: any) => void; + onEdgesChange?: (changes: any) => void; }; export function Canvas({ @@ -33,8 +35,8 @@ export function Canvas({ onDropNewEdge, ...props }: CanvasProps) { - const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []); - const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []); + const [nodes, setNodes, _onNodesChange] = useNodesState(_nodes ?? []); + const [edges, setEdges, _onEdgesChange] = useEdgesState(_edges ?? []); const { screenToFlowPosition } = useReactFlow(); const { theme } = useTheme(); @@ -42,6 +44,22 @@ export function Canvas({ const [isSpacePressed, setIsSpacePressed] = 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) => { if (event.metaKey) { setIsCommandPressed(true); @@ -173,8 +191,6 @@ export function Canvas({ snapToGrid nodes={nodes} edges={edges} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} nodesConnectable={false} /*panOnDrag={isSpacePressed}*/ panOnDrag={true} @@ -183,6 +199,8 @@ export function Canvas({ zoomOnDoubleClick={false} selectionOnDrag={!isSpacePressed} {...props} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} > {backgroundStyle === "lines" && ( diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 4eb8cb43..07229241 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -246,7 +246,6 @@ export const Switch = forwardRef< props.disabled && "opacity-50 !cursor-not-allowed", )} onCheckedChange={(bool) => { - console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -272,7 +271,7 @@ export const Switch = forwardRef< export const Select = forwardRef< HTMLSelectElement, React.ComponentProps<"select"> & { - options?: { value: string; label: string }[] | (string | number)[]; + options?: { value: string; label: string; disabled?: boolean }[] | (string | number)[]; } >(({ children, options, ...props }, ref) => (
@@ -297,7 +296,7 @@ export const Select = forwardRef< return o; }) .map((opt) => ( - ))} diff --git a/app/src/ui/components/form/json-schema-form/ArrayField.tsx b/app/src/ui/components/form/json-schema-form/ArrayField.tsx index 96a864dd..eac28ea9 100644 --- a/app/src/ui/components/form/json-schema-form/ArrayField.tsx +++ b/app/src/ui/components/form/json-schema-form/ArrayField.tsx @@ -78,6 +78,7 @@ const ArrayItem = memo(({ path, index, schema }: any) => { return (
0} name={itemPath} schema={subschema!} value={value} diff --git a/app/src/ui/hooks/use-route-path-state.tsx b/app/src/ui/hooks/use-route-path-state.tsx index 620d1a8b..418eaf6e 100644 --- a/app/src/ui/hooks/use-route-path-state.tsx +++ b/app/src/ui/hooks/use-route-path-state.tsx @@ -28,10 +28,10 @@ export function useRoutePathState(_path?: string, identifier?: string) { const [, navigate] = useLocation(); function toggle(_open?: boolean) { - const open = _open ?? !localActive; + const open = _open ?? !active; if (ctx) { - ctx.setActiveIdentifier(identifier!); + ctx.setActiveIdentifier(open ? identifier! : ""); } if (path) { @@ -58,7 +58,7 @@ export function useRoutePathState(_path?: string, identifier?: string) { } type RoutePathStateContextType = { - defaultIdentifier: string; + defaultIdentifier?: string; path: string; activeIdentifier: string; setActiveIdentifier: (identifier: string) => void; @@ -72,7 +72,9 @@ export function RoutePathStateProvider({ }: Pick & { children: React.ReactNode }) { const segment = extractPathSegment(path); const routeIdentifier = useParams()[segment]; - const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier); + const [activeIdentifier, setActiveIdentifier] = useState( + routeIdentifier ?? defaultIdentifier ?? "", + ); return ( ; + [key: string]: any; +} + +function entitiesToNodes( + entities: AppDataConfig["entities"], + indices?: AppDataConfig["indices"], +): Node[] { + const indexed_fields = Object.entries(indices ?? {}).flatMap(([, index]) => index.fields); -function entitiesToNodes(entities: AppDataConfig["entities"]): Node[] { return Object.entries(entities ?? {}).map(([name, entity]) => { return { 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", dragHandle: ".drag-handle", position: { x: 0, y: 0 }, @@ -65,24 +91,29 @@ const nodeTypes = { entity: EntityTableNode.Component, } 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() { const { config: { data }, } = useBknd(); 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) => ({ ...e, - style: { - stroke: theme === "light" ? "#ccc" : "#666", - }, + style: getEdgeStyle(theme), type: "smoothstep", - markerEnd: { - type: MarkerType.Arrow, - width: 20, - height: 20, - color: theme === "light" ? "#aaa" : "#777", - }, + markerEnd: getMarkerEndStyle(theme), })); const nodeLayout = layoutWithDagre({ @@ -107,7 +138,7 @@ export function DataSchemaCanvas() { return ( - - + ); } + +function toRecord(nodes: Node[]): Record> { + 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>>( + 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 ; +} diff --git a/app/src/ui/modules/data/components/canvas/EntityTableNode.tsx b/app/src/ui/modules/data/components/canvas/EntityTableNode.tsx index 96ba653b..4cd7a589 100644 --- a/app/src/ui/modules/data/components/canvas/EntityTableNode.tsx +++ b/app/src/ui/modules/data/components/canvas/EntityTableNode.tsx @@ -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 { useState } from "react"; -import { TbDiamonds, TbKey } from "react-icons/tb"; +import { useEffect, useState } from "react"; +import { TbBolt, TbDiamonds, TbKey } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; 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 = { name: string; type?: string; - fields: TableField[]; -}; -export type TableField = { - name: string; - type: string; - primary?: boolean; - foreign?: boolean; - indexed?: boolean; + fields: TCanvasEntityField[]; }; -function NodeComponent(props: NodeProps>) { - const [hovered, setHovered] = useState(false); +function NodeComponent(props: NodeProps>) { const { data } = props; const fields = props.data.fields ?? {}; const field_count = Object.keys(fields).length; @@ -32,10 +27,11 @@ function NodeComponent(props: NodeProps {Object.entries(fields).map(([name, field], index) => ( ))}
@@ -53,13 +49,16 @@ const TableRow = ({ index, onHover, last, + selected, }: { - field: TableField; + field: TCanvasEntityField; table: string; index: number; last?: boolean; + selected?: boolean; onHover?: (hovered: boolean) => void; }) => { + const [navigate] = useNavigate(); const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2; const handles = true; const handleId = `${table}:${field.name}`; @@ -67,10 +66,12 @@ const TableRow = ({ return (
navigate(`/entity/${table}/fields/${field.name}`)} > {handles && ( } {field.type === "relation" && }
-
{field.name}
+
+ + {field.name} + {" "} + {field.indexed && } +
{field.type}
{handles && ( diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 8fbc3b7c..a8a13e65 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -1,6 +1,6 @@ import { isDebug } from "core"; import { autoFormatString } from "core/utils"; -import { type ChangeEvent, useState } from "react"; +import type { ChangeEvent } from "react"; import { TbAt, TbBrandAppleFilled, @@ -13,7 +13,6 @@ import { TbBrandX, TbSettings, } from "react-icons/tb"; -import { twMerge } from "tailwind-merge"; import { useBknd } from "ui/client/bknd"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { Button } from "ui/components/buttons/Button"; diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index a72be123..54d1847f 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -83,10 +83,6 @@ export function DataRoot({ children }) {
- {/*
- -
*/} - - - - 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" - > - - - $data.modals.createRelation(entity.name), - }, - { - icon: TbPhoto, - label: "Add media", - onClick: () => $data.modals.createMedia(entity.name), - }, - () =>
, - { - icon: TbDatabasePlus, - label: "Create Entity", - onClick: () => $data.modals.createEntity(), - }, - ]} - position="bottom-end" - > - - - - } - className="pl-3" + <> + -
- - - - -
- -
- + + + 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" + > + + + $data.modals.createRelation(entity.name), + }, + { + icon: TbPhoto, + label: "Add media", + onClick: () => $data.modals.createMedia(entity.name), + }, + () =>
, + { + icon: TbDatabasePlus, + label: "Create Entity", + onClick: () => $data.modals.createEntity(), + }, + ]} + position="bottom-end" + > + + + + } + className="pl-3" + > +
+ + + + +
+ +
+ - - - + - navigate(routes.settings.path(["data", "relations"]), { absolute: true }), - }} - /> - - - - navigate(routes.settings.path(["data", "indices"]), { - absolute: true, - }), - }} - /> - -
- + ActiveIcon={IconCirclesRelation} + > + + navigate(routes.settings.path(["data", "relations"]), { + absolute: true, + }), + }} + /> + + +
+ + ); } @@ -281,3 +277,37 @@ const BasicSettings = ({ entity }: { entity: Entity }) => { ); }; + +const Indices = ({ entity }: { entity: Entity }) => { + const [navigate] = useNavigate(); + const [data, setData] = useState>({}); + 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 ( + + open ? ( + + ) : null + } + > +
+ +
+
+ ); +}; diff --git a/app/src/ui/routes/data/forms/entity.indices.form.tsx b/app/src/ui/routes/data/forms/entity.indices.form.tsx new file mode 100644 index 00000000..ce8cb140 --- /dev/null +++ b/app/src/ui/routes/data/forms/entity.indices.form.tsx @@ -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) => void; +} + +export function EntityIndicesForm({ entity, onChange }: EntityIndicesFormProps) { + const { $data } = useBkndData(); + const [indices, setIndices] = useState($data.indicesOf(entity.name)); + const [create, setCreate] = useState({ + 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 ( +
+
+ {indices.map((index) => ( + + } + right={} + > + {index.fields.map((f) => f.name).join(", ")} + {index.name} + + + ))} +
+
+ Add Index +
+ Unique + { + setCreate((prev) => ({ + ...prev, + unique: checked, + fields: prev.fields.some((f) => required_fields.includes(f)) + ? prev.fields + : [], + })); + }} + /> +
+
+ Field +
+ ({ + 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] })); + }} + /> +
+
+
+ +
+
+ [i.name, i.toJSON()])), + }} + expand={9} + /> +
+
+ ); +}