Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 3180037b67 admin ui: store data canvas positions 2025-03-18 12:39:23 +01:00
13 changed files with 142 additions and 137 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.10.0",
"version": "0.10.0-rc.5",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
+2 -9
View File
@@ -167,9 +167,7 @@ export class Mutator<
const res = await this.single(query);
await this.emgr.emit(
new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }),
);
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
return res as any;
}
@@ -200,12 +198,7 @@ export class Mutator<
const res = await this.single(query);
await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({
entity,
entityId: id,
data: res.data,
changed: validatedData,
}),
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }),
);
return res as any;
+1 -6
View File
@@ -18,11 +18,7 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat
});
}
}
export class MutatorInsertAfter extends Event<{
entity: Entity;
data: EntityData;
changed: EntityData;
}> {
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
static override slug = "mutator-insert-after";
}
export class MutatorUpdateBefore extends Event<
@@ -52,7 +48,6 @@ export class MutatorUpdateAfter extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
changed: EntityData;
}> {
static override slug = "mutator-update-after";
}
@@ -118,20 +118,14 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
const res = await this.fetch(url, {
method: "PUT",
body,
headers: isFile(body)
? {
// required for node environments
"Content-Length": String(body.size),
}
: {},
});
if (!res.ok) {
throw new Error(`Failed to upload object: ${res.status} ${res.statusText}`);
if (res.ok) {
// "df20fcb574dba1446cf5ec997940492b"
return String(res.headers.get("etag"));
}
// "df20fcb574dba1446cf5ec997940492b"
return String(res.headers.get("etag"));
return undefined;
}
private async headObject(
+14 -12
View File
@@ -22,17 +22,20 @@ type CanvasProps = ReactFlowProps & {
onDropNewEdge?: (base: any) => any;
};
export function Canvas({
nodes: _nodes,
edges: _edges,
externalProvider,
backgroundStyle = "lines",
minimap = false,
children,
onDropNewNode,
onDropNewEdge,
...props
}: CanvasProps) {
export function Canvas(
{
nodes: _nodes,
edges: _edges,
externalProvider,
backgroundStyle = "lines",
minimap = false,
children,
onDropNewNode,
onDropNewEdge,
...props
}: CanvasProps,
ref?: any,
) {
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow();
@@ -176,7 +179,6 @@ export function Canvas({
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodesConnectable={false}
/*panOnDrag={isSpacePressed}*/
panOnDrag={true}
zoomOnScroll={isCommandPressed}
panOnScroll={!isCommandPressed}
@@ -44,8 +44,10 @@ export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
const position = dagreGraph.node(node.id);
return {
...node,
x: position.x - (node.width ?? 0) / 2,
y: position.y - (node.height ?? 0) / 2,
position: {
x: position.x - (node.width ?? 0) / 2,
y: position.y - (node.height ?? 0) / 2,
},
};
}),
edges,
+8 -10
View File
@@ -35,16 +35,14 @@ export function Panels({ children, ...props }: PanelsProps) {
)}
<Panel unstyled position="bottom-right">
{props.zoom && (
<>
<Panel.Wrapper className="px-1.5">
<Panel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
<Panel.Text className="px-2" mono onClick={handleZoomReset}>
{percent}%
</Panel.Text>
<Panel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
<Panel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
</Panel.Wrapper>
</>
<Panel.Wrapper className="px-1.5">
<Panel.IconButton Icon={TbPlus} round onClick={handleZoomIn} />
<Panel.Text className="px-2" mono onClick={handleZoomReset}>
{percent}%
</Panel.Text>
<Panel.IconButton Icon={TbMinus} round onClick={handleZoomOut} />
<Panel.IconButton Icon={TbMaximize} round onClick={handleZoomReset} />
</Panel.Wrapper>
)}
{props.minimap && (
<>
+10 -7
View File
@@ -4,12 +4,15 @@
// there is no lifecycle or Hook in React that we can use to switch
// .current at the right timing."
// So we will have to make do with this "close enough" approach for now.
import { useLayoutEffect, useRef } from "react";
import { isDebug } from "core";
import { useEffect, useRef } from "react";
export const useEvent = <Fn>(fn: Fn): Fn => {
if (isDebug()) {
console.warn("useEvent() is deprecated");
}
return fn;
export const useEvent = <Fn>(fn: Fn | ((...args: any[]) => any) | undefined): Fn => {
const ref = useRef([fn, (...args) => ref[0](...args)]).current;
// Per Dan Abramov: useInsertionEffect executes marginally closer to the
// correct timing for ref synchronization than useLayoutEffect on React 18.
// See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360
useEffect(() => {
ref[0] = fn;
}, []);
return ref[1];
};
@@ -6,6 +6,10 @@ import { layoutWithDagre } from "ui/components/canvas/layouts";
import { Panels } from "ui/components/canvas/panels";
import { EntityTableNode } from "./EntityTableNode";
import { useTheme } from "ui/client/use-theme";
import { Panel } from "ui/components/canvas/panels/Panel";
import { TbLayout } from "react-icons/tb";
import { useState } from "react";
import { type CanvasPosition, dataCanvasStore } from "ui/store";
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
return Object.entries(entities ?? {}).map(([name, entity]) => {
@@ -65,12 +69,45 @@ const nodeTypes = {
entity: EntityTableNode.Component,
} as const;
function getNodeAutoLayout(nodes: Node<TAppDataEntity>[], edges: any[]): CanvasPosition[] {
const nodeLayout = layoutWithDagre({
nodes: nodes.map((n) => ({
id: n.id,
...EntityTableNode.getSize(n.data),
})),
edges,
graph: {
rankdir: "LR",
marginx: 50,
marginy: 50,
},
});
return nodeLayout.nodes.map((n) => ({
id: n.id,
...n.position,
}));
}
function setNodesLayout(nodes: Node<TAppDataEntity>[], layout: CanvasPosition[]) {
return nodes.map((node) => {
const pos = layout.find((l) => l.id === node.id);
if (pos) {
return {
...node,
position: { x: pos.x, y: pos.y },
};
}
return node;
});
}
export function DataSchemaCanvas() {
const {
config: { data },
} = useBknd();
const { theme } = useTheme();
const nodes = entitiesToNodes(data.entities);
const edges = relationsToEdges(data.relations).map((e) => ({
...e,
style: {
@@ -85,31 +122,35 @@ export function DataSchemaCanvas() {
},
}));
const nodeLayout = layoutWithDagre({
nodes: nodes.map((n) => ({
id: n.id,
...EntityTableNode.getSize(n.data),
})),
edges,
graph: {
rankdir: "LR",
marginx: 50,
marginy: 50,
},
});
const entityNodes = entitiesToNodes(data.entities);
const positions = dataCanvasStore((state) => state.positions);
const setPositions = dataCanvasStore((state) => state.setPositions);
const resetPositions = dataCanvasStore((state) => state.reset);
nodeLayout.nodes.forEach((node) => {
const n = nodes.find((n) => n.id === node.id);
if (n) {
n.position = { x: node.x, y: node.y };
}
});
const layout = positions ? positions : getNodeAutoLayout(entityNodes, edges);
const [nodes, setNodes] = useState<Node<TAppDataEntity>[]>(setNodesLayout(entityNodes, layout));
function setLayout(positions: CanvasPosition[] = getNodeAutoLayout(entityNodes, edges)) {
setNodes(setNodesLayout(entityNodes, positions));
}
function resetLayout() {
resetPositions();
setLayout();
}
return (
<ReactFlowProvider>
<Canvas
nodes={nodes}
edges={edges}
onNodeDragStop={(e, node) => {
const positions = nodes
.map((n) => (n.id === node.id ? node : n))
.map((n) => ({ id: n.id, ...n.position }));
setPositions(positions);
setLayout(positions);
}}
nodeTypes={nodeTypes}
minZoom={0.1}
maxZoom={2}
@@ -118,7 +159,11 @@ export function DataSchemaCanvas() {
maxZoom: 0.8,
}}
>
<Panels zoom minimap />
<Panels zoom minimap>
<Panel position="bottom-left">
<Panel.IconButton round Icon={TbLayout} onClick={resetLayout} />
</Panel>
</Panels>
</Canvas>
</ReactFlowProvider>
);
+2 -2
View File
@@ -240,8 +240,8 @@ const StrategyPasswordForm = () => {
const StrategyOAuthForm = () => {
return (
<>
<Field name="config.client.client_id" required inputProps={{ type: "password" }} />
<Field name="config.client.client_secret" required inputProps={{ type: "password" }} />
<Field name="config.client.client_id" required />
<Field name="config.client.client_secret" required />
</>
);
};
+25
View File
@@ -0,0 +1,25 @@
import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
export type CanvasPosition = {
id: string;
x: number;
y: number;
};
export const dataCanvasStore = create(
persist(
combine(
{
positions: null as CanvasPosition[] | null,
},
(set) => ({
setPositions: (positions: CanvasPosition[]) => set(() => ({ positions })),
reset: () => set(() => ({ positions: null })),
}),
),
{
name: "datacanvas",
},
),
);
+1
View File
@@ -1 +1,2 @@
export { appShellStore } from "./appshell";
export { dataCanvasStore, type CanvasPosition } from "./datacanvas";
+5 -58
View File
@@ -55,65 +55,12 @@ connection object to your new database:
}
```
### Cloudflare D1
Using the [Cloudflare Adapter](/integration/cloudflare), you can choose to use a D1 database binding. To do so, you only need to add a D1 database to your `wrangler.toml` and it'll pick up automatically. To manually specify which D1 database to take, you can specify it manually:
```ts
import { serve, d1 } from "bknd/adapter/cloudflare";
export default serve<Env>({
app: ({ env }) => d1({ binding: env.D1_BINDING })
});
```
### PostgreSQL
To use bknd with Postgres, you need to install the `@bknd/postgres` package. You can do so by running the following command:
```bash
npm install @bknd/postgres
```
This package uses `pg` under the hood. If you'd like to see `postgres` or any other flavor, please create an [issue on Github](https://github.com/bknd-io/bknd/issues/new).
To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. Here is a quick example using the [Node.js Adapter](http://localhost:3000/integration/node):
```js
import { serve } from "bknd/adapter/node";
import { PostgresConnection } from "@bknd/postgres";
/** @type {import("bknd/adapter/node").NodeBkndConfig} */
const config = {
connection: new PostgresConnection({
connectionString:
"postgresql://user:password@localhost:5432/database",
}),
};
serve(config);
```
### SQLocal
To use bknd with `sqlocal` for a offline expierence, you need to install the `@bknd/sqlocal` package. You can do so by running the following command:
```bash
npm install @bknd/sqlocal
```
This package uses `sqlocal` under the hood. Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options:
```js
import { createApp } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
const app = createApp({
connection: new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
})
});
```
### Custom Connection
<Note>
Follow the progress of custom connections on its [Github Issue](https://github.com/bknd-io/bknd/issues/24).
If you're interested, make sure to upvote so it can be prioritized.
</Note>
Any bknd app instantiation accepts as connection either `undefined`, a connection object like
described above, or an class instance that extends from `Connection`: