diff --git a/README.md b/README.md index b4d19de7..5d84258a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ [![](./docs/img/landing.png)](https://worxpace-playground.vercel.app) +### Important + +**This project is no longer maintaining.** +I'm currently working on the Notion design system, see my new repo [`Notion Kit`](https://github.com/steeeee0223/notion-kit). + ### Apps - [Steeeee WorXpace](https://worxpace.steeeee0223.vercel.app/) diff --git a/packages/database-ui/eslint.config.js b/packages/database-ui/eslint.config.js new file mode 100644 index 00000000..972aac39 --- /dev/null +++ b/packages/database-ui/eslint.config.js @@ -0,0 +1,11 @@ +import baseConfig from "@swy/eslint-config/base"; +import reactConfig from "@swy/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + }, + ...baseConfig, + ...reactConfig, +]; diff --git a/packages/database-ui/index.html b/packages/database-ui/index.html new file mode 100644 index 00000000..e4b78eae --- /dev/null +++ b/packages/database-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/database-ui/package.json b/packages/database-ui/package.json new file mode 100644 index 00000000..fabcba7c --- /dev/null +++ b/packages/database-ui/package.json @@ -0,0 +1,46 @@ +{ + "name": "@swy/database-ui", + "version": "1.4.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "clean": "git clean -xdf .turbo node_modules", + "dev": "vite --port 5174", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@swy/ui": "workspace:*", + "@tanstack/react-table": "catalog:ui", + "lucide-react": "catalog:ui", + "react": "catalog:react18", + "react-dom": "catalog:react18", + "usehooks-ts": "^2.9.1", + "uuid": "catalog:uuid", + "zod": "catalog:" + }, + "devDependencies": { + "@swy/eslint-config": "workspace:*", + "@swy/prettier-config": "workspace:*", + "@swy/tailwind-config": "workspace:*", + "@swy/tsconfig": "workspace:*", + "@types/node": "catalog:node22", + "@types/react": "catalog:react18", + "@types/react-dom": "catalog:react18", + "@types/uuid": "catalog:uuid", + "eslint": "catalog:", + "globals": "^15.12.0", + "prettier": "catalog:", + "sonner": "catalog:ui", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "^6.0.1" + }, + "prettier": "@swy/prettier-config" +} diff --git a/packages/database-ui/postcss.config.cjs b/packages/database-ui/postcss.config.cjs new file mode 100644 index 00000000..ee5f90b3 --- /dev/null +++ b/packages/database-ui/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/packages/database-ui/public/vite.svg b/packages/database-ui/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/packages/database-ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/database-ui/src/App.tsx b/packages/database-ui/src/App.tsx new file mode 100644 index 00000000..3b49abef --- /dev/null +++ b/packages/database-ui/src/App.tsx @@ -0,0 +1,27 @@ +import "./notion.css"; + +import { Database } from "./database"; +import { ThemeProvider, ThemeToggle } from "./theme"; + +function App() { + return ( + +
+ + {/* Notion Page Content */} +
+ {/* Wrapper for Database View */} + {/* width, left, padding-x will change when resizing */} +
+ +
+
+
+
+ ); +} + +export default App; diff --git a/packages/database-ui/src/database/button-group.tsx b/packages/database-ui/src/database/button-group.tsx new file mode 100644 index 00000000..3bd26c1a --- /dev/null +++ b/packages/database-ui/src/database/button-group.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { + ArrowUpDown, + ChevronDown, + Ellipsis, + ListFilter, + Maximize2, + Search, + Zap, +} from "lucide-react"; + +import { cn } from "@swy/ui/lib"; +import { Button } from "@swy/ui/shadcn"; + +const styles = { + icon: "flex-shrink-0 size-4 text-primary/45 dark:text-primary/45", +}; + +export const ButtonGroup: React.FC<{ className?: string }> = ({ + className, +}) => { + return ( +
+ + + + + + + +
+ ); +}; diff --git a/packages/database-ui/src/database/constant.ts b/packages/database-ui/src/database/constant.ts new file mode 100644 index 00000000..140d02aa --- /dev/null +++ b/packages/database-ui/src/database/constant.ts @@ -0,0 +1 @@ +export const paddingX = 96; diff --git a/packages/database-ui/src/database/database.tsx b/packages/database-ui/src/database/database.tsx new file mode 100644 index 00000000..163ed51d --- /dev/null +++ b/packages/database-ui/src/database/database.tsx @@ -0,0 +1,56 @@ +import { DatabaseIcon, Ellipsis, Plus } from "lucide-react"; + +import { + Button, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@swy/ui/shadcn"; + +import { TableView } from "../table-view"; +import { ButtonGroup } from "./button-group"; +import { ViewWrapper } from "./view-wrapper"; + +export const Database = () => { + return ( + + +
+ Members + +
+ +
+
+
+ +
+ Title +
+ +
+
+ + + + + +
+ ); +}; diff --git a/packages/database-ui/src/database/index.ts b/packages/database-ui/src/database/index.ts new file mode 100644 index 00000000..c30cd664 --- /dev/null +++ b/packages/database-ui/src/database/index.ts @@ -0,0 +1 @@ +export * from "./database"; diff --git a/packages/database-ui/src/database/view-wrapper.tsx b/packages/database-ui/src/database/view-wrapper.tsx new file mode 100644 index 00000000..5bf60e50 --- /dev/null +++ b/packages/database-ui/src/database/view-wrapper.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { HintProvider, ModalProvider } from "@swy/ui/shared"; + +import { paddingX } from "./constant"; + +export const ViewWrapper: React.FC = ({ + children, +}) => { + return ( + + +
+
+
+ {children} +
+
+
+
+
+ ); +}; diff --git a/packages/database-ui/src/examples/README.md b/packages/database-ui/src/examples/README.md new file mode 100644 index 00000000..c7d12211 --- /dev/null +++ b/packages/database-ui/src/examples/README.md @@ -0,0 +1,15 @@ +# Examples Usage + +1. Popover with Triggers + + ```tsx + + ``` + +2. Popover with Modals + + ```tsx + + + + ``` diff --git a/packages/database-ui/src/examples/cell-action-provider.tsx b/packages/database-ui/src/examples/cell-action-provider.tsx new file mode 100644 index 00000000..2d2284de --- /dev/null +++ b/packages/database-ui/src/examples/cell-action-provider.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React, { useRef } from "react"; + +import { Input, Popover, PopoverContent } from "@swy/ui/shadcn"; +import { useModal } from "@swy/ui/shared"; + +import { CellType } from "../table-view"; + +import "./view.css"; + +type CellActionPopoverProps = CellType & { + position: { + top: number; + left: number; + }; +}; + +export const CellActionPopover: React.FC = ({ + position, + ...props +}) => { + const { isOpen, setClose } = useModal(); + const containerRef = useRef(null); + + return ( + + {/* TODO this is a workaround solution */} + {/* See https://github.com/radix-ui/primitives/issues/2908 */} +
+ {renderContent({ containerRef, ...props })} +
+
+ ); +}; + +type RenderContentProps = CellType & { + containerRef: React.RefObject; +}; + +function renderContent({ containerRef, ...data }: RenderContentProps) { + switch (data.type) { + case "title": + case "text": + return ( + e.stopPropagation()} + className="flex max-h-[773px] min-h-[34px] w-[240px] flex-col overflow-visible backdrop-filter-none" + > + + {/*
+
{value}
+
*/} +
+ ); + default: + return null; + } +} diff --git a/packages/database-ui/src/examples/popover-with-modals.tsx b/packages/database-ui/src/examples/popover-with-modals.tsx new file mode 100644 index 00000000..4e835561 --- /dev/null +++ b/packages/database-ui/src/examples/popover-with-modals.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import { Button } from "@swy/ui/shadcn"; +import { useModal } from "@swy/ui/shared"; + +import "./view.css"; + +import { CellActionPopover } from "./cell-action-provider"; + +const randomName: Record = { + "1": "alpha 1!!", + "2": "BRAVO", + "3": "chris for 3...", +}; + +export function PopoverDemo2() { + const { setOpen } = useModal(); + const onClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + console.log("button position", e.currentTarget.title, rect); + setOpen( + , + ); + }; + return ( +
+
+ {Array.from("abcd").map((id) => ( +
+ {Array.from("123").map((id) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/packages/database-ui/src/examples/popover-with-triggers.tsx b/packages/database-ui/src/examples/popover-with-triggers.tsx new file mode 100644 index 00000000..854d0a87 --- /dev/null +++ b/packages/database-ui/src/examples/popover-with-triggers.tsx @@ -0,0 +1,60 @@ +import React, { useRef, useState } from "react"; + +import { + Button, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, +} from "@swy/ui/shadcn"; + +const randomName: Record = { + "1": "alpha 1!!", + "2": "BRAVO", + "3": "chris for 3...", +}; + +export function PopoverDemo() { + const containerRef = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ height: 0 }); + const onOpenChange = (open: boolean) => { + if (!open) setOpen(false); + }; + const onClick = (e: React.MouseEvent) => { + setOpen(true); + const rect = e.currentTarget.getBoundingClientRect(); + setPosition({ height: rect.height }); + console.log("button position", e.currentTarget.title, rect); + }; + return ( + +
+
+ {Array.from("abcd").map((id) => ( +
+ {Array.from("123").map((id) => ( + + + + ))} +
+ ))} +
+
+ + + + +
+ ); +} diff --git a/packages/database-ui/src/globals.css b/packages/database-ui/src/globals.css new file mode 100644 index 00000000..bb8c63ee --- /dev/null +++ b/packages/database-ui/src/globals.css @@ -0,0 +1,102 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* text color */ + --primary: 55 53 47; + /* background color */ + --bg-main: 255 255 255; + --bg-input: 242 241 238; + --bg-sidebar: 247 247 245; + --bg-modal: 255 255 255; + --bg-popover: 255 255 255; + --bg-tooltip: 15 15 15; + /* border color */ + --border-cell: 233 233 231; + + /* Geneated by Shadcn */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + /* --popover: 0 0% 100%; */ + /* --popover-foreground: 222.2 84% 4.9%; */ + + /* --primary: 222.2 47.4% 11.2%; */ + /* --primary-foreground: 210 40% 98%; */ + + /* --secondary: 210 40% 96.1%; */ + /* --secondary-foreground: 222.2 47.4% 11.2%; */ + + /* --muted: 210 40% 96.1%; */ + /* --muted-foreground: 215.4 16.3% 46.9%; */ + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + /* --border: 214.3 31.8% 91.4%; */ + /* --input: 214.3 31.8% 91.4%; */ + /* --ring: 222.2 84% 4.9%; */ + + --radius: 0.5rem; + } + + .dark { + /* text color */ + --primary: 255 255 255; + /* background color */ + --bg-main: 25 25 25; + --bg-input: 255 255 255; + --bg-sidebar: 32 32 32; + --bg-modal: 32 32 32; + --bg-popover: 37 37 37; + --bg-tooltip: 47 47 47; + /* border color */ + --border-cell: 47 47 47; + + /* Geneated by Shadcn */ + --background: 0 0% 12.5%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + /* --popover: 222.2 84% 4.9%; */ + /* --popover-foreground: 210 40% 98%; */ + + /* --primary: 210 40% 98%; */ + /* --primary-foreground: 222.2 47.4% 11.2%; */ + + /* --secondary: 217.2 32.6% 17.5%; */ + /* --secondary-foreground: 210 40% 98%; */ + + /* --muted: 217.2 32.6% 17.5%; */ + /* --muted-foreground: 215 20.2% 65.1%; */ + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + /* --border: 217.2 32.6% 17.5%; */ + /* --input: 217.2 32.6% 17.5%; */ + /* --ring: 212.7 26.8% 83.9%; */ + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-main text-primary dark:text-primary/80; + } +} diff --git a/packages/database-ui/src/lib/use-reducer-atom.ts b/packages/database-ui/src/lib/use-reducer-atom.ts new file mode 100644 index 00000000..2d9f5f46 --- /dev/null +++ b/packages/database-ui/src/lib/use-reducer-atom.ts @@ -0,0 +1,15 @@ +// import { useCallback } from "react"; +// import { useAtom } from "jotai"; +// import type { PrimitiveAtom } from "jotai"; + +// export function useReducerAtom( +// anAtom: PrimitiveAtom, +// reducer: (v: Value, a: Action) => Value, +// ) { +// const [state, setState] = useAtom(anAtom); +// const dispatch = useCallback( +// (action: Action) => setState((prev) => reducer(prev, action)), +// [setState, reducer], +// ); +// return [state, dispatch] as const; +// } diff --git a/packages/database-ui/src/main.tsx b/packages/database-ui/src/main.tsx new file mode 100644 index 00000000..d8d5ae6a --- /dev/null +++ b/packages/database-ui/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import "./globals.css"; + +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/database-ui/src/notion.css b/packages/database-ui/src/notion.css new file mode 100644 index 00000000..74c6be42 --- /dev/null +++ b/packages/database-ui/src/notion.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .notion-page-content { + @apply relative block min-h-0 w-full overflow-visible; + } +} diff --git a/packages/database-ui/src/table-view/README.md b/packages/database-ui/src/table-view/README.md new file mode 100644 index 00000000..fdd667d2 --- /dev/null +++ b/packages/database-ui/src/table-view/README.md @@ -0,0 +1 @@ +- rgb(233, 233, 231) -> #E9E9E7 diff --git a/packages/database-ui/src/table-view/__mock__.tsx b/packages/database-ui/src/table-view/__mock__.tsx new file mode 100644 index 00000000..5e3976d6 --- /dev/null +++ b/packages/database-ui/src/table-view/__mock__.tsx @@ -0,0 +1,65 @@ +import type { DatabaseProperty, RowDataType } from "./types"; + +export const cols: DatabaseProperty[] = [ + { + id: "prop-1", + type: "title", + name: "Name", + width: "216px", + }, + { + id: "prop-2", + type: "text", + name: "Desc.", + width: "100px", + }, + { + id: "prop-3", + type: "checkbox", + name: "Done", + width: "90px", + }, +]; + +export const mockData: RowDataType[] = [ + { + id: "15f35e0f-492c-804c-9534-d615e3925074", + properties: { + "prop-1": { + id: "prop-1-1", + type: "title", + value: "page 1", + }, + "prop-2": { + id: "prop-1-2", + type: "text", + value: "desc1", + }, + "prop-3": { + id: "prop-1-3", + type: "checkbox", + checked: true, + }, + }, + }, + { + id: "15f35e0f-492c-809e-b647-f72038f14c5f", + properties: { + "prop-1": { + id: "prop-2-1", + type: "title", + value: "page 2", + }, + "prop-2": { + id: "prop-2-2", + type: "text", + value: "desc2", + }, + "prop-3": { + id: "prop-2-3", + type: "checkbox", + checked: false, + }, + }, + }, +]; diff --git a/packages/database-ui/src/table-view/cells/cell-trigger.tsx b/packages/database-ui/src/table-view/cells/cell-trigger.tsx new file mode 100644 index 00000000..110e5c8f --- /dev/null +++ b/packages/database-ui/src/table-view/cells/cell-trigger.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from "react"; + +import "../view.css"; + +import { cn } from "@swy/ui/lib"; + +interface CellTriggerProps extends React.PropsWithChildren { + className?: string; + wrapped?: boolean; + onPointerDown?: React.PointerEventHandler; +} + +export const CellTrigger = forwardRef( + ({ className, wrapped, ...props }, ref) => { + return ( +
+ ); + }, +); + +CellTrigger.displayName = "CellTrigger"; diff --git a/packages/database-ui/src/table-view/cells/checkbox-cell.tsx b/packages/database-ui/src/table-view/cells/checkbox-cell.tsx new file mode 100644 index 00000000..acec13fc --- /dev/null +++ b/packages/database-ui/src/table-view/cells/checkbox-cell.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; + +import "../view.css"; + +import { cn } from "@swy/ui/lib"; + +import * as Icon from "../icons"; +import { CellTrigger } from "./cell-trigger"; + +interface CheckboxCellProps { + checked: boolean; + wrapped?: boolean; + onChange?: (check: boolean) => void; +} + +export const CheckboxCell: React.FC = ({ + checked, + wrapped, + onChange, +}) => { + return ( + onChange?.(!checked)} + > +
+
+ + { + e.stopPropagation(); + onChange?.(e.target.checked); + }} + className="absolute left-0 top-0 size-4 cursor-pointer opacity-0" + /> +
+
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/cells/index.ts b/packages/database-ui/src/table-view/cells/index.ts new file mode 100644 index 00000000..5fd91abf --- /dev/null +++ b/packages/database-ui/src/table-view/cells/index.ts @@ -0,0 +1,3 @@ +export * from "./checkbox-cell"; +export * from "./text-cell"; +export * from "./title-cell"; diff --git a/packages/database-ui/src/table-view/cells/text-cell.tsx b/packages/database-ui/src/table-view/cells/text-cell.tsx new file mode 100644 index 00000000..88d76e3d --- /dev/null +++ b/packages/database-ui/src/table-view/cells/text-cell.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React from "react"; + +import "../view.css"; + +import { useCopyToClipboard } from "usehooks-ts"; + +import { cn } from "@swy/ui/lib"; +import { Hint } from "@swy/ui/shared"; + +import * as Icon from "../icons"; +import { CellTrigger } from "./cell-trigger"; +import { TextInputPopover } from "./text-input-popover"; +import { useTriggerPosition } from "./use-trigger-position"; + +interface TextCellProps { + value: string; + wrap?: boolean; + onChange?: (value: string) => void; +} + +export const TextCell: React.FC = ({ + value, + wrap, + onChange, +}) => { + const { ref, position } = useTriggerPosition(); + const [, copy] = useCopyToClipboard(); + + return ( + + +
+
+ +
{ + e.stopPropagation(); + void copy(value); + }} + onKeyDown={(e) => e.stopPropagation()} + > + +
+
+
+
+
+ {value} +
+
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/cells/text-input-popover.tsx b/packages/database-ui/src/table-view/cells/text-input-popover.tsx new file mode 100644 index 00000000..1fe4a3e4 --- /dev/null +++ b/packages/database-ui/src/table-view/cells/text-input-popover.tsx @@ -0,0 +1,52 @@ +"use client"; + +import React, { useState } from "react"; + +import { Input, Popover, PopoverContent, PopoverTrigger } from "@swy/ui/shadcn"; + +import "../view.css"; + +type TextInputPopoverProps = React.PropsWithChildren<{ + position: { top?: number; left?: number }; + value: string; + onChange?: (value: string) => void; +}>; + +export const TextInputPopover: React.FC = ({ + position, + value: initialValue, + onChange, + children, +}) => { + const [value, setValue] = useState(initialValue); + + return ( + + {children} + onChange?.(value)} + > + { + e.preventDefault(); + setValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onChange?.(value); + }} + className="word-break max-h-[771px] min-h-8 whitespace-pre-wrap border-none bg-transparent caret-primary" + /> + {/*
+
{value}
+
*/} +
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/cells/title-cell.tsx b/packages/database-ui/src/table-view/cells/title-cell.tsx new file mode 100644 index 00000000..3fe7aa55 --- /dev/null +++ b/packages/database-ui/src/table-view/cells/title-cell.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React from "react"; + +import { Hint } from "@swy/ui/shared"; + +import "../view.css"; + +import { cn } from "@swy/ui/lib"; + +import * as Icon from "../icons"; +import { CellTrigger } from "./cell-trigger"; +import { TextInputPopover } from "./text-input-popover"; +import { useTriggerPosition } from "./use-trigger-position"; + +interface TitleCellProps { + value: string; + wrapped?: boolean; + onChange?: (value: string) => void; +} + +export const TitleCell: React.FC = ({ + value, + wrapped, + onChange, +}) => { + const { ref, position, width } = useTriggerPosition(); + return ( + + +
+
+ +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + 110 && "mr-1.5", + )} + /> + {width > 110 && <>Open} +
+
+
+
+ + {value} + +
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/cells/use-trigger-position.ts b/packages/database-ui/src/table-view/cells/use-trigger-position.ts new file mode 100644 index 00000000..ed838847 --- /dev/null +++ b/packages/database-ui/src/table-view/cells/use-trigger-position.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; + +interface Position { + top: number; + left: number; +} + +export const useTriggerPosition = () => { + const ref = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + const rect = ref.current?.getBoundingClientRect(); + if (rect) { + setPosition({ top: rect.height, left: rect.left }); + setWidth(rect.width); + } + }, []); + + return { ref, position, width }; +}; diff --git a/packages/database-ui/src/table-view/common/index.ts b/packages/database-ui/src/table-view/common/index.ts new file mode 100644 index 00000000..97aea51a --- /dev/null +++ b/packages/database-ui/src/table-view/common/index.ts @@ -0,0 +1,2 @@ +export * from "./menu"; +export * from "./prop-meta"; diff --git a/packages/database-ui/src/table-view/common/menu.tsx b/packages/database-ui/src/table-view/common/menu.tsx new file mode 100644 index 00000000..c1c4e154 --- /dev/null +++ b/packages/database-ui/src/table-view/common/menu.tsx @@ -0,0 +1,98 @@ +import React, { forwardRef } from "react"; + +import { cn } from "@swy/ui/lib"; +import { + Button, + groupVariants, + menuItemVariants, + PopoverClose, + type MenuItemVariants, +} from "@swy/ui/shadcn"; + +import * as Icon from "../icons"; + +interface MenuHeaderProps { + title: string; + onBack?: () => void; +} + +export const MenuHeader: React.FC = ({ title, onBack }) => { + return ( +
+ + + {title} + + + + +
+ ); +}; + +export const MenuGroup: React.FC = ({ children }) => ( +
+ {children} +
+); + +interface MenuGroupHeaderProps { + title: string; + action?: string | null; + onActionClick?: () => void; +} + +export const MenuGroupHeader: React.FC = ({ + title, + action, + onActionClick, +}) => { + return ( +
+
{title}
+ {action && ( +
+ +
+ )} +
+ ); +}; + +type MenuItemProps = React.HTMLAttributes & + MenuItemVariants & { + disabled?: boolean; + }; + +export const MenuItem = forwardRef( + ({ children, variant, disabled, className, ...props }, ref) => ( +
+ {children} +
+ ), +); + +MenuItem.displayName = "MenuItem"; diff --git a/packages/database-ui/src/table-view/common/prop-meta.tsx b/packages/database-ui/src/table-view/common/prop-meta.tsx new file mode 100644 index 00000000..6fa354ef --- /dev/null +++ b/packages/database-ui/src/table-view/common/prop-meta.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useLayoutEffect, useState } from "react"; + +import { Input } from "@swy/ui/shadcn"; +import { Hint, IconBlock, IconMenu } from "@swy/ui/shared"; + +import { DefaultIcon } from "../default-icon"; +import { useInputField } from "../hooks"; +import * as Icon from "../icons"; +import { UpdateColumnPayload } from "../table-contexts"; +import { DatabaseProperty } from "../types"; + +interface PropMetaProps { + property: Pick; + validateName: (value: string) => boolean; + onUpdate: (data: Omit) => void; + onKeyDownUpdate: () => void; +} + +export const PropMeta: React.FC = ({ + property, + validateName, + onUpdate, + onKeyDownUpdate, +}) => { + const [showDesc, setShowDesc] = useState(false); + const toggleDesc = () => setShowDesc((prev) => !prev); + + const nameField = useInputField({ + id: "name", + initialValue: property.name, + validate: validateName, + onUpdate: (name) => onUpdate({ name }), + onKeyDownUpdate, + }); + const descField = useInputField({ + id: "description", + initialValue: property.description ?? "", + onUpdate: (description) => onUpdate({ description }), + onKeyDownUpdate, + }); + /** Icon */ + const uploadIcon = (file: File) => { + // TODO impl. this + onUpdate({ icon: { type: "file", url: URL.createObjectURL(file) } }); + }; + + useLayoutEffect(() => { + if (showDesc) descField.ref.current?.focus(); + }, [descField.ref, showDesc]); + + return ( + <> +
+
+
+ onUpdate({ icon })} + onRemove={() => onUpdate({ icon: null })} + onUpload={uploadIcon} + > + {property.icon ? ( + + ) : ( + + )} + +
+
+
+ +
+ +
+ + } + /> +
+
+
+ {nameField.error && ( +
+ A property named Select already exists in this database. +
+ )} +
+ {showDesc && ( +
+ +
+ )} + + ); +}; diff --git a/packages/database-ui/src/table-view/default-icon.tsx b/packages/database-ui/src/table-view/default-icon.tsx new file mode 100644 index 00000000..68fda399 --- /dev/null +++ b/packages/database-ui/src/table-view/default-icon.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import { cn } from "@swy/ui/lib"; + +import * as Icon from "./icons"; +import { PropertyType } from "./types"; + +interface DefaultIconProps { + type: PropertyType; + className?: string; +} + +export const DefaultIcon: React.FC = ({ + type, + className, +}) => { + const iconClassName = cn("block size-4 shrink-0", className); + switch (type) { + case "title": + return ; + case "text": + return ; + case "checkbox": + return ; + case "select": + default: + return
; + } +}; diff --git a/packages/database-ui/src/table-view/hooks/index.ts b/packages/database-ui/src/table-view/hooks/index.ts new file mode 100644 index 00000000..b4335405 --- /dev/null +++ b/packages/database-ui/src/table-view/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-input-field"; diff --git a/packages/database-ui/src/table-view/hooks/use-input-field.ts b/packages/database-ui/src/table-view/hooks/use-input-field.ts new file mode 100644 index 00000000..ffd1c46c --- /dev/null +++ b/packages/database-ui/src/table-view/hooks/use-input-field.ts @@ -0,0 +1,58 @@ +import React, { useLayoutEffect, useRef, useState } from "react"; + +interface UseInputFieldOptions { + id: string; + initialValue: string; + validate?: (value: string) => boolean; + onUpdate?: (value: string) => void; + onKeyDownUpdate?: () => void; +} + +interface UseInputFieldResults { + error: boolean; + props: Pick< + React.InputHTMLAttributes, + "id" | "value" | "onError" | "onChange" | "onBlur" | "onKeyDown" + >; + ref: React.RefObject; +} + +export const useInputField = ({ + id, + initialValue, + validate = (_value) => true, + onUpdate, + onKeyDownUpdate, +}: UseInputFieldOptions): UseInputFieldResults => { + const ref = useRef(null); + const [value, setValue] = useState(initialValue); + const [error, setError] = useState(false); + + useLayoutEffect(() => { + ref.current?.focus(); + }, []); + + return { + error, + ref, + props: { + id, + value, + onChange: (e) => { + e.preventDefault(); + setValue(e.target.value); + const isValid = validate(e.target.value); + setError(!isValid); + }, + onBlur: () => { + if (error || value === initialValue) return; + onUpdate?.(value); + }, + onKeyDown: (e) => { + if (e.key !== "Enter" || value === initialValue) return; + if (!error) onUpdate?.(value); + onKeyDownUpdate?.(); + }, + }, + }; +}; diff --git a/packages/database-ui/src/table-view/icons.tsx b/packages/database-ui/src/table-view/icons.tsx new file mode 100644 index 00000000..93bcdc34 --- /dev/null +++ b/packages/database-ui/src/table-view/icons.tsx @@ -0,0 +1,230 @@ +interface IconProps { + className?: string; +} + +export const DragHandle = (props: IconProps) => ( + + + +); + +export const TypesTitle = (props: IconProps) => ( + + + +); + +export const TypesText = (props: IconProps) => ( + + + +); + +export const TypesCheckbox = (props: IconProps) => ( + + + +); + +export const Plus = (props: IconProps) => ( + + + +); + +export const Dots = (props: IconProps) => ( + + + + + + + +); + +export const RoundedSquareCheckbox = (props: IconProps) => ( + + + +); + +export const RoundedCheck = (props: IconProps) => ( + + + +); + +export const PeekModeSide = (props: IconProps) => ( + + + +); + +export const Copy = (props: IconProps) => ( + + + +); + +export const ArrowLeftThick = (props: IconProps) => ( + + + +); + +export const Close = (props: IconProps) => ( + + + +); + +export const InfoFilled = (props: IconProps) => ( + + + +); + +export const Info = (props: IconProps) => ( + +); + +export const Help = (props: IconProps) => ( + +); + +export const ChevronRight = (props: IconProps) => ( + + + +); + +export const Undo = (props: IconProps) => ( + + + +); + +export const Eye = (props: IconProps) => ( + +); + +export const EyeHide = (props: IconProps) => ( + +); + +export const EyeHideInversePadded = (props: IconProps) => ( + + + +); + +export const Duplicate = (props: IconProps) => ( + + + +); +export const Trash = (props: IconProps) => ( + + + +); + +export const Options = (props: IconProps) => ( + +); + +export const Pin = (props: IconProps) => ( + +); + +export const PinStrikeThrough = (props: IconProps) => ( + +); + +export const LockedFilled = (props: IconProps) => ( + +); diff --git a/packages/database-ui/src/table-view/index.ts b/packages/database-ui/src/table-view/index.ts new file mode 100644 index 00000000..34870859 --- /dev/null +++ b/packages/database-ui/src/table-view/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./view"; diff --git a/packages/database-ui/src/table-view/menus/deleted-props-menu.tsx b/packages/database-ui/src/table-view/menus/deleted-props-menu.tsx new file mode 100644 index 00000000..b58f923a --- /dev/null +++ b/packages/database-ui/src/table-view/menus/deleted-props-menu.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo } from "react"; + +import { Button } from "@swy/ui/shadcn"; +import { IconBlock } from "@swy/ui/shared"; + +import { MenuGroup, MenuHeader, MenuItem } from "../common"; +import { DefaultIcon } from "../default-icon"; +import * as Icon from "../icons"; +import { useTableActions, useTableViewCtx } from "../table-contexts"; +import type { DatabaseProperty } from "../types"; +import { useMenuControl } from "./menu-control-context"; +import { PropsMenu } from "./props-menu"; + +export const DeletedPropsMenu = () => { + const { properties } = useTableViewCtx(); + const { updateColumn, deleteColumn } = useTableActions(); + const { openPopover } = useMenuControl(); + + const openPropsMenu = () => openPopover(, { x: -12, y: -12 }); + + const deletedProps = useMemo( + () => Object.values(properties).filter((prop) => prop.isDeleted), + [properties], + ); + + return ( + <> + + + {deletedProps.map((prop) => ( + updateColumn(prop.id, { isDeleted: false })} + onDelete={() => deleteColumn(prop.id)} + /> + ))} + + + ); +}; + +interface PropertyItemProps { + property: DatabaseProperty; + onRestore: () => void; + onDelete: () => void; +} + +const PropertyItem: React.FC = ({ + property, + onRestore, + onDelete, +}) => { + const { name, icon, type } = property; + + return ( + + {icon ? : } + {name} +
+ + +
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/menus/edit-prop-menu.tsx b/packages/database-ui/src/table-view/menus/edit-prop-menu.tsx new file mode 100644 index 00000000..fb0878e6 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/edit-prop-menu.tsx @@ -0,0 +1,161 @@ +"use client"; + +import React from "react"; + +import { Separator, Switch } from "@swy/ui/shadcn"; + +import "../view.css"; + +import { Hint } from "@swy/ui/shared"; + +import { MenuGroup, MenuHeader, MenuItem, PropMeta } from "../common"; +import { DefaultIcon } from "../default-icon"; +import * as Icon from "../icons"; +import { useTableActions, useTableViewCtx } from "../table-contexts"; +import { useMenuControl } from "./menu-control-context"; +import { PropsMenu } from "./props-menu"; +import { TypesMenu } from "./types-menu"; +import { propertyTypes } from "./types-menu-options"; + +interface EditPropMenuProps { + propId: string; +} + +/** + * @summary The property editing menu + * + * 1. ✅ Type selection + * 2. 🚧 Type config + * 3. ✅ Wrap in view + * 4. ✅ Hide in view + * 5. ✅ Duplicate property + * 6. ✅ Delete property + */ +export const EditPropMenu: React.FC = ({ propId }) => { + const { properties, isPropertyUnique } = useTableViewCtx(); + const { updateColumn, duplicateColumn } = useTableActions(); + const { openPopover, closePopover } = useMenuControl(); + + const property = properties[propId]!; + + const openPropsMenu = () => openPopover(, { x: -12, y: -12 }); + + // 1. Type selection + const openTypesMenu = () => + openPopover(, { x: -12, y: -12 }); + // 3. Wrap in view + const wrapProp = () => + updateColumn(property.id, { wrapped: !property.wrapped }); + // 4. Hide in view + const hideProp = () => { + updateColumn(property.id, { hidden: true }); + closePopover(); + }; + // 5. Duplicate property + const duplicateProp = () => { + duplicateColumn(property.id); + closePopover(); + }; + // 6. Delete property + const deleteProp = () => { + updateColumn(property.id, { isDeleted: true }); + closePopover(); + }; + + return ( + <> + + updateColumn(property.id, data)} + onKeyDownUpdate={closePopover} + /> + + {property.type === "title" ? ( + + +
Type
+
+
+ +
+ Title +
+ +
+ + + ) : ( + <> + +
Type
+
+
+ +
+ {propertyTypes[property.type]!.title} +
+ +
+ + +
+
+ AI Autofill +
+ New +
+
+
+
+
Off
+ +
+
+ + )} + + + + + + Wrap in view +
+ +
+
+ {property.type !== "title" && ( + <> + + + Hide in view + + + + Duplicate property + + + + Delete property + + + )} +
+ + ); +}; diff --git a/packages/database-ui/src/table-view/menus/index.ts b/packages/database-ui/src/table-view/menus/index.ts new file mode 100644 index 00000000..dad203b4 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/index.ts @@ -0,0 +1,6 @@ +export * from "./edit-prop-menu"; +export * from "./menu-control-context"; +export * from "./menu-control-provider"; +export * from "./types-menu"; +export * from "./prop-menu"; +export * from "./props-menu"; diff --git a/packages/database-ui/src/table-view/menus/menu-control-context.ts b/packages/database-ui/src/table-view/menus/menu-control-context.ts new file mode 100644 index 00000000..eaea8169 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/menu-control-context.ts @@ -0,0 +1,27 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export interface MenuControlInterface { + openPopover: ( + popover: React.ReactNode, + style: { + x?: number; + y?: number; + className?: string; + }, + ) => void; + closePopover: () => void; +} + +export const MenuControlContext = createContext( + null, +); + +export const useMenuControl = () => { + const context = useContext(MenuControlContext); + if (!context) { + throw new Error("useMenuControl must be used within a MenuControlProvider"); + } + return context; +}; diff --git a/packages/database-ui/src/table-view/menus/menu-control-provider.tsx b/packages/database-ui/src/table-view/menus/menu-control-provider.tsx new file mode 100644 index 00000000..3e92ed14 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/menu-control-provider.tsx @@ -0,0 +1,86 @@ +"use client"; + +import React, { useMemo, useRef, useState } from "react"; + +import { cn } from "@swy/ui/lib"; +import { Popover, PopoverContent, PopoverTrigger } from "@swy/ui/shadcn"; + +import { + MenuControlContext, + MenuControlInterface, +} from "./menu-control-context"; + +type MenuControlProviderProps = React.PropsWithChildren; + +export const MenuControlProvider: React.FC = ({ + children, +}) => { + const containerRef = useRef(null); + + const [open, setOpen] = useState(false); + const [popover, setPopover] = useState(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const contextValue = useMemo( + () => ({ + openPopover: (popover, { x, y, className }) => { + if (!popover) return false; + setPopover( + + {popover} + , + ); + setPosition({ x: x ?? 0, y: y ?? 0 }); + setOpen(true); + }, + closePopover: () => { + setPopover(null); + setOpen(false); + }, + }), + [], + ); + + return ( + +
+ {children} + + + {popover} + +
+
+ ); +}; + +const getTriggerPosition = ( + containerRef: React.RefObject, + position: { x: number; y: number }, +) => { + const rect = containerRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = {}; + if (position.x >= 0) { + style.left = position.x - (rect?.x ?? 0); + } else { + style.right = -position.x; + } + if (position.y >= 0) { + style.top = position.y - (rect?.y ?? 0); + } else { + style.bottom = -position.y; + } + return style; +}; diff --git a/packages/database-ui/src/table-view/menus/prop-menu.tsx b/packages/database-ui/src/table-view/menus/prop-menu.tsx new file mode 100644 index 00000000..671e5114 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/prop-menu.tsx @@ -0,0 +1,136 @@ +"use client"; + +import React from "react"; + +import { Separator, Switch } from "@swy/ui/shadcn"; + +import "../view.css"; + +import { MenuGroup, MenuItem, PropMeta } from "../common"; +import * as Icon from "../icons"; +import { useTableActions, useTableViewCtx } from "../table-contexts"; +import { EditPropMenu } from "./edit-prop-menu"; +import { useMenuControl } from "./menu-control-context"; + +interface PropMenuProps { + propId: string; + rect?: DOMRect; +} + +/** + * @summary The definition of the property + * + * 1. ✅ Edit property: opens `EditPropMenu` + * 2. 🚧 Sorting + * 3. 🚧 Filter + * 4. ✅ Hide in view + * 5. ✅ Freeze up to column + * 6. ✅ Duplicate property + * 7. ✅ Delete property + * 8. ✅ Wrap column + */ +export const PropMenu: React.FC = ({ propId, rect }) => { + const { table, properties, isPropertyUnique, canFreezeProperty } = + useTableViewCtx(); + const { updateColumn, duplicateColumn, freezeColumns } = useTableActions(); + const { openPopover, closePopover } = useMenuControl(); + + const property = properties[propId]!; + + // 1. Edit property + const openEditPropMenu = () => { + openPopover(, { + x: rect?.x, + y: rect?.bottom, + }); + }; + // 4. Hide in view + const hideProp = () => { + updateColumn(property.id, { hidden: true }); + closePopover(); + }; + // 5. Pin columns + const canFreeze = canFreezeProperty(property.id); + const canUnfreeze = table.getColumn(property.id)?.getIsLastColumn("left"); + const pinColumns = () => { + freezeColumns(canUnfreeze ? null : property.id); + closePopover(); + }; + // 6. Duplicate property + const duplicateProp = () => { + duplicateColumn(property.id); + closePopover(); + }; + // 7. Delete property + const deleteProp = () => { + updateColumn(property.id, { isDeleted: true }); + closePopover(); + }; + // 8. Wrap in view + const wrapProp = () => + updateColumn(property.id, { wrapped: !property.wrapped }); + + return ( + <> + updateColumn(property.id, data)} + onKeyDownUpdate={closePopover} + /> + + + + Edit property + + + + + {property.type !== "title" && ( + + + Hide in view + + )} + + {canUnfreeze ? ( + <> + + Unfreeze columns + + ) : ( + <> + + Freeze up to column + + )} + + {property.type !== "title" && ( + <> + + + Duplicate property + + + + Delete property + + + )} + + + + + Wrap column +
+ +
+
+
+ + ); +}; diff --git a/packages/database-ui/src/table-view/menus/props-menu.tsx b/packages/database-ui/src/table-view/menus/props-menu.tsx new file mode 100644 index 00000000..fcd98b89 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/props-menu.tsx @@ -0,0 +1,243 @@ +import { useLayoutEffect, useMemo, useRef } from "react"; +import { closestCenter, DndContext } from "@dnd-kit/core"; +import { + restrictToParentElement, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { useFilter } from "@swy/ui/hooks"; +import { Button, Input, Separator } from "@swy/ui/shadcn"; +import { IconBlock } from "@swy/ui/shared"; + +import { MenuGroup, MenuGroupHeader, MenuHeader, MenuItem } from "../common"; +import { DefaultIcon } from "../default-icon"; +import * as Icon from "../icons"; +import { useTableActions, useTableViewCtx } from "../table-contexts"; +import type { DatabaseProperty } from "../types"; +import { DeletedPropsMenu } from "./deleted-props-menu"; +import { EditPropMenu } from "./edit-prop-menu"; +import { useMenuControl } from "./menu-control-context"; +import { TypesMenu } from "./types-menu"; + +/** + * @summary The menu of all properties + */ +export const PropsMenu = () => { + const { table, properties } = useTableViewCtx(); + const { reorderColumns, updateColumn, toggleAllColumns } = useTableActions(); + const { openPopover } = useMenuControl(); + + const { columnOrder, columnVisibility } = table.getState(); + const noShownProps = (() => { + const count = Object.values(columnVisibility).reduce( + (acc, shown) => { + const num = Number(shown) as 0 | 1; + acc[num]++; + return acc; + }, + { [1]: 0, [0]: 0 }, + ); + return count[1] === 1; + })(); + // Search + const inputRef = useRef(null); + const [props, deletedCount] = useMemo(() => { + const props: DatabaseProperty[] = []; + let deletedCount = 0; + columnOrder.forEach((propId) => { + const prop = properties[propId]; + if (prop && !prop.isDeleted) { + props.push(prop); + } else { + deletedCount++; + } + }); + return [props, deletedCount]; + }, [properties, columnOrder]); + const { search, results, updateSearch } = useFilter( + props, + (prop, v) => prop.name.toLowerCase().includes(v), + { default: "empty" }, + ); + // Menu actions + const openTypesMenu = () => + openPopover(, { x: -12, y: -12 }); + const openEditPropMenu = (propId: string) => + openPopover(, { x: -12, y: -12 }); + const toggleVisibility = (propId: string, hidden: boolean) => + updateColumn(propId, { hidden }); + const openDeletedPropsMenu = () => + openPopover(, { x: -12, y: -12 }); + + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( + <> + +
+ updateSearch(e.target.value)} + onCancel={() => updateSearch("")} + placeholder="Search for a property..." + /> +
+ + toggleAllColumns(!noShownProps)} + /> +
+ + + {search.length === 0 + ? props.map((prop) => ( + openEditPropMenu(prop.id)} + onVisibilityChange={(hidden) => + toggleVisibility(prop.id, hidden) + } + /> + )) + : (results ?? []).map((prop) => ( + openEditPropMenu(prop.id)} + onVisibilityChange={(hidden) => + toggleVisibility(prop.id, hidden) + } + /> + ))} + + +
+
+ + {deletedCount > 0 && ( + + +
+ Deleted properties +
+
+
{deletedCount}
+ +
+
+ )} +
+ + + + + New property + + + window.open("https://www.notion.com/help/database-properties") + } + > + + Learn about properties + + + + ); +}; + +interface PropertyItemProps { + draggable?: boolean; + property: DatabaseProperty; + onClick: () => void; + onVisibilityChange: (hidden: boolean) => void; +} + +const PropertyItem: React.FC = ({ + draggable, + property, + onClick, + onVisibilityChange, +}) => { + const { id, name, icon, type, hidden } = property; + + /** DND */ + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }); + + const style: React.CSSProperties = { + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 10 : 0, + transform: CSS.Translate.toString(transform), // translate instead of transform to avoid squishing + transition, // Warning: it is somehow laggy + }; + + return ( + + {draggable && ( +
+ +
+ )} + {icon ? : } + {name} +
+ + +
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/menus/types-menu-options.tsx b/packages/database-ui/src/table-view/menus/types-menu-options.tsx new file mode 100644 index 00000000..81fde473 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/types-menu-options.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import { DefaultIcon } from "../default-icon"; +import type { PropertyType } from "../types"; + +interface MenuOption { + type: PropertyType; + title: string; + description: string; + icon: React.ReactNode; +} + +export const propertyTypes: Partial> = { + title: { + type: "title", + title: "Title", + description: "", + icon: ( + + ), + }, + text: { + type: "text", + title: "Text", + description: + "Add text that can be formatted. Great for summaries, notes, or descriptions.", + icon: , + }, + checkbox: { + type: "checkbox", + title: "Checkbox", + description: + "Use a checkbox to indicate whether a condition is true or false. Useful for lightweight task tracking.", + icon: ( + + ), + }, +} as const; + +export const propOptions = Object.values(propertyTypes); diff --git a/packages/database-ui/src/table-view/menus/types-menu.tsx b/packages/database-ui/src/table-view/menus/types-menu.tsx new file mode 100644 index 00000000..bae17a57 --- /dev/null +++ b/packages/database-ui/src/table-view/menus/types-menu.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React from "react"; +import { v4 } from "uuid"; + +import { useFilter } from "@swy/ui/hooks"; +import { cn } from "@swy/ui/lib"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, + Input, +} from "@swy/ui/shadcn"; +import { Hint } from "@swy/ui/shared"; + +import { MenuHeader } from "../common"; +import { DefaultIcon } from "../default-icon"; +import { useTableActions, useTableViewCtx } from "../table-contexts"; +import type { PropertyType } from "../types"; +import { getUniqueName } from "../utils"; +import { EditPropMenu } from "./edit-prop-menu"; +import { useMenuControl } from "./menu-control-context"; +import { propOptions } from "./types-menu-options"; + +interface TypesMenuProps { + /** + * @prop {propId}: if null, will create a new column; + * otherwise will update a column by given `propId` + */ + propId: string | null; +} + +export const TypesMenu: React.FC = ({ propId }) => { + const { properties } = useTableViewCtx(); + const { addColumn, updateColumnType } = useTableActions(); + const { openPopover } = useMenuControl(); + + const property = propId ? properties[propId]! : null; + + const { search, results, updateSearch } = useFilter(propOptions, (prop, v) => + prop.title.toLowerCase().includes(v), + ); + const select = (type: PropertyType, name: string) => { + let colId = propId; + if (colId === null) { + colId = v4(); + const uniqueName = getUniqueName( + name, + Object.values(properties).map((p) => p.name), + ); + addColumn({ id: colId, type, name: uniqueName }); + } else { + updateColumnType(colId, type); + } + + openPopover(, { x: -12, y: -12 }); + }; + + return ( + <> + + +
+ updateSearch(e.target.value)} + placeholder={ + propId ? "Search for property type" : "Search or add new property" + } + /> +
+ + {results && results.length > 0 && ( + + {results.map(({ type, title, description, icon }) => ( + + select(type, title)} + disabled={type === "title"} + > + {icon} + {title} + {property?.type === type && ( +
+ +
+ )} +
+
+ ))} +
+ )} + {!propId && search.length > 0 && ( + + select("text", search)} + > + + {search} + + + )} +
+
+ + ); +}; diff --git a/packages/database-ui/src/table-view/table-body.tsx b/packages/database-ui/src/table-view/table-body.tsx new file mode 100644 index 00000000..a1ed5b6b --- /dev/null +++ b/packages/database-ui/src/table-view/table-body.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import type { Table } from "@tanstack/react-table"; + +import { TableRow } from "./table-row"; +import type { RowDataType } from "./types"; + +interface TableBodyProps { + table: Table; +} + +/** + * un-memoized normal table body component - see memoized version below + */ +export const TableBody: React.FC = ({ table }) => { + return table + .getRowModel() + .rows.map((row) => ); +}; + +/** + * special memoized wrapper for our table body that we will use during column resizing + */ +export const MemoizedTableBody = React.memo( + TableBody, + (prev, next) => prev.table.options.data === next.table.options.data, +); diff --git a/packages/database-ui/src/table-view/table-contexts/index.ts b/packages/database-ui/src/table-view/table-contexts/index.ts new file mode 100644 index 00000000..50104102 --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/index.ts @@ -0,0 +1,4 @@ +export * from "./table-view-context"; +export * from "./table-view-provider"; +export * from "./table-reducer"; +export type * from "./types"; diff --git a/packages/database-ui/src/table-view/table-contexts/table-reducer.tsx b/packages/database-ui/src/table-view/table-contexts/table-reducer.tsx new file mode 100644 index 00000000..cf594b8d --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/table-reducer.tsx @@ -0,0 +1,199 @@ +import type { Updater } from "@tanstack/react-table"; +import { v4 } from "uuid"; + +import type { + CellDataType, + DatabaseProperty, + PropertyType, + RowDataType, +} from "../types"; +import { + getDefaultCell, + getUniqueName, + transferPropertyValues, +} from "../utils"; +import type { AddColumnPayload, UpdateColumnPayload } from "./types"; + +export interface TableViewAtom { + /** + * @field property definitions + * key: property (column) id + */ + properties: Record; + /** + * @field column freezing up to the given `index` in `propertiesOrder` + * returns -1 if no column is freezing + */ + freezedIndex: number; + /** + * @field array of ordered property (column) ids + * @note freezed columns: `propertiesOrder.slice(0, freezedIndex + 1)` + */ + propertiesOrder: string[]; + data: RowDataType[]; +} + +export type TableViewAction = + | { type: "add:col"; payload: AddColumnPayload } + | { type: "update:col"; payload: { id: string; data: UpdateColumnPayload } } + | { type: "update:col:type"; payload: { id: string; type: PropertyType } } + | { type: "update:col:visibility"; payload: { hidden: boolean } } + | { type: "reorder:col"; updater: Updater } + | { type: "freeze:col"; payload: { id: string | null } } + | { type: "delete:col" | "duplicate:col"; payload: { id: string } } + | { + type: "update:cell"; + payload: { rowId: string; colId: string; data: CellDataType }; + } + | { type: "reset" }; + +export const tableViewReducer = ( + v: TableViewAtom, + a: TableViewAction, +): TableViewAtom => { + switch (a.type) { + case "add:col": { + const { id: colId, type, name } = a.payload; + return { + properties: { + ...v.properties, + [colId]: { id: colId, type, name, icon: null }, + }, + propertiesOrder: [...v.propertiesOrder, colId], + freezedIndex: v.freezedIndex, + data: v.data.map(({ id, properties }) => ({ + id, + properties: { ...properties, [colId]: getDefaultCell(type) }, + })), + }; + } + case "update:col": { + const prop = v.properties[a.payload.id]; + if (!prop) return v; + return { + ...v, + properties: { + ...v.properties, + [a.payload.id]: { ...prop, ...a.payload.data }, + }, + }; + } + case "update:col:type": { + const prop = v.properties[a.payload.id]; + if (!prop) return v; + return { + ...v, + properties: { + ...v.properties, + [a.payload.id]: { ...prop, type: a.payload.type }, + }, + data: v.data.map((row) => { + if (row.properties[a.payload.id] === undefined) return row; + return { + ...row, + properties: { + ...row.properties, + [a.payload.id]: transferPropertyValues( + row.properties[a.payload.id]!, + a.payload.type, + ), + }, + }; + }), + }; + } + case "update:col:visibility": { + const properties = { ...v.properties }; + v.propertiesOrder.forEach((propId) => { + const property = properties[propId]!; + property.hidden = property.type === "title" ? false : a.payload.hidden; + }); + return { ...v, properties }; + } + case "reorder:col": { + const propertiesOrder = Array.isArray(a.updater) + ? a.updater + : a.updater(v.propertiesOrder); + return { ...v, propertiesOrder }; + } + case "duplicate:col": { + const src = v.properties[a.payload.id]; + const idx = v.propertiesOrder.findIndex( + (colId) => colId === a.payload.id, + ); + if (!src || idx < 0) return v; + const prop = { + ...src, + id: v4(), + name: getUniqueName( + src.name, + Object.values(v.properties).map((col) => col.name), + ), + }; + return { + properties: { ...v.properties, [prop.id]: prop }, + propertiesOrder: [ + ...v.propertiesOrder.slice(0, idx + 1), + prop.id, + ...v.propertiesOrder.slice(idx + 1), + ], + freezedIndex: v.freezedIndex + Number(idx <= v.freezedIndex), + data: v.data.map(({ id, properties }) => ({ + id, + properties: { ...properties, [prop.id]: getDefaultCell(src.type) }, + })), + }; + } + case "freeze:col": { + const freezedIndex = + a.payload.id !== null + ? v.propertiesOrder.findIndex((colId) => colId === a.payload.id) + : -1; + return { ...v, freezedIndex }; + } + case "delete:col": { + const idx = v.propertiesOrder.findIndex( + (colId) => colId === a.payload.id, + ); + if (idx < 0) return v; + + return { + get properties() { + const { [a.payload.id]: _, ...properties } = v.properties; + return properties; + }, + propertiesOrder: v.propertiesOrder.filter( + (colId) => colId !== a.payload.id, + ), + freezedIndex: v.freezedIndex - Number(idx <= v.freezedIndex), + data: v.data.map((row) => { + const { [a.payload.id]: _, ...rest } = row.properties; + return { ...row, properties: rest }; + }), + }; + } + case "update:cell": + return { + ...v, + data: v.data.map((row) => { + if (row.id !== a.payload.rowId) return row; + return { + ...row, + properties: { + ...row.properties, + [a.payload.colId]: a.payload.data, + }, + }; + }), + }; + case "reset": + return { + properties: {}, + propertiesOrder: [], + freezedIndex: -1, + data: [], + }; + default: + return v; + } +}; diff --git a/packages/database-ui/src/table-view/table-contexts/table-view-context.ts b/packages/database-ui/src/table-view/table-contexts/table-view-context.ts new file mode 100644 index 00000000..0b2e1738 --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/table-view-context.ts @@ -0,0 +1,58 @@ +"use client"; + +import React, { createContext, useContext } from "react"; +import type { + DragEndEvent, + SensorDescriptor, + SensorOptions, +} from "@dnd-kit/core"; +import type { Table } from "@tanstack/react-table"; + +import type { DatabaseProperty, PropertyType, RowDataType } from "../types"; +import type { TableViewAction } from "./table-reducer"; +import type { AddColumnPayload, UpdateColumnPayload } from "./types"; + +export interface TableViewCtx { + table: Table; + properties: Record; + data: RowDataType[]; + columnSizeVars: Record; + isPropertyUnique: (name: string) => boolean; + canFreezeProperty: (id: string) => boolean; + /** DND */ + columnSensors: SensorDescriptor[]; +} + +export const TableViewContext = createContext(null); + +export const useTableViewCtx = () => { + const ctx = useContext(TableViewContext); + if (!ctx) + throw new Error( + "`useTableViewCtx` must be used within `TableViewProvider`", + ); + return ctx; +}; + +export interface TableActions { + dispatch: React.Dispatch; + addColumn: (data: AddColumnPayload) => void; + updateColumn: (id: string, data: UpdateColumnPayload) => void; + toggleAllColumns: (hidden: boolean) => void; + updateColumnType: (id: string, type: PropertyType) => void; + reorderColumns: (e: DragEndEvent) => void; + duplicateColumn: (id: string) => void; + freezeColumns: (id: string | null) => void; + deleteColumn: (id: string) => void; +} + +export const TableActionsContext = createContext(null); + +export const useTableActions = () => { + const ctx = useContext(TableActionsContext); + if (!ctx) + throw new Error( + "`useTableActions` must be used within `TableActionsProvider`", + ); + return ctx; +}; diff --git a/packages/database-ui/src/table-view/table-contexts/table-view-provider.tsx b/packages/database-ui/src/table-view/table-contexts/table-view-provider.tsx new file mode 100644 index 00000000..73fcd8d3 --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/table-view-provider.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React, { useMemo } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; + +import type { DatabaseProperty, RowDataType } from "../types"; +import { + TableActionsContext, + TableViewContext, + type TableActions, + type TableViewCtx, +} from "./table-view-context"; +import { useTableView } from "./use-table-view"; + +interface TableViewProviderProps extends React.PropsWithChildren { + initialData: { + properties: DatabaseProperty[]; + data: RowDataType[]; + }; +} + +export const TableViewProvider: React.FC = ({ + children, + initialData, +}) => { + const { dispatch, ...ctx } = useTableView(initialData); + + const tableViewCtx = useMemo( + () => ({ + ...ctx, + getColumn: (id: string) => ctx.properties[id] ?? null, + isPropertyUnique: (name: string) => + Object.values(ctx.properties).every((p) => p.name !== name), + canFreezeProperty: (id: string) => + ctx.table.getState().columnOrder.at(-1) !== id, + }), + [ctx], + ); + + const actions = useMemo( + () => ({ + dispatch, + addColumn: (payload) => dispatch({ type: "add:col", payload }), + updateColumn: (id, data) => + dispatch({ type: "update:col", payload: { id, data } }), + updateColumnType: (id, type) => + dispatch({ type: "update:col:type", payload: { id, type } }), + toggleAllColumns: (hidden) => + dispatch({ type: "update:col:visibility", payload: { hidden } }), + reorderColumns: (e) => { + // reorder columns after drag & drop + const { active, over } = e; + if (!over || active.id === over.id) return; + dispatch({ + type: "reorder:col", + updater: (prev) => { + const oldIndex = prev.indexOf(active.id as string); + const newIndex = prev.indexOf(over.id as string); + return arrayMove(prev, oldIndex, newIndex); //this is just a splice util + }, + }); + }, + duplicateColumn: (id) => + dispatch({ type: "duplicate:col", payload: { id } }), + freezeColumns: (id) => dispatch({ type: "freeze:col", payload: { id } }), + deleteColumn: (id) => dispatch({ type: "delete:col", payload: { id } }), + }), + [dispatch], + ); + + return ( + + + {children} + + + ); +}; diff --git a/packages/database-ui/src/table-view/table-contexts/types.ts b/packages/database-ui/src/table-view/table-contexts/types.ts new file mode 100644 index 00000000..854f0f7e --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/types.ts @@ -0,0 +1,16 @@ +import { DatabaseProperty } from "../types"; + +export type AddColumnPayload = Pick; + +export type UpdateColumnPayload = Partial< + Pick< + DatabaseProperty, + | "name" + | "icon" + | "description" + | "width" + | "wrapped" + | "hidden" + | "isDeleted" + > +>; diff --git a/packages/database-ui/src/table-view/table-contexts/use-table-view.tsx b/packages/database-ui/src/table-view/table-contexts/use-table-view.tsx new file mode 100644 index 00000000..194c8529 --- /dev/null +++ b/packages/database-ui/src/table-view/table-contexts/use-table-view.tsx @@ -0,0 +1,175 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +"use client"; + +import { useMemo, useReducer } from "react"; +import { + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + ColumnPinningState, + getCoreRowModel, + useReactTable, + VisibilityState, + type ColumnDef, +} from "@tanstack/react-table"; + +import { TableHeaderCell } from "../table-header-cells"; +import { TableRowCell } from "../table-row-cells"; +import type { DatabaseProperty, RowDataType } from "../types"; +import { TableViewAtom, tableViewReducer } from "./table-reducer"; + +export const useTableView = (initial: { + properties: DatabaseProperty[]; + data: RowDataType[]; +}) => { + const [{ properties, propertiesOrder, freezedIndex, data }, dispatch] = + useReducer(tableViewReducer, { + ...initial.properties.reduce< + Omit + >( + (acc, col) => ({ + properties: { ...acc.properties, [col.id]: col }, + propertiesOrder: [...acc.propertiesOrder, col.id], + }), + { properties: {}, propertiesOrder: [] }, + ), + freezedIndex: -1, + data: initial.data, + }); + + const columns = useMemo( + () => + Object.values(properties).map>( + ({ id, ...property }) => ({ + id, + accessorKey: property.name, + minSize: property.type === "checkbox" ? 32 : 100, + header: ({ header }) => ( + + dispatch({ + type: "update:col", + payload: { + id, + data: { width: `${header.column.getSize()}px` }, + }, + }), + onTouchStart: header.getResizeHandler(), + onTouchEnd: () => + dispatch({ + type: "update:col", + payload: { + id, + data: { width: `${header.column.getSize()}px` }, + }, + }), + }} + /> + ), + cell: ({ row, column }) => { + const cell = row.original.properties[id]; + if (!cell) return null; + return ( + + dispatch({ + type: "update:cell", + payload: { + rowId: row.original.id, + colId: id, + data: { id: cell.id, ...data }, + }, + }) + } + /> + ); + }, + }), + ), + [properties], + ); + + const columnVisibility = useMemo( + () => + Object.values(properties).reduce( + (acc, col) => ({ ...acc, [col.id]: !col.hidden && !col.isDeleted }), + {}, + ), + [properties], + ); + + const columnPinning = useMemo( + () => ({ left: propertiesOrder.slice(0, freezedIndex + 1) }), + [propertiesOrder, freezedIndex], + ); + + const table = useReactTable({ + columns, + data, + defaultColumn: { + size: 200, + minSize: 100, + maxSize: Number.MAX_SAFE_INTEGER, + }, + columnResizeMode: "onChange", + getCoreRowModel: getCoreRowModel(), + state: { + columnOrder: propertiesOrder, + columnVisibility, + columnPinning, + }, + }); + + /** + * Instead of calling `column.getSize()` on every render for every header + * and especially every data cell (very expensive), + * we will calculate all column sizes at once at the root table level in a useMemo + * and pass the column sizes down as CSS variables to the element. + */ + const columnSizeVars = useMemo(() => { + return table.getFlatHeaders().reduce>( + (sizes, header) => ({ + ...sizes, + [`--header-${header.id}-size`]: header.getSize(), + [`--col-${header.column.id}-size`]: header.column.getSize(), + }), + {}, + ); + }, [ + table.getFlatHeaders(), + table.getState().columnSizingInfo, + table.getState().columnSizing, + ]); + + /** DND */ + const columnSensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}), + ); + + return { + table, + columnSizeVars, + data, + properties, + dispatch, + /** DND */ + columnSensors, + }; +}; diff --git a/packages/database-ui/src/table-view/table-header-cells.tsx b/packages/database-ui/src/table-view/table-header-cells.tsx new file mode 100644 index 00000000..ee426b2b --- /dev/null +++ b/packages/database-ui/src/table-view/table-header-cells.tsx @@ -0,0 +1,193 @@ +"use client"; + +import React, { forwardRef, useRef } from "react"; +import { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { cn } from "@swy/ui/lib"; +import { Hint, IconBlock } from "@swy/ui/shared"; + +import { DefaultIcon } from "./default-icon"; +import * as Icon from "./icons"; +import { PropMenu, useMenuControl } from "./menus"; +import { useTableViewCtx } from "./table-contexts"; + +interface TableHeaderCellProps { + id: string; + width: string; + isResizing?: boolean; + resizeHandle: { + /** + * @prop onMouseDown: resize for desktop + */ + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + /** + * @prop onTouchStart: resize for mobile + */ + onTouchStart?: React.TouchEventHandler; + onTouchEnd?: React.TouchEventHandler; + }; +} + +/** + * Table Header Cell + * + * @requires SortableContext + */ +export const TableHeaderCell: React.FC = ({ + id, + width, + isResizing, + resizeHandle, +}) => { + const { properties } = useTableViewCtx(); + const { openPopover } = useMenuControl(); + + const property = properties[id]!; + + const cellRef = useRef(null); + const openPropMenu = () => { + const rect = cellRef.current?.getBoundingClientRect(); + openPopover(, { + x: rect?.x, + y: rect?.bottom, + className: "h-full max-h-[70vh] w-[220px]", + }); + }; + + /** DND */ + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }); + + const style: React.CSSProperties = { + width, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 10 : 0, + transform: CSS.Translate.toString(transform), // translate instead of transform to avoid squishing + transition, // Warning: it is somehow laggy + }; + + return ( +
+
+
+ +
+
+
+
+ {property.icon ? ( + + ) : ( + + )} +
+ {/* Use this to sort columns */} + +
+
{property.name}
+ {property.description && ( +
+ +
+ )} +
+
+
+
+ {/* Resize handle */} +
+
+
+
+
+ ); +}; + +interface DragHandleProps { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; +} + +const DragHandle: React.FC = ({ attributes, listeners }) => { + return ( +
+
+ +
+
+ ); +}; + +interface ActionCellProps { + icon: React.ReactNode; + onClick?: () => void; +} + +export const ActionCell = forwardRef( + ({ icon, onClick }, ref) => { + return ( +
+
+ {icon} +
+
+ ); + }, +); + +ActionCell.displayName = "ActionCell"; diff --git a/packages/database-ui/src/table-view/table-header-row.tsx b/packages/database-ui/src/table-view/table-header-row.tsx new file mode 100644 index 00000000..84050ad1 --- /dev/null +++ b/packages/database-ui/src/table-view/table-header-row.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useRef } from "react"; + +import "./view.css"; + +import { + horizontalListSortingStrategy, + SortableContext, +} from "@dnd-kit/sortable"; +import { + flexRender, + type ColumnOrderState, + type Header, +} from "@tanstack/react-table"; + +import { cn } from "@swy/ui/lib"; + +import * as Icon from "./icons"; +import { PropsMenu, TypesMenu, useMenuControl } from "./menus"; +import { ActionCell } from "./table-header-cells"; +import type { RowDataType } from "./types"; + +interface TableHeaderRowProps { + leftPinnedHeaders: Header[]; + headers: Header[]; + columnOrder: ColumnOrderState; +} + +export const TableHeaderRow: React.FC = ({ + leftPinnedHeaders, + headers, + columnOrder, +}) => { + const { openPopover } = useMenuControl(); + + const isLeftPinned = leftPinnedHeaders.length > 0; + + const plusButtonRef = useRef(null); + const openTypesMenu = () => { + const rect = plusButtonRef.current?.getBoundingClientRect(); + openPopover(, { + x: rect?.left, + y: rect ? rect.top + rect.height : 0, + }); + }; + const openPropsMenu = () => openPopover(, { x: -12, y: -12 }); + + return ( +
+
+
+
+
+ +
+
+
+
+
+ + {/* Pinned Columns */} + {isLeftPinned && ( +
+ {leftPinnedHeaders.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} +
+ )} + {/* Unpinned Columns */} +
+ {headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} +
+
+
+ + } + onClick={openTypesMenu} + /> + + } + onClick={openPropsMenu} + /> +
+ ); +}; diff --git a/packages/database-ui/src/table-view/table-row-cells.tsx b/packages/database-ui/src/table-view/table-row-cells.tsx new file mode 100644 index 00000000..f539539c --- /dev/null +++ b/packages/database-ui/src/table-view/table-row-cells.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useState } from "react"; + +import "./view.css"; + +import { CheckboxCell, TextCell, TitleCell } from "./cells"; +import { CellType } from "./types"; + +enum CellMode { + Normal = "normal", + Edit = "edit", + Select = "select", +} + +interface DataCellProps { + data: CellType; + wrapped?: boolean; + onChange?: (data: CellType) => void; +} + +interface TableRowCellProps extends DataCellProps { + rowId: number; + colId: number; + width?: string; +} + +export const TableRowCell: React.FC = ({ + data, + rowId, + colId, + width, + wrapped, + onChange, +}) => { + const [mode] = useState(CellMode.Normal); + + return ( +
+
+ +
+ {mode === CellMode.Select && ( +
+ )} +
+ ); +}; + +const DataCell: React.FC = ({ data, wrapped, onChange }) => { + switch (data.type) { + case "title": + return ( + onChange?.({ type: "title", value })} + /> + ); + case "text": + return ( + onChange?.({ type: "text", value })} + /> + ); + case "checkbox": + return ( + onChange?.({ type: "checkbox", checked })} + /> + ); + default: + return null; + } +}; diff --git a/packages/database-ui/src/table-view/table-row.tsx b/packages/database-ui/src/table-view/table-row.tsx new file mode 100644 index 00000000..b85afa2c --- /dev/null +++ b/packages/database-ui/src/table-view/table-row.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from "react"; +import { flexRender, Row } from "@tanstack/react-table"; + +import type { RowDataType } from "./types"; + +interface TableRowProps { + row: Row; +} + +export const TableRow: React.FC = ({ row }) => { + return ( +
+
+
+ {/* Left pinned columns */} +
+ {/* pinned: wrap another div (is this needed?) */} + {/* div: flex opacity-100 transition-duration: 200ms; transition-timing-function: ease; transition-property: opacity; */} + {/* Hover checkbox */} +
+
+
+ +
+
+
+ {/* TODO: pinned columns in the wrapped div */} + {row.getLeftVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} +
+ {/* Center columns */} + {row.getCenterVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} +
+
+ +
+
+ ); +}; diff --git a/packages/database-ui/src/table-view/types.ts b/packages/database-ui/src/table-view/types.ts new file mode 100644 index 00000000..f16f9022 --- /dev/null +++ b/packages/database-ui/src/table-view/types.ts @@ -0,0 +1,42 @@ +import type { IconInfo } from "@swy/ui/shared"; + +export interface Option { + id: string; + name: string; + color: string; +} + +export type CellType = + | { type: "title" | "text"; value: string } + | { type: "checkbox"; checked: boolean } + | { type: "select"; select: Option | null }; +export type PropertyType = CellType["type"]; + +export type CellDataType = { + id: string; // cell id +} & CellType; + +export interface RowDataType { + id: string; // row id (page id) + /** + * @param key: column id + * @param value: cell data + */ + properties: Record; +} + +export type HeaderCellType = + | { type: "title" | "text" | "checkbox" } + | { type: "select"; options: Option[] }; + +export interface DatabaseProperty { + id: string; + type: PropertyType; + name: string; + icon?: IconInfo | null; + width?: string; + description?: string; + wrapped?: boolean; + hidden?: boolean; + isDeleted?: boolean; +} diff --git a/packages/database-ui/src/table-view/utils.ts b/packages/database-ui/src/table-view/utils.ts new file mode 100644 index 00000000..ef3b317d --- /dev/null +++ b/packages/database-ui/src/table-view/utils.ts @@ -0,0 +1,70 @@ +import { v4 } from "uuid"; + +import type { CellDataType, CellType, Option, PropertyType } from "./types"; + +export function getDefaultCell(type: PropertyType): CellDataType { + switch (type) { + case "checkbox": + return { type, id: v4(), checked: false }; + case "select": + return { type, id: v4(), select: null }; + default: + return { type, id: v4(), value: "" }; + } +} + +export function getUniqueName(name: string, names: string[]) { + const namesSet = new Set(names); + let uniqueName = name; + let suffix = 1; + + while (namesSet.has(uniqueName)) { + uniqueName = `${name} ${suffix}`; + suffix++; + } + return uniqueName; +} + +export function transferPropertyValues( + src: CellDataType, + dest: PropertyType, +): CellDataType { + switch (dest) { + case "title": + case "text": + return { type: dest, id: src.id, value: toTextValue(src) }; + case "checkbox": + return { type: dest, id: src.id, checked: toCheckboxValue(src) }; + case "select": + return { type: dest, id: src.id, select: toSelectValue(src) }; + } +} + +function toTextValue(src: CellType): string { + switch (src.type) { + case "text": + return src.value; + case "select": + return src.select?.name ?? ""; + default: + return ""; + } +} + +function toCheckboxValue(src: CellType): boolean { + switch (src.type) { + case "checkbox": + return src.checked; + default: + return false; + } +} + +function toSelectValue(src: CellType): Option | null { + switch (src.type) { + case "select": + return src.select; + default: + return null; + } +} diff --git a/packages/database-ui/src/table-view/view.css b/packages/database-ui/src/table-view/view.css new file mode 100644 index 00000000..e2b1362a --- /dev/null +++ b/packages/database-ui/src/table-view/view.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + /* undefined */ + --sticky-horizontal-offset: 0px; +} + +@layer utilities { + .cursor-webkit-grab { + cursor: -webkit-grab; + } + .word-break { + word-break: break-word; + } + /* used in specific component */ + .shadow-header-row { + box-shadow: + white -3px 0px 0px, + rgb(233, 233, 231) 0px -1px 0px inset; + } + .shadow-header-row-dark { + box-shadow: + rgb(25, 25, 25) -3px 0px 0px, + rgb(47, 47, 47) 0px -1px 0px inset; + } + /* made by myself */ + .shadow-header-sticky { + box-shadow: + white -3px 0px 0px, + rgb(233, 233, 231) -1px -1px 0px inset; + } + /* made by myself */ + .shadow-header-sticky-dark { + box-shadow: + rgb(25, 25, 25) -3px 0px 0px, + rgb(47, 47, 47) -1px -1px 0px inset; + } + .shadow-cell { + box-shadow: + rgba(35, 131, 226, 0.57) 0px 0px 0px 2px inset, + rgba(35, 131, 226, 0.35) 0px 0px 0px 1px inset; + } + .cell-open { + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + Segoe UI Variable Display, + Segoe UI, + Helvetica, + Apple Color Emoji, + Arial, + sans-serif, + Segoe UI Emoji, + Segoe UI Symbol; + box-shadow: + rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, + rgba(15, 15, 15, 0.1) 0px 2px 4px; + } + .title-cell-bg-img { + background-image: linear-gradient( + right, + rgba(55, 53, 47, 0.16) 0%, + rgba(55, 53, 47, 0.16) 100% + ); + background-repeat: repeat-x; + background-position: 0px 100%; + background-size: 100% 1px; + } +} diff --git a/packages/database-ui/src/table-view/view.tsx b/packages/database-ui/src/table-view/view.tsx new file mode 100644 index 00000000..3bb903a5 --- /dev/null +++ b/packages/database-ui/src/table-view/view.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React from "react"; +import { closestCenter, DndContext } from "@dnd-kit/core"; +import { + restrictToHorizontalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; + +import { paddingX } from "../database/constant"; +import { cols, mockData } from "./__mock__"; +import * as Icon from "./icons"; +import { MenuControlProvider } from "./menus"; +import { MemoizedTableBody, TableBody } from "./table-body"; +import { + TableViewProvider, + useTableActions, + useTableViewCtx, +} from "./table-contexts"; +import { TableHeaderRow } from "./table-header-row"; +import type { DatabaseProperty, RowDataType } from "./types"; + +interface TableViewProps { + properties?: DatabaseProperty[]; + data?: RowDataType[]; +} + +export const TableView: React.FC = ({ + properties = cols, + data = mockData, +}) => { + return ( + + + + + + ); +}; + +const TableViewContent = () => { + const { table, columnSizeVars, columnSensors } = useTableViewCtx(); + const { reorderColumns } = useTableActions(); + + const leftPinnedHeaders = table.getLeftLeafHeaders(); + const headers = table.getCenterLeafHeaders(); + + return ( +
+
+
+
+ {/* Header row */} +
+
+
+
+ +
+ {table.getHeaderGroups().map((headerGroup) => ( + + ))} +
+
+
+
+
+
+ {/* Table body */} +
+ {/* Drag and Fill handle */} +
+
+ {/* The blue circle */} + {/*
+
+
+
+
+
*/} +
+
+ {/* ??? */} +
+
+
+ {/* Rows */} + {table.getState().columnSizingInfo.isResizingColumn ? ( + + ) : ( + + )} +
+
+
+ + + New page + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/packages/database-ui/src/theme/index.ts b/packages/database-ui/src/theme/index.ts new file mode 100644 index 00000000..bb2b82b2 --- /dev/null +++ b/packages/database-ui/src/theme/index.ts @@ -0,0 +1,2 @@ +export * from "./theme-provider"; +export * from "./theme-toggle"; diff --git a/packages/database-ui/src/theme/theme-provider.tsx b/packages/database-ui/src/theme/theme-provider.tsx new file mode 100644 index 00000000..0fc1b2f5 --- /dev/null +++ b/packages/database-ui/src/theme/theme-provider.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +interface ThemeProviderState { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) ?? defaultTheme) as Theme, + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = useMemo( + () => ({ + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }), + [theme, storageKey], + ); + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/packages/database-ui/src/theme/theme-toggle.tsx b/packages/database-ui/src/theme/theme-toggle.tsx new file mode 100644 index 00000000..b38dfc19 --- /dev/null +++ b/packages/database-ui/src/theme/theme-toggle.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@swy/ui/shadcn"; + +import { useTheme } from "./theme-provider"; + +export function ThemeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/packages/database-ui/src/vite-env.d.ts b/packages/database-ui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/database-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/database-ui/tailwind.config.ts b/packages/database-ui/tailwind.config.ts new file mode 100644 index 00000000..173f63c1 --- /dev/null +++ b/packages/database-ui/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "tailwindcss"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +import baseConfig from "@swy/tailwind-config"; + +const config = { + darkMode: ["class"], + content: [...baseConfig.content, "../ui/src/**/*.{ts,tsx}"], + presets: [baseConfig], + theme: { + extend: { + fontFamily: { + sans: ["var(--font-geist-sans)", ...fontFamily.sans], + mono: ["var(--font-geist-mono)", ...fontFamily.mono], + }, + }, + }, +} satisfies Config; + +export default config; diff --git a/packages/database-ui/tsconfig.json b/packages/database-ui/tsconfig.json new file mode 100644 index 00000000..ca9d92b2 --- /dev/null +++ b/packages/database-ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@swy/tsconfig/internal-package.json", + "compilerOptions": { + "module": "esnext", + "lib": ["ES2022", "dom", "dom.iterable"], + "baseUrl": ".", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9974cd3..c37e8f16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,6 +589,88 @@ importers: specifier: 'catalog:' version: 5.6.3 + packages/database-ui: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@swy/ui': + specifier: workspace:* + version: link:../ui + '@tanstack/react-table': + specifier: catalog:ui + version: 8.21.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lucide-react: + specifier: catalog:ui + version: 0.456.0(react@18.3.1) + react: + specifier: catalog:react18 + version: 18.3.1 + react-dom: + specifier: catalog:react18 + version: 18.3.1(react@18.3.1) + usehooks-ts: + specifier: ^2.9.1 + version: 2.16.0(react@18.3.1) + uuid: + specifier: catalog:uuid + version: 10.0.0 + zod: + specifier: 'catalog:' + version: 3.23.8 + devDependencies: + '@swy/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@swy/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@swy/tailwind-config': + specifier: workspace:* + version: link:../../tooling/tailwind + '@swy/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: catalog:node22 + version: 22.13.10 + '@types/react': + specifier: catalog:react18 + version: 18.3.11 + '@types/react-dom': + specifier: catalog:react18 + version: 18.3.1 + '@types/uuid': + specifier: catalog:uuid + version: 10.0.0 + eslint: + specifier: 'catalog:' + version: 9.13.0(jiti@2.4.0) + globals: + specifier: ^15.12.0 + version: 15.15.0 + prettier: + specifier: 'catalog:' + version: 3.3.3 + sonner: + specifier: catalog:ui + version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwindcss: + specifier: 'catalog:' + version: 3.4.17(ts-node@10.9.2(@types/node@22.13.10)(typescript@5.6.3)) + typescript: + specifier: 'catalog:' + version: 5.6.3 + vite: + specifier: ^6.0.1 + version: 6.0.3(@types/node@22.13.10)(jiti@2.4.0)(terser@5.36.0)(yaml@2.6.0) + packages/db: dependencies: '@vercel/postgres': @@ -2112,6 +2194,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.1': resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==} @@ -7543,6 +7653,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -13007,6 +13121,38 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.0 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.0 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.0 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.0 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.0 + '@drizzle-team/brocli@0.10.1': {} '@edgestore/react@0.1.7(next@14.2.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8)': @@ -18869,6 +19015,8 @@ snapshots: globals@14.0.0: {} + globals@15.15.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1