table controller integrated with the Api

This commit is contained in:
KarimTamani
2025-05-08 17:27:58 +01:00
parent 1f42c07d92
commit 2ad2105d9d
24 changed files with 782 additions and 228 deletions
+2
View File
@@ -13,12 +13,14 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@heroui/react": "^2.7.6",
"@powersync/drizzle-driver": "^0.4.0",
"@powersync/react": "^1.5.3",
"@powersync/web": "^1.20.0",
"@radix-ui/react-tooltip": "^1.2.3",
"@react-aria/visually-hidden": "3.8.21",
"@react-types/shared": "3.28.0",
"@xyflow/react": "^12.6.0",
"drizzle-orm": "^0.43.1",
"framer-motion": "11.15.0",
"i18next-browser-languagedetector": "^8.0.5",
"lucide-react": "^0.501.0",
+7 -4
View File
@@ -3,16 +3,19 @@ import "@/styles/globals.css"
import { TooltipProvider } from "./components/tooltip/tooltip";
import { ReactFlowProvider } from "@xyflow/react";
import useAppRoutes from "./routes/app-route";
import { SyncProvider } from "./providers/sync-provider";
import { SyncProvider } from "./providers/sync-provider/sync-provider";
import DatabaseProvider from "./providers/database-provider/database-provider";
function App() {
const appRoutes = useAppRoutes();
return (
<SyncProvider>
<ReactFlowProvider>
<TooltipProvider delayDuration={0}>
{appRoutes}
</TooltipProvider>
<DatabaseProvider>
<TooltipProvider delayDuration={0}>
{appRoutes}
</TooltipProvider>
</DatabaseProvider>
</ReactFlowProvider>
</SyncProvider>
);
@@ -0,0 +1,40 @@
import { Autocomplete as HeroUiAutocomplete, AutocompleteItem } from "@heroui/react";
import { Key } from "react";
interface AutocompleteProps {
items: any[]
label?: string,
defaultSelection: string | undefined
onSelectionChange?: (item: any) => void,
placeholder?: string
}
const Autocomplete: React.FC<AutocompleteProps> = ({ items, label = "name", onSelectionChange, defaultSelection, placeholder }) => {
const onItemChange = (item: Key | null) => {
onSelectionChange && onSelectionChange(item);
}
return (
<HeroUiAutocomplete
className="w-full"
defaultItems={items}
size="sm"
onSelectionChange={onItemChange}
defaultSelectedKey={defaultSelection as any}
variant="bordered"
aria-label={placeholder}
placeholder={placeholder}
>
{(item: any) => <AutocompleteItem key={item.id}>{item[label]}</AutocompleteItem>}
</HeroUiAutocomplete>
)
}
export default Autocomplete ;
+46
View File
@@ -0,0 +1,46 @@
import { cn } from "@heroui/react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip"
interface ToggleProps {
active?: boolean,
children: React.ReactNode,
onToggle?: (value: boolean) => void,
className?: string ,
label? : string
}
const ToggleButton: React.FC<ToggleProps> = ({ active = false, children, onToggle, className , label }) => {
const toggle = () => {
onToggle && onToggle(!active)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={
cn("p-1 px-2 transition-all hover:bg-default rounded duration-200 ",
className ? className : "",
active ?
"text-slate-700 bg-default/80 " :
"text-icon"
)
}
onClick={toggle}
>
<span className="text-sm">
{children}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{label}
</TooltipContent>
</Tooltip>
)
}
export default ToggleButton
+5 -1
View File
@@ -36,7 +36,11 @@ export const en = {
many_to_many : "Many to Many" ,
} ,
delete : "Delete" ,
field_setting : "Field Setting" ,
table_actions : "Field Actions" ,
field_note : "Field note" ,
delete_field : "Delete Field" ,
},
table : {
double_click : "Double click to edit"
-4
View File
@@ -1,4 +0,0 @@
export interface DataType {
id: string;
name: string;
}
-14
View File
@@ -1,14 +0,0 @@
import { DataType } from "../data/data-types";
export interface Field {
id: string;
name: string;
type: DataType;
primaryKey: boolean;
unique: boolean;
nullable: boolean;
createdAt: number;
default?: string;
}
-14
View File
@@ -1,14 +0,0 @@
import { Field } from "./field";
export interface Table {
id: string;
name: string;
x: number;
y: number;
fields: Field[];
width?: number;
order?: number;
color: string;
createdAt: number;
comments?: string;
}
+15 -20
View File
@@ -1,24 +1,19 @@
import { column, Schema, Table } from '@powersync/web';
export const DATATYPE_TABLE = 'dataTypes';
const data_types = new Table(
{
name: column.text,
},
);
import { DrizzleAppSchema, wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
import { data_types } from './data-type-schema';
import { tables, tablesRelations } from './table-schema';
import { fields, fieldsRelations } from './field-schema';
export const AppSchema = new Schema({
data_types,
});
export const drizzleSchema = {
data_types ,
tables ,
fields ,
export type Database = (typeof AppSchema)['types'];
export type DataTypesRecord = Database['data_types'];
// OR:
// export type Todo = RowType<typeof todos>;
// relationships
tablesRelations ,
fieldsRelations
};
// Infer the PowerSync schema from your Drizzle schema
export const AppSchema = new DrizzleAppSchema(drizzleSchema);
+11
View File
@@ -0,0 +1,11 @@
import { InferSelectModel } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const data_types = sqliteTable('data_types', {
id : text("id") ,
name: text('name'),
});
export interface DataType extends InferSelectModel<typeof data_types> { };
View File
+36
View File
@@ -0,0 +1,36 @@
import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core';
import { tables } from './table-schema';
import { data_types } from './data-type-schema';
import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
export const fields = sqliteTable("fields", {
id: text("id").primaryKey().notNull(),
tableId: text("tableId")
.notNull()
.references(() => tables.id, { onDelete: "cascade" }),
name: text("name").notNull(),
isPrimary: integer("isPrimary", { mode: "boolean" }),
unique: integer("unique", { mode: "boolean" }),
nullable: integer("nullable", { mode: "boolean" }),
defaultValue: text("defaultValue"),
note: text("note"),
typeId: text("typeId").references(() => data_types.id, { onDelete: "cascade" }),
sequence: integer("sequence").default(0),
});
export const fieldsRelations = relations(fields, ({ one }) => ({
table: one(tables, {
fields: [fields.tableId],
references: [tables.id],
}),
}));
export interface FieldType extends InferSelectModel<typeof fields> {
sequence: number
};
export interface FieldInsertType extends InferInsertModel<typeof fields> { };
+39
View File
@@ -0,0 +1,39 @@
import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core';
import { fields, FieldType } from './field-schema';
export const tables = sqliteTable('tables', {
id: text('id')
.primaryKey()
.notNull()
.unique(),
databaseId: text('databaseId'),
name: text('name').notNull(),
posX: real('posX').notNull().default(0),
posY: real('posY').notNull().default(0),
color: text('color'),
width: real('width'),
note: text('note'),
sequence: integer('sequence').default(0),
createdAt: text('createdAt'),
});
export const tablesRelations = relations(tables, ({ many }) => ({
fields: many(fields),
}));
export interface TableType extends InferSelectModel<typeof tables> {
fields : FieldType[]
};
export interface TableInsertType extends InferInsertModel<typeof tables> {
fields? : FieldType[]
};
+7 -11
View File
@@ -1,12 +1,12 @@
import { Table as TableType } from "@/lib/interfaces/table";
import { addEdge, applyEdgeChanges, applyNodeChanges, Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import Table from "./table/table";
import '@xyflow/react/dist/style.css';
import { Relationship } from "./table/relationship";
import DBController from "./db-controller/db-controller";
import { useQuery } from "@powersync/react";
import DBController from "./db-controller/db-controller";
import { Table as TableType} from "@/lib/schemas/table-schema";
interface DatabaseProps {
initialTables?: TableType
@@ -173,18 +173,14 @@ const DatabasePage: React.FC<DatabaseProps> = ({ initialTables }) => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { data , isLoading , error , isFetching } = useQuery('SELECT * FROM data_types ;' );
console.log (data)
const nodeTypes = useMemo(() => ({ table: Table }), []);
const onConnect = useCallback((params: any) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
return (
<div className="w-full h-screen flex">
<DBController/>
<DBController />
<ReactFlow
nodes={nodes}
edges={edges}
@@ -1,110 +1,184 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { Autocomplete, AutocompleteItem, Button, Input } from "@heroui/react";
import { EllipsisVertical, Grip, GripVertical, KeyRound } from "lucide-react";
import { Button, Input, Popover, PopoverContent, PopoverTrigger, Switch, Textarea } from "@heroui/react";
import { EllipsisVertical, GripVertical, KeyRound, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema";
import { Key , useState } from "react";
import { useDatabase } from "@/providers/database-provider/database-provider";
import Autocomplete from "@/components/auto-complete/auto-complete";
import ToggleButton from "@/components/toggle/toggle";
interface Props {
id : string
field: FieldType
}
export const animals = [
{ label: "Cat", key: "cat", description: "The second most popular pet in the world" },
{ label: "Dog", key: "dog", description: "The most popular pet in the world" },
{ label: "Elephant", key: "elephant", description: "The largest land animal" },
{ label: "Lion", key: "lion", description: "The king of the jungle" },
{ label: "Tiger", key: "tiger", description: "The largest cat species" },
{ label: "Giraffe", key: "giraffe", description: "The tallest land animal" },
{
label: "Dolphin",
key: "dolphin",
description: "A widely distributed and diverse group of aquatic mammals",
},
{ label: "Penguin", key: "penguin", description: "A group of aquatic flightless birds" },
{ label: "Zebra", key: "zebra", description: "A several species of African equids" },
{
label: "Shark",
key: "shark",
description: "A group of elasmobranch fish characterized by a cartilaginous skeleton",
},
{
label: "Whale",
key: "whale",
description: "Diverse group of fully aquatic placental marine mammals",
},
{ label: "Otter", key: "otter", description: "A carnivorous mammal in the subfamily Lutrinae" },
{ label: "Crocodile", key: "crocodile", description: "A large semiaquatic reptile" },
];
const FieldItem: React.FC<Props> = ({id }) => {
const FieldItem: React.FC<Props> = ({ field }) => {
const [fieldName, setFieldName] = useState<string>(field.name);
const [popOverOpen, setPopOverOpen] = useState<boolean>(false);
const { deleteField, editField, data_types } = useDatabase();
const [note, setNote] = useState<string | undefined>(field.note as string | undefined);
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const { attributes, listeners, setNodeRef, transform } = useSortable({ id: field.id });
const style = {
transform: CSS.Transform.toString(transform),
};
};
const removeField = () => {
setPopOverOpen(false);
deleteField(field.id)
}
const updateFieldNote = () => {
editField({
id: field.id,
note: note,
} as FieldInsertType)
}
const toggleUnqiue = (value: boolean) => {
editField({
id: field.id,
unique: value
} as FieldInsertType)
}
const saveFieldName = () => {
editField({
id: field.id,
name: fieldName
} as FieldType);
}
const updateFieldType = (key: Key | null) => {
editField({
id: field.id,
typeId: key
} as FieldType);
}
const toggleNullable = (nullable: boolean) => {
editField({
id: field.id,
nullable: nullable
} as FieldType);
}
const togglePrimaryKey = (primaryKey: boolean) => {
editField({
id: field.id,
isPrimary: primaryKey
} as FieldType);
}
return (
<div className="flex w-full gap-1 items-center " style={style} ref={setNodeRef} {...attributes}>
<div {...listeners}>
<GripVertical className="size-4 text-icon" />
<GripVertical className="size-4 text-icon cursor-move" />
</div>
<Input
variant="bordered"
size="sm"
aria-label={t("db_controller.name")}
placeholder={t("db_controller.name")}
value={id}
value={fieldName}
onValueChange={setFieldName}
onBlur={saveFieldName}
/>
<Autocomplete
className="w-full"
defaultItems={animals}
size="sm"
variant="bordered"
items={data_types}
onSelectionChange={updateFieldType}
defaultSelection={field.typeId as any}
placeholder={t("db_controller.type")}
>
{(item) => <AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>}
</Autocomplete>
/>
<div className="flex gap-2 ml-2">
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 px-3 transition-all hover:bg-default rounded duration-200" >
<span className="text-icon text-sm">
N
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.nullable")}?
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 px-2 transition-all hover:bg-default rounded duration-200" >
<span className="text-icon text-sm">
<KeyRound className="size-4" />
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{t("db_controller.primary_key")}
</TooltipContent>
</Tooltip>
<Button
size="sm"
isIconOnly
variant="light"
<ToggleButton
className="px-3"
onToggle={toggleNullable}
active={field.nullable as boolean}
label={`${t("db_controller.nullable")}?`}
>
<EllipsisVertical className="size-4 text-icon" />
</Button>
</div>
N
</ToggleButton>
<ToggleButton
onToggle={togglePrimaryKey}
active={field.isPrimary as boolean}
label={`${t("db_controller.primary_key")}?`}
>
<KeyRound className="size-4" />
</ToggleButton>
<Popover placement="bottom" radius="sm" shadow="sm" showArrow isOpen={popOverOpen} onOpenChange={setPopOverOpen}>
<PopoverTrigger>
<Button
size="sm"
isIconOnly
variant="light"
>
<EllipsisVertical className="size-4 text-slate-500" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[210px]" >
<div className="w-full flex flex-col gap-2 p-2">
<h3 className="font-semibold text-sm text-gray">
{t("db_controller.field_setting")}
</h3>
<hr className="text-default-200" />
<div className="flex w-full 500 justify-between">
<span className="text-sm text-slate-500 font-medium">
{t("db_controller.unique")}
</span>
<Switch size="sm" defaultSelected={field.unique as boolean} onValueChange={toggleUnqiue}>
</Switch>
</div>
<label className="text-sm font-medium text-slate-500">
{t("db_controller.note")}
</label>
<Textarea
variant="bordered"
className="w-full"
label={t("db_controller.field_note")}
value={note}
disableAutosize
disableAnimation
onValueChange={setNote}
onBlur={updateFieldNote}
classNames={{
inputWrapper: "bg-default/80",
base: "max-w-xs",
input: "resize-y min-h-[60px] max-h-[180px]",
}} />
<hr className="text-default-200" />
<Button
className="bg-default"
radius="sm" variant="faded"
color="danger"
size="sm"
onPressEnd={removeField}>
<span className="font-medium text-sm">
{t("db_controller.delete_field")}
</span>
<Trash2 className="mr-1 size-3.5 text-danger" />
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
@@ -2,18 +2,31 @@ import { Button } from "@heroui/react";
import { Plus } from "lucide-react";
import FieldItem from "./field-item";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { useCallback, useEffect, useState } from "react";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { v4 } from "uuid";
import { getNextSequence } from "@/utils/field";
interface Props {
table: TableType
}
const FieldList: React.FC<Props> = ({ }) => {
const FieldList: React.FC<Props> = ({ table }) => {
const { t } = useTranslation();
const [fields, setFields] = useState<string[]>(["Field 1", "Field 2", "Field 3", "Field 4"])
const [fields, setFields] = useState<FieldType[]>(table.fields);
const { createField, orderTableFields } = useDatabase();
useEffect(() => {
setFields(table.fields)
}, [table.fields])
const sensors = useSensors(
useSensor(PointerSensor)
@@ -23,15 +36,31 @@ const FieldList: React.FC<Props> = ({ }) => {
const { active, over } = event;
if (active.id !== over?.id) {
setFields((items) => {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
return arrayMove(items, oldIndex, newIndex);
setFields((items) => {
const oldIndex = items.findIndex((item: FieldType) => item.id == active.id);
const newIndex = items.findIndex((item: FieldType) => item.id == over.id);
const fields = arrayMove(items, oldIndex, newIndex);
orderTableFields(fields)
return fields;
});
}
}
const addField = () => {
createField({
id: v4(),
name: `field_${table.fields.length + 1}`,
tableId: table.id,
sequence: getNextSequence(fields) ,
nullable: true,
})
}
return (
<div className="w-full space-y-2 no-select">
<DndContext
@@ -45,8 +74,8 @@ const FieldList: React.FC<Props> = ({ }) => {
strategy={verticalListSortingStrategy}
>
{
fields.map((field: string) => (
<FieldItem id={field}/>
fields.map((field: FieldType) => (
<FieldItem field={field} key={field.id} />
))
}
@@ -56,11 +85,12 @@ const FieldList: React.FC<Props> = ({ }) => {
<Button
variant="flat"
radius="sm"
startContent={
<Plus className="size-4 text-icon" />
}
className="h-8 p-2 text-xs bg-transparent hover:bg-default text-gray font-semibold"
//onClick={handleCreateTable}
onPressEnd={addField}
>
{t("db_controller.add_field")}
</Button>
@@ -1,26 +1,51 @@
import { Table } from "@/lib/interfaces/table"
import { Accordion, AccordionItem, Button, cn, Divider, Textarea } from "@heroui/react";
import { ChevronLeft, ChevronRight, FileKey, FileText, FileType, Key, MessageSquareQuote } from "lucide-react";
import { useCallback, useState } from "react";
import { Accordion, AccordionItem, Button, cn, Textarea } from "@heroui/react";
import { ChevronLeft, FileKey, FileType, Key, MessageSquareQuote, Plus } from "lucide-react";
import { MouseEventHandler, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import ColorPicker from "@/components/color-picker/color-picker";
import FieldList from "./field/field-list";
import IndexesList from "./index/indexes-list";
import { TableType } from "@/lib/schemas/table-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { getNextSequence } from "@/utils/field";
import { v4 } from "uuid";
export interface TableAccordionBodyProps {
table?: Table ,
table: TableType,
}
const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
const [selectedKeys, setSelectedKeys] = useState(new Set(["fields"]));
const [note, setNote] = useState<string>(table.note ? table.note : "");
const { t } = useTranslation();
const { editTable, createField } = useDatabase();
const onColorChange = useCallback((color : string) => {
console.log ( color)
} ,[])
const onColorChange = useCallback((color: string) => {
editTable({ id: table.id, color } as TableType);
}, [table]);
const addField = (event: any) => {
event.stopPropagation && event.stopPropagation();
createField({
id: v4(),
name: `field_${table.fields.length + 1}`,
tableId: table.id,
sequence: getNextSequence(table.fields),
nullable: true,
})
}
const saveNote = () => {
editTable({
id : table.id ,
note
} as TableType)
}
return (
<div className="w-full">
@@ -29,25 +54,33 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
<AccordionItem key="fields" aria-label="Fields"
indicator={({ isOpen }) => (
<div className={cn(
'tarnsition-all duration-200',
'tarnsition-all duration-200 ',
isOpen ? "rotate-[-90deg]" : ""
)}>
<ChevronLeft className="size-4 text-icon" />
</div>
)}
classNames={{
trigger: "hover:bg-default h-6 "
}}
subtitle={
<div className="flex gap-2 items-center font-medium p-1 w-full hover:underline text-slate-500 hover:text-slate-600 transition-all duration-200">
<FileType className="size-4 " />
<div className="group flex gap-2 items-center font-medium p-1 w-full hover:underline text-slate-500 hover:text-slate-600 transition-all duration-200">
<FileType className="size-4" />
<label className="text-sm w-full cursor-pointer">
{t("db_controller.fields")}
</label>
<button
className="size-4 p-0 text-xs opacity-0 group-hover:opacity-100 transition-all duration-200 hover:text-slate-700 text-icon"
onClick={addField}
>
<Plus className="size-4 " />
</button>
</div>
}
>
<FieldList />
<FieldList table={table} />
</AccordionItem>
<AccordionItem key="indexes" aria-label="Indexes"
indicator={({ isOpen }) => (
@@ -69,7 +102,7 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
</label>
</div>
}>
<IndexesList/>
<IndexesList />
</AccordionItem>
<AccordionItem key="note" aria-label="Note"
indicator={({ isOpen }) => (
@@ -91,16 +124,22 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
</label>
</div>
}>
<Textarea variant="bordered" className="w-full " label={t("db_controller.table_note")} classNames={{
inputWrapper: "bg-default/80",
<Textarea variant="bordered" className="w-full " label={t("db_controller.table_note")}
classNames={{
inputWrapper: "bg-default/80",
}}
value={note}
onValueChange={setNote}
onBlur={saveNote}
}} />
/>
</AccordionItem>
</Accordion>
<hr className="bg-default-200" />
<div className="flex p-2 pb-0 items-start">
<div className="w-full">
<ColorPicker
defaultColor={table.color as string}
onChange={onColorChange}
/>
</div>
@@ -117,6 +156,7 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
{t("db_controller.add_index")}
</Button>
<Button
onPressEnd={addField}
variant="ghost"
size="sm"
className="font-semibold p-4"
@@ -1,25 +1,57 @@
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/tooltip";
import { randomColor } from "@/lib/colors";
import { Table } from "@/lib/interfaces/table"
import { useSortable } from "@dnd-kit/sortable";
import { Button, cn, Divider, Input } from "@heroui/react";
import { Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, EllipsisVertical, Focus, Grip, GripVertical, Pencil } from "lucide-react";
import { Button, cn, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Listbox, ListboxItem, Popover, PopoverContent, PopoverTrigger, useDisclosure } from "@heroui/react";
import { Check, ChevronRight, Copy, EllipsisVertical, FileKey, FileType, Focus, Pencil, Trash } from "lucide-react";
import { useState } from "react";
import { CSS } from "@dnd-kit/utilities";
import { useTranslation } from "react-i18next";
import { TableType } from "@/lib/schemas/table-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { v4 } from "uuid";
import { getNextSequence } from "@/utils/field";
export interface TableAccordionHeaderProps {
table?: Table,
table: TableType,
isOpen?: boolean,
id: string
}
const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOpen, id }) => {
const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOpen }) => {
const { editTable, deleteTable, createField } = useDatabase();
const [popOverOpen, setPopOverOpen] = useState<boolean>(false);
const style = {
borderLeft: "6px solid " + randomColor(),
borderLeft: "6px solid " + table.color,
};
const [tableName, setTableName] = useState<string>(table.name);
const { t } = useTranslation();
const [editMode, setEditMode] = useState<boolean>(false);
const saveTableName = async () => {
await editTable({ id: table.id, name: tableName });
setEditMode(false);
}
const onDeleteTable = async () => {
deleteTable(table.id)
setPopOverOpen(false);
}
const addField = () => {
setPopOverOpen(false)
createField({
id: v4(),
name: `field_${table.fields.length + 1}`,
tableId: table.id,
sequence: getNextSequence(table.fields),
nullable: true,
})
}
return (
<div className="group w-full flex h-12 gap-1 border-l-4 flex p-2 items-center"
style={style}
@@ -31,7 +63,7 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
<ChevronRight className="size-4 text-icon" />
</div>
<div className=" w-[1px] h-full bg-default-200">
</div>
{
!editMode &&
@@ -42,7 +74,7 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
className="w-full text-editable truncate px-2 py-1 text-sm font-semibold text-black"
onDoubleClick={() => setEditMode(true)}
>
{id}
{table.name}
</label>
</TooltipTrigger>
<TooltipContent>
@@ -54,11 +86,13 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
{
editMode && <>
<Input
placeholder={"Usres"}
placeholder={"Table name"}
autoFocus
size="sm"
value={tableName}
onChange={(event: any) => setTableName(event.target.value)}
variant="bordered"
onBlur={() => setEditMode(false)}
onBlur={saveTableName}
type="text"
className="rounded-md px-2 py-0.5 w-full border-blue-400 focus-visible:ring-0 dark:bg-slate-900 text-sm "
/>
@@ -66,7 +100,7 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
variant="light"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
size="sm"
onClick={() => setEditMode(false)}
onPressEnd={saveTableName}
isIconOnly
>
<Check className="size-4 text-icon" />
@@ -94,13 +128,60 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
<Pencil className="size-4 text-icon" />
</Button>
</div>
<Button
size="sm"
isIconOnly
variant="light"
>
<EllipsisVertical className="size-4 text-slate-500" />
</Button>
<Popover placement="bottom" radius="sm" shadow="sm" showArrow isOpen={popOverOpen} onOpenChange={setPopOverOpen}>
<PopoverTrigger>
<Button
size="sm"
isIconOnly
variant="light"
>
<EllipsisVertical className="size-4 text-slate-500" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px]" >
<div className="w-full flex flex-col gap-2 ">
<h3 className="font-semibold text-sm text-gray p-2">
{t("db_controller.table_actions")}
</h3>
</div>
<hr className="text-default-200" />
<Listbox aria-label="Actions" className="p-0 pb-1" >
<ListboxItem
key="add_field"
onPressEnd={addField}
endContent={
<FileType className="size-4 text-icon" />
}>
Add Field
</ListboxItem>
<ListboxItem
key="add_index"
endContent={<FileKey className="size-4 text-icon " />}
showDivider>
Add Index
</ListboxItem>
<ListboxItem
key="duplicate"
showDivider
endContent={<Copy className="size-4 text-icon" />}>
Duplicate
</ListboxItem>
<ListboxItem
key="delete"
className="text-danger"
color="danger"
onPressEnd={onDeleteTable}
endContent={<Trash className="size-4" />}
>
Delete Table
</ListboxItem>
</Listbox>
</PopoverContent>
</Popover>
</>
}
@@ -2,18 +2,42 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/to
import { Accordion, AccordionItem, Button, Input } from "@heroui/react"
import { ChevronDown, Code, EllipsisVertical, Focus, Grid, Grip, List, Pencil, Table } from "lucide-react"
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import TableAccordionHeader from "./table-accordion-item/table-accordion-header";
import TableAccordionBody from "./table-accordion-item/table-accordion-body";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { TableType } from "@/lib/schemas/table-schema";
import { v4 } from "uuid";
import { randomColor } from "@/lib/colors";
interface Props {}
interface Props { }
const TablesController: React.FC<Props> = ({ }) => {
const [items, setItems] = useState(["Item 1", "Item 2", "Item 3", "Item 4"]);
const { tables, createTable } = useDatabase();
const { t } = useTranslation();
const [selectedTable, setSelectedTable] = useState(new Set([]));
const addNewTable = useCallback(async () => {
const newTableId: string = v4();
await createTable({
id: newTableId,
name: `table_${tables.length + 1}`,
color: randomColor(),
createdAt: new Date().toISOString()
});
setSelectedTable(new Set([newTableId]) as any);
}, [tables]);
const selectedTableId = selectedTable.values().next().value;
return (
<div className="w-full h-full flex flex-col gap-2">
@@ -61,6 +85,7 @@ const TablesController: React.FC<Props> = ({ }) => {
variant="solid"
color="primary"
radius="sm"
onPressEnd={addNewTable}
startContent={
<Table className="h-4 w-4 " />
}
@@ -74,20 +99,25 @@ const TablesController: React.FC<Props> = ({ }) => {
<Accordion
hideIndicator
variant="splitted"
selectedKeys={selectedTable}
onSelectionChange={setSelectedTable as any}
>
{items.map(item => (
{tables.map((table: TableType) => (
<AccordionItem
key={item}
aria-label={item}
key={table.id}
aria-label={table.name}
classNames={{
trigger: "w-full hover:bg-default transition-all duration-200",
base: "rounded-md shadow p-0 overflow-hidden",
}}
subtitle={
<TableAccordionHeader id={item} />
<TableAccordionHeader
isOpen={selectedTableId == table.id}
table={table}
/>
}
>
<TableAccordionBody />
<TableAccordionBody table={table} />
</AccordionItem>
))}
</Accordion>
@@ -0,0 +1,39 @@
import { DataType } from "@/lib/schemas/data-type-schema";
import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { QueryResult } from "@powersync/web";
import { createContext } from "react";
export interface DatabaseContextType {
tables: TableType[],
data_types: DataType[]
createTable: (table: TableInsertType) => Promise<QueryResult>,
editTable: (table: TableInsertType) => Promise<QueryResult>,
deleteTable: (id: string) => Promise<QueryResult>,
createField: (field: FieldInsertType) => Promise<QueryResult>,
editField: (field: FieldInsertType) => Promise<QueryResult>,
deleteField: (id: string) => Promise<QueryResult>,
orderTableFields: (fields: FieldType[]) => Promise<QueryResult>
}
export default createContext<DatabaseContextType>({} as DatabaseContextType);
/*
return Promise.all(
fields.map((field : FieldType , index : number) => editField({id : field.id , sequence : index} as FieldType))
)
*/
@@ -0,0 +1,97 @@
import DatabaseContext from "./database-context";
import { useContext } from "react";
import { db, powerSyncDb } from "../sync-provider/sync-provider";
import { TableInsertType, tables, TableType } from "@/lib/schemas/table-schema";
import { useQuery } from "@powersync/react";
import { toCompilableQuery } from "@powersync/drizzle-driver";
import { asc, desc, eq, sql } from "drizzle-orm";
import { QueryResult } from "@powersync/web";
import { FieldInsertType, fields, FieldType } from "@/lib/schemas/field-schema";
interface Props { children: React.ReactNode }
const DatabaseProvider: React.FC<Props> = ({ children }) => {
const { data: tablesList, isLoading, error } = useQuery(toCompilableQuery(
db.query.tables.findMany({
with: {
fields: {
orderBy: asc(fields.sequence),
}
},
orderBy: desc(tables.createdAt)
})
));
const { data: data_types } = useQuery(toCompilableQuery(
db.query.data_types.findMany()
));
const createTable = async (table: TableInsertType): Promise<QueryResult> => {
return await db.insert(tables).values(table)
}
const editTable = async (table: TableInsertType): Promise<QueryResult> => {
return await db.update(tables).set(table).where(eq(tables.id, table.id))
}
const deleteTable = async (id: string): Promise<QueryResult> => {
return await db.delete(tables).where(eq(tables.id, id));
}
const createField = async (field: FieldInsertType): Promise<QueryResult> => {
return await db.insert(fields).values(field);
}
const editField = async (field: FieldInsertType): Promise<QueryResult> => {
return await db.update(fields).set(field).where(eq(fields.id, field.id))
}
const deleteField = async (id: string): Promise<QueryResult> => {
return await db.delete(fields).where(eq(fields.id, id));
}
const orderTableFields = async (fields: FieldType[]): Promise<QueryResult> => {
const caseStatements = fields
.map((field: FieldType, index: number) => `WHEN '${field.id}' THEN ${index}`)
.join('\n ');
const ids = fields.map(u => `'${u.id}'`).join(',\n ');
const sql = `
UPDATE fields
SET sequence = CASE id
${caseStatements}
END
WHERE id IN (
${ids}
);`;
return await powerSyncDb.execute(sql)
}
return (
<DatabaseContext.Provider value={{
createTable,
editTable,
deleteTable,
createField,
editField,
deleteField,
orderTableFields,
tables: tablesList as TableType[],
data_types
}}>
{children}
</DatabaseContext.Provider>
)
}
export const useDatabase = () => useContext(DatabaseContext);
export default DatabaseProvider;
+13 -10
View File
@@ -2,39 +2,42 @@
import { PowerSyncDatabase } from '@powersync/web';
import { PowerSyncContext } from "@powersync/react";
import { createContext, Suspense, useContext, useEffect, useState } from 'react';
import { AppSchema } from '@/lib/schemas/app-schema';
import { AppSchema, drizzleSchema } from '@/lib/schemas/app-schema';
import { StackRenderConnector } from '@/utils/stackrender-connector';
import { CircularProgress } from '@heroui/react';
export const db = new PowerSyncDatabase({
import { PowerSyncSQLiteDatabase, wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
import { ExtractTablesWithRelations } from 'drizzle-orm';
export const powerSyncDb = new PowerSyncDatabase({
database: {
dbFilename: 'stackrender'
dbFilename: 'stackrender.sqlite'
},
schema: AppSchema,
});
export const db: PowerSyncSQLiteDatabase<typeof drizzleSchema> = wrapPowerSyncWithDrizzle(powerSyncDb, {
schema: drizzleSchema
});
const ConnectorContext = createContext<StackRenderConnector | null>(null);
export const useConnector = () => useContext(ConnectorContext);
interface SyncProviderProps {
children: React.ReactNode;
}
export const SyncProvider: React.FC<SyncProviderProps> = ({ children }) => {
const [powerSync] = useState(db);
const [powerSync] = useState(powerSyncDb);
const [connector] = useState(new StackRenderConnector());
useEffect(() => {
useEffect(() => {
powerSync.init();
powerSync.connect(connector);
}, [powerSync, connector])
return (
<Suspense fallback={<CircularProgress/>}>
<Suspense fallback={<CircularProgress />}>
<PowerSyncContext.Provider value={powerSync}>
<ConnectorContext.Provider value={connector}>
{children}
+15
View File
@@ -0,0 +1,15 @@
import { FieldType } from "@/lib/schemas/field-schema";
export const getNextSequence = (fields: FieldType[]): number => {
if (fields.length == 0)
return 0;
const maxSequenceItem = fields.reduce((max, field: FieldType) => {
return field.sequence > max.sequence ? field : max
});
return maxSequenceItem.sequence + 1;
}
+19 -14
View File
@@ -1,6 +1,6 @@
import { v4 as uuid } from 'uuid';
import { AbstractPowerSyncDatabase, PowerSyncBackendConnector } from '@powersync/web';
import { AbstractPowerSyncDatabase, PowerSyncBackendConnector, UpdateType } from '@powersync/web';
export type DemoConfig = {
backendUrl: string;
@@ -52,9 +52,9 @@ export class StackRenderConnector implements PowerSyncBackendConnector {
}
async uploadData(database: AbstractPowerSyncDatabase): Promise<void> {
const transaction = await database.getNextCrudTransaction();
if (!transaction) {
return;
}
@@ -66,6 +66,10 @@ export class StackRenderConnector implements PowerSyncBackendConnector {
try {
let batch: any[] = [];
for (let operation of transaction.crud) {
if (operation.op != UpdateType.DELETE && Object.keys(operation.opData as any).length == 0)
continue
let payload = {
op: operation.op,
table: operation.table,
@@ -74,19 +78,20 @@ export class StackRenderConnector implements PowerSyncBackendConnector {
};
batch.push(payload);
}
if (batch.length > 0) {
const response = await fetch(`${this.config.backendUrl}/api/data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ batch })
});
const response = await fetch(`${this.config.backendUrl}/api/data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ batch })
});
if (!response.ok) {
throw new Error(`Received ${response.status} from /api/data: ${await response.text()}`);
if (!response.ok) {
throw new Error(`Received ${response.status} from /api/data: ${await response.text()}`);
}
}
await transaction.complete(
import.meta.env.VITE_CHECKPOINT_MODE == CheckpointMode.CUSTOM
? await this.getCheckpoint(this._clientId)
@@ -103,7 +108,7 @@ export class StackRenderConnector implements PowerSyncBackendConnector {
* when custom Write Checkpoints are enabled during build.
*/
async getCheckpoint(client_id: string) {
const r = await fetch(`${this.config.backendUrl}/api/data/checkpoint`, {
method: 'PUT',
headers: {