diff --git a/apps/studio/.storybook/preview.tsx b/apps/studio/.storybook/preview.tsx index 3a85c2e98..1ecf486cf 100644 --- a/apps/studio/.storybook/preview.tsx +++ b/apps/studio/.storybook/preview.tsx @@ -53,7 +53,7 @@ const StorybookEnvDecorator: Decorator = (story) => { return {story()} } -const SetupDecorator: Decorator = (story) => { +const SetupDecorator: Decorator = (Story) => { const [queryClient] = useState( new QueryClient({ defaultOptions: { @@ -76,7 +76,7 @@ const SetupDecorator: Decorator = (story) => { }> - {story()} + @@ -186,17 +186,17 @@ export const MockDateDecorator: Decorator = (story, { parameters }) => { const decorators: Decorator[] = [ WithLayoutDecorator, - StorybookEnvDecorator, + MockDateDecorator, MockFeatureFlagsDecorator, - LoginStateDecorator, SetupDecorator, + StorybookEnvDecorator, withThemeFromJSXProvider({ themes: { default: theme, }, Provider: ThemeProvider, }) as Decorator, // FIXME: Remove this cast when types are fixed - MockDateDecorator, + LoginStateDecorator, ] const preview: Preview = { diff --git a/apps/studio/package.json b/apps/studio/package.json index b4f7a96b3..3215bb85b 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -61,6 +61,7 @@ "@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1", + "@tanstack/react-table": "^8.19.3", "@tiptap/extension-blockquote": "^2.4.0", "@tiptap/extension-bold": "^2.4.0", "@tiptap/extension-bullet-list": "^2.4.0", diff --git a/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx b/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx index 5bb93ca53..351990fe4 100644 --- a/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx +++ b/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx @@ -39,7 +39,7 @@ export function CmsSidebarContainer({ {sidebar} - + {children} diff --git a/apps/studio/src/components/Datatable/Datatable.tsx b/apps/studio/src/components/Datatable/Datatable.tsx new file mode 100644 index 000000000..57863a8c9 --- /dev/null +++ b/apps/studio/src/components/Datatable/Datatable.tsx @@ -0,0 +1,155 @@ +import type { LayoutProps, TableProps } from "@chakra-ui/react" +import type { Table as ReactTable } from "@tanstack/react-table" +import { + Box, + Flex, + Spinner, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useMultiStyleConfig, +} from "@chakra-ui/react" +import { flexRender } from "@tanstack/react-table" + +import { DatatablePagination } from "./DatatablePagination" + +export interface DatatableProps extends TableProps { + instance: ReactTable + /** + * If provided, this number will be used for pagination instead of retrieving + * from react-table's filtered row count. + */ + totalRowCount?: number + pagination?: boolean + /** + * If provided, this string will be used to display the total row count. + */ + totalRowCountString?: string + isFetching?: boolean + emptyPlaceholder?: React.ReactElement + overflow?: LayoutProps["overflow"] +} + +export function createAccessor(props: (keyof T)[]) { + return (row: T): string => { + return props.map((prop) => String(row[prop])).join(" ") + } +} + +export const Datatable = ({ + instance, + isFetching, + pagination, + totalRowCount, + totalRowCountString, + emptyPlaceholder, + overflow = "auto", + ...tableProps +}: DatatableProps): JSX.Element => { + const { rows } = instance.getRowModel() + const styles = useMultiStyleConfig("Table", tableProps) + + return ( + + {isFetching && ( + <> + + + + + + + + )} + + + + {instance.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {rows.length === 0 && emptyPlaceholder} + {rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ) + })} + + ) + })} + +
+ + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ + {totalRowCountString && ( + + {totalRowCountString} + + )} + {pagination && ( + + + + )} + +
+ ) +} diff --git a/apps/studio/src/components/Datatable/DatatablePagination.tsx b/apps/studio/src/components/Datatable/DatatablePagination.tsx new file mode 100644 index 000000000..ce22e16f9 --- /dev/null +++ b/apps/studio/src/components/Datatable/DatatablePagination.tsx @@ -0,0 +1,27 @@ +import { Pagination } from "@opengovsg/design-system-react" +import { type Table } from "@tanstack/react-table" + +export interface DataTablePaginationProps { + instance: Table + totalRowCount?: number +} + +export const DatatablePagination = ({ + instance, + totalRowCount: totalRowCountProp, +}: DataTablePaginationProps): JSX.Element => { + const paginationState = instance.getState().pagination + const totalRowCount = + totalRowCountProp ?? instance.getFilteredRowModel().rows.length + + return ( + { + instance.setPageIndex(newPage - 1) + }} + pageSize={10} + totalCount={totalRowCount} + /> + ) +} diff --git a/apps/studio/src/components/Datatable/EmptyTablePlaceholder.tsx b/apps/studio/src/components/Datatable/EmptyTablePlaceholder.tsx new file mode 100644 index 000000000..7e336c126 --- /dev/null +++ b/apps/studio/src/components/Datatable/EmptyTablePlaceholder.tsx @@ -0,0 +1,27 @@ +import { Flex, Stack, Td, Text, Tr } from "@chakra-ui/react" + +export const EmptyTablePlaceholder = ({ + entityName, + hasSearchTerm, +}: { + entityName: string + hasSearchTerm: boolean +}) => { + return ( + + + + + + No {entityName} + {hasSearchTerm ? " found" : ""} + + {hasSearchTerm && ( + Try different search terms + )} + + + + + ) +} diff --git a/apps/studio/src/components/Datatable/InfoCell.tsx b/apps/studio/src/components/Datatable/InfoCell.tsx new file mode 100644 index 000000000..0a0ad4168 --- /dev/null +++ b/apps/studio/src/components/Datatable/InfoCell.tsx @@ -0,0 +1,39 @@ +import { memo } from "react" +import NextLink from "next/link" +import { Link, Text, VStack } from "@chakra-ui/react" + +export interface InfoCellProps { + caption?: string | null + subcaption?: string | null + href?: string +} + +export const InfoCell = memo( + ({ caption, subcaption, href }: InfoCellProps): JSX.Element => { + return ( + + {href ? ( + + {caption} + + ) : ( + {caption} + )} + {subcaption} + + ) + }, + (prevProps, nextProps) => { + return ( + prevProps.caption === nextProps.caption && + prevProps.subcaption === nextProps.subcaption + ) + }, +) + +InfoCell.displayName = "InfoCell" diff --git a/apps/studio/src/components/Datatable/TableCell.tsx b/apps/studio/src/components/Datatable/TableCell.tsx new file mode 100644 index 000000000..77d2c7453 --- /dev/null +++ b/apps/studio/src/components/Datatable/TableCell.tsx @@ -0,0 +1,7 @@ +import type { TextProps } from "@chakra-ui/react" +import React from "react" +import { Text } from "@chakra-ui/react" + +export const TableCell = ({ children, ...props }: TextProps) => { + return {children} +} diff --git a/apps/studio/src/components/Datatable/TableHeader.tsx b/apps/studio/src/components/Datatable/TableHeader.tsx new file mode 100644 index 000000000..01dcc11cd --- /dev/null +++ b/apps/studio/src/components/Datatable/TableHeader.tsx @@ -0,0 +1,11 @@ +import type { BoxProps } from "@chakra-ui/react" +import React from "react" +import { Box } from "@chakra-ui/react" + +export const TableHeader = ({ children, ...props }: BoxProps) => { + return ( + + {children} + + ) +} diff --git a/apps/studio/src/components/Datatable/index.ts b/apps/studio/src/components/Datatable/index.ts new file mode 100644 index 000000000..320a1e964 --- /dev/null +++ b/apps/studio/src/components/Datatable/index.ts @@ -0,0 +1,4 @@ +export * from "./Datatable" +export * from "./TableCell" +export * from "./TableHeader" +export * from "./InfoCell" diff --git a/apps/studio/src/components/Menu/MenuItem.tsx b/apps/studio/src/components/Menu/MenuItem.tsx new file mode 100644 index 000000000..31e9512b5 --- /dev/null +++ b/apps/studio/src/components/Menu/MenuItem.tsx @@ -0,0 +1,47 @@ +import type { MenuItemProps as ChakraMenuItemProps } from "@chakra-ui/react" +import { useMemo } from "react" +import { MenuItem as ChakraMenuItem, cssVar } from "@chakra-ui/react" + +const $bg = cssVar("menu-bg") + +export interface MenuItemProps extends ChakraMenuItemProps { + colorScheme?: "critical" +} + +export const MenuItem = ({ + colorScheme, + ...menuItemProps +}: MenuItemProps): JSX.Element => { + // Unable to use useMultiStyleConfig here because Menu parent still controls + // other styles such as size and placement + const extraStyles = useMemo(() => { + if (!colorScheme) return {} + switch (colorScheme) { + case "critical": { + return { + bg: $bg.reference, + color: "interaction.critical.default", + _hover: { + [$bg.variable]: `colors.interaction.muted.critical.hover`, + }, + _focus: { + [$bg.variable]: `colors.interaction.muted.critical.hover`, + _active: { + [$bg.variable]: `colors.interaction.muted.critical.active`, + }, + }, + _focusVisible: { + _active: { + [$bg.variable]: `colors.interaction.muted.critical.active`, + }, + }, + _active: { + [$bg.variable]: `colors.interaction.muted.critical.active`, + }, + } + } + } + }, [colorScheme]) + + return +} diff --git a/apps/studio/src/components/Menu/index.ts b/apps/studio/src/components/Menu/index.ts new file mode 100644 index 000000000..7829c6f2f --- /dev/null +++ b/apps/studio/src/components/Menu/index.ts @@ -0,0 +1 @@ +export { MenuItem } from "./MenuItem" diff --git a/apps/studio/src/features/dashboard/DashboardTable.tsx b/apps/studio/src/features/dashboard/DashboardTable.tsx deleted file mode 100644 index 733b305d1..000000000 --- a/apps/studio/src/features/dashboard/DashboardTable.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useState } from "react" -import { - Badge, - Box, - Checkbox, - HStack, - Icon, - IconButton, - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - VStack, -} from "@chakra-ui/react" -import { Pagination } from "@opengovsg/design-system-react" -import { - BiDotsHorizontalRounded, - BiFileBlank, - BiFolder, - BiHome, - BiSolidCircle, -} from "react-icons/bi" -import { MdOutlineHorizontalRule } from "react-icons/md" - -export const DashboardTable = (): JSX.Element => { - const dummyChildData: { - id: string - name: string - permalink: string - type: "page" | "folder" - status: "folder" | "draft" | "published" - lastEditUser: string - lastEditDate: Date | "folder" - }[] = [ - { - id: "0001", - name: "Test Page 1", - permalink: "/", - type: "page", - status: "draft", - lastEditUser: "user1@test.com", - lastEditDate: new Date(), - }, - { - id: "0003", - name: "Test Folder 1", - permalink: "/testfolder1", - type: "folder", - status: "folder", - lastEditUser: "folder", - lastEditDate: "folder", - }, - { - id: "0002", - name: "Test Page 2", - permalink: "/testpage2", - type: "page", - status: "published", - lastEditUser: "user2@test.com", - lastEditDate: new Date(50000000000), - }, - { - id: "0004", - name: "Test Folder 2", - permalink: "/testfolder2", - type: "folder", - status: "folder", - lastEditUser: "folder", - lastEditDate: "folder", - }, - ] - - const [pageNumber, onPageChange] = useState(1) - const [dataToDisplay, setDataToDisplay] = useState(dummyChildData) - - const entriesPerPage = 6 - return ( - <> - - - - - - - - - - - - {dataToDisplay - .slice( - (pageNumber - 1) * entriesPerPage, - pageNumber * entriesPerPage, - ) - .map((element, index) => { - return ( - - - - - - - - - - ) - })} - -
- {/* checkbox */} - - - - Title - - - - Status - - - - Last Edited - -
- - - - {element.type === "page" && - element.permalink === "/" && ( - - )} - {element.type === "page" && - element.permalink !== "/" && ( - - )} - {element.type === "folder" && ( - - )} - - - {element.name} - - {element.type === "page" && element.permalink} - {element.type === "folder" && "0 pages"} - - - - - {element.type === "page" && element.status == "draft" && ( - - - Draft - - )} - {element.type === "page" && - element.status == "published" && ( - - - Published - - )} - {element.type === "folder" && } - - - {element.type === "page" && ( - - - {element.lastEditUser} - - - {Math.floor( - (new Date().getTime() - - (element.lastEditDate as Date).getTime()) / - (1000 * 3600 * 24), - )}{" "} - Days Ago - - - )} - - {element.type === "folder" && ( - - )} - } - /> - -
-
- - - - - ) -} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/LastEditCell.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/LastEditCell.tsx new file mode 100644 index 000000000..59603c306 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/LastEditCell.tsx @@ -0,0 +1,37 @@ +import { Text, VStack } from "@chakra-ui/react" +import { formatDistance } from "date-fns" + +import type { ResourceTableData } from "./types" + +export interface LastEditCellProps { + date: ResourceTableData["lastEditDate"] + email: ResourceTableData["lastEditUser"] +} + +export const LastEditCell = ({ + date, + email, +}: LastEditCellProps): JSX.Element => { + if (date === "folder") { + return ( + + - + + ) + } + + return ( + + + {email} + + + {formatDistance(date, new Date(), { addSuffix: true })} + + + ) +} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTable.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTable.tsx new file mode 100644 index 000000000..b80101aa6 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTable.tsx @@ -0,0 +1,107 @@ +import type { PaginationState } from "@tanstack/react-table" +import { useState } from "react" +import { + createColumnHelper, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" + +import type { RouterOutput } from "~/utils/trpc" +import { TableHeader } from "~/components/Datatable" +import { createAccessor, Datatable } from "~/components/Datatable/Datatable" +import { EmptyTablePlaceholder } from "~/components/Datatable/EmptyTablePlaceholder" +import { trpc } from "~/utils/trpc" +import { LastEditCell } from "./LastEditCell" +import { ResourceTableMenu } from "./ResourceTableMenu" +import { StatusCell } from "./StatusCell" +import { TitleCell } from "./TitleCell" + +type ResourceTableData = RouterOutput["page"]["list"][number] + +const columnsHelper = createColumnHelper() + +const columns = [ + columnsHelper.accessor("name", { + minSize: 300, + header: () => Title, + cell: ({ row }) => ( + + ), + }), + columnsHelper.accessor("status", { + size: 100, + header: () => Status, + cell: ({ row }) => , + }), + columnsHelper.accessor(createAccessor(["lastEditDate", "lastEditUser"]), { + id: "edit_details", + size: 120, + header: () => Last edited, + cell: ({ row }) => ( + + ), + }), + columnsHelper.display({ + id: "resource_menu", + header: () => Actions, + cell: ({ row }) => ( + + ), + size: 24, + }), +] + +export const ResourceTable = (): JSX.Element => { + const { data: resources } = trpc.page.list.useQuery(undefined, { + keepPreviousData: true, // Required for table to show previous data while fetching next page + }) + + const totalRowCount = resources?.length ?? 0 + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }) + + const tableInstance = useReactTable({ + columns, + data: resources ?? [], + getCoreRowModel: getCoreRowModel(), + manualFiltering: true, + autoResetPageIndex: false, + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination, + }, + }) + + return ( + + } + instance={tableInstance} + sx={{ + tableLayout: "auto", + minWidth: "1000px", + overflowX: "auto", + }} + totalRowCount={totalRowCount} + totalRowCountString={`${totalRowCount} item${totalRowCount === 1 ? "" : "s"} in collection`} + /> + ) +} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx new file mode 100644 index 000000000..15132fd89 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx @@ -0,0 +1,53 @@ +import { MenuButton, MenuList } from "@chakra-ui/react" +import { IconButton, Menu } from "@opengovsg/design-system-react" +import { + BiCog, + BiDotsHorizontalRounded, + BiDuplicate, + BiFolderOpen, + BiTrash, +} from "react-icons/bi" + +import type { ResourceTableData } from "./types" +import { MenuItem } from "~/components/Menu" + +interface ResourceTableMenuProps { + title: ResourceTableData["name"] + resourceId: ResourceTableData["id"] + type: ResourceTableData["type"] +} + +export const ResourceTableMenu = ({ title, type }: ResourceTableMenuProps) => { + return ( + + } + variant="clear" + /> + + {/* TODO: Open edit modal depending on resource */} + {type === "page" ? ( + <> + }> + Edit page settings + + }> + Duplicate page + + + ) : ( + }> + Edit folder settings + + )} + }>Move to... + }> + Delete + + + + ) +} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/StatusCell.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/StatusCell.tsx new file mode 100644 index 000000000..af27599d9 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/StatusCell.tsx @@ -0,0 +1,42 @@ +import { Text } from "@chakra-ui/react" +import { Badge, BadgeLeftIcon } from "@opengovsg/design-system-react" +import { BiSolidCircle } from "react-icons/bi" + +import type { ResourceTableData } from "./types" + +export interface StatusCellProps { + status: ResourceTableData["status"] +} + +export const StatusCell = ({ status }: StatusCellProps): JSX.Element => { + switch (status) { + case "folder": + return ( + + - + + ) + case "published": + return ( + + + Published + + ) + case "draft": + return ( + + + Draft + + ) + } +} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx new file mode 100644 index 000000000..51609a616 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx @@ -0,0 +1,39 @@ +import { HStack, Icon, Text, VStack } from "@chakra-ui/react" +import { BiFileBlank, BiFolder } from "react-icons/bi" + +import type { ResourceTableData } from "./types" + +export interface TitleCellProps { + title: ResourceTableData["name"] + permalink?: ResourceTableData["permalink"] + type: ResourceTableData["type"] +} + +export const TitleCell = ({ + title, + permalink, + type, +}: TitleCellProps): JSX.Element => { + return ( + + + + + {title} + + + {permalink} + + + + ) +} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/index.ts b/apps/studio/src/features/dashboard/components/ResourceTable/index.ts new file mode 100644 index 000000000..11e9b8529 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/index.ts @@ -0,0 +1 @@ +export * from "./ResourceTable" diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/types.ts b/apps/studio/src/features/dashboard/components/ResourceTable/types.ts new file mode 100644 index 000000000..b51902e43 --- /dev/null +++ b/apps/studio/src/features/dashboard/components/ResourceTable/types.ts @@ -0,0 +1,3 @@ +import type { RouterOutput } from "~/utils/trpc" + +export type ResourceTableData = RouterOutput["page"]["list"][number] diff --git a/apps/studio/src/pages/sites/[siteId]/index.tsx b/apps/studio/src/pages/sites/[siteId]/index.tsx index 7b444473f..aa2edafbb 100644 --- a/apps/studio/src/pages/sites/[siteId]/index.tsx +++ b/apps/studio/src/pages/sites/[siteId]/index.tsx @@ -1,7 +1,7 @@ -import { HStack, Text, useDisclosure, VStack } from "@chakra-ui/react" +import { Box, HStack, Text, useDisclosure, VStack } from "@chakra-ui/react" import { Button } from "@opengovsg/design-system-react" -import { DashboardTable } from "~/features/dashboard/DashboardTable" +import { ResourceTable } from "~/features/dashboard/components/ResourceTable" import PageCreateModal from "~/features/editing-experience/components/PageCreateModal" import { type NextPageWithLayout } from "~/lib/types" import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout" @@ -13,36 +13,33 @@ const SitePage: NextPageWithLayout = () => { onClose: onpageCreateModalClose, } = useDisclosure() return ( - - - My Pages - - - - Double click a page to start editing. - - - - - + <> + + + + My Pages + + + + + + + + + + + + + - + ) } diff --git a/apps/studio/src/server/modules/page/page.router.ts b/apps/studio/src/server/modules/page/page.router.ts index 55a4ac745..1f8f425e0 100644 --- a/apps/studio/src/server/modules/page/page.router.ts +++ b/apps/studio/src/server/modules/page/page.router.ts @@ -53,6 +53,137 @@ const validatedPageProcedure = protectedProcedure.use( ) export const pageRouter = router({ + list: protectedProcedure.query(() => { + const dummyChildData: { + id: string + name: string + permalink: string + type: "page" | "folder" + status: "folder" | "draft" | "published" + lastEditUser: string + lastEditDate: Date | "folder" + }[] = [ + { + id: "0001", + name: "Test Page 1", + permalink: "/", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0002", + name: "Test Page 2", + permalink: "/testpage2", + type: "page", + status: "published", + lastEditUser: "user2@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0003", + name: "Test Folder 1", + permalink: "/testfolder1", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0004", + name: "Test Folder 2", + permalink: "/testfolder2", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0005", + name: "Test Page 5", + permalink: "/", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0006", + name: "Test Folder 6", + permalink: "/testfolder6", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0007", + name: "Test Page 7", + permalink: "/testpage7", + type: "page", + status: "published", + lastEditUser: "user7@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0008", + name: "Test Folder 8", + permalink: "/testfolder8", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0009", + name: "Test Folder 9", + permalink: "/testfolder9", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0010", + name: "Test Page 10", + permalink: "/testpage10", + type: "page", + status: "published", + lastEditUser: "user2@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0011", + name: "Test Folder 11", + permalink: "/testfolder11", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0012", + name: "Test Page 12", + permalink: "/testpage12", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0013", + name: "Test Folder 13", + permalink: "/testfolder13", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + ] + // TODO: Implement actual data fetching + return dummyChildData + }), readPageAndBlob: protectedProcedure .input(getEditPageSchema) .query(async ({ input, ctx }) => { diff --git a/apps/studio/src/stories/Page/SitePage.stories.tsx b/apps/studio/src/stories/Page/SitePage.stories.tsx index 7006661e4..9146329c0 100644 --- a/apps/studio/src/stories/Page/SitePage.stories.tsx +++ b/apps/studio/src/stories/Page/SitePage.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react" +import { userEvent, waitFor, within } from "@storybook/test" import { meHandlers } from "tests/msw/handlers/me" +import { pageHandlers } from "tests/msw/handlers/page" import SitePage from "~/pages/sites/[siteId]" @@ -9,7 +11,7 @@ const meta: Meta = { parameters: { getLayout: SitePage.getLayout, msw: { - handlers: [meHandlers.me()], + handlers: [meHandlers.me(), pageHandlers.list.default()], }, }, decorators: [], @@ -21,3 +23,27 @@ type Story = StoryObj export const Default: Story = { args: {}, } + +export const PageResourceMenu: Story = { + play: async ({ canvasElement }) => { + await waitFor(async () => { + const screen = within(canvasElement) + const pageMenuButton = screen.getByRole("button", { + name: "Options for Test Page 1", + }) + await userEvent.click(pageMenuButton) + }) + }, +} + +export const FolderResourceMenu: Story = { + play: async ({ canvasElement }) => { + await waitFor(async () => { + const screen = within(canvasElement) + const folderMenuButton = screen.getByRole("button", { + name: "Options for Test Folder 1", + }) + await userEvent.click(folderMenuButton) + }) + }, +} diff --git a/apps/studio/src/theme/components/Table.ts b/apps/studio/src/theme/components/Table.ts new file mode 100644 index 000000000..72a8d923b --- /dev/null +++ b/apps/studio/src/theme/components/Table.ts @@ -0,0 +1,87 @@ +import type { SystemStyleObject } from "@chakra-ui/react" +import { tableAnatomy } from "@chakra-ui/anatomy" +import { createMultiStyleConfigHelpers } from "@chakra-ui/react" + +import { textStyles } from "../foundations/textStyles" + +const parts = tableAnatomy.extend("container") + +// eslint-disable-next-line @typescript-eslint/unbound-method +const { defineMultiStyleConfig, definePartsStyle } = + createMultiStyleConfigHelpers(parts.keys) + +const baseStyle = definePartsStyle({ + tr: { + pos: "relative", + textStyle: "body-2", + _last: { + borderBottomWidth: 0, + }, + }, +}) + +const sizes = { + md: definePartsStyle({ + container: { + p: "0.75rem", + }, + th: { + py: "0.625rem", + minH: "1.5rem", + ...textStyles["body-2"], + }, + td: { + py: "0.5rem", + px: "1rem", + }, + }), +} + +const getSubtleVariantThStyles = (): SystemStyleObject => { + const baseStyles: SystemStyleObject = { + color: "base.content.medium", + textTransform: "initial", + } + + return { + color: "base.content.medium", + ...baseStyles, + } +} + +const variantSubtle = definePartsStyle(() => { + return { + container: { + bg: "white", + borderRadius: "8px", + border: "1px solid", + borderColor: "base.divider.medium", + }, + table: { + bg: "white", + }, + thead: { + opacity: 1, + zIndex: 1, + }, + th: getSubtleVariantThStyles(), + td: { + color: "base.content.default", + }, + } +}) + +const variants = { + subtle: variantSubtle, +} + +export const Table = defineMultiStyleConfig({ + baseStyle, + variants, + defaultProps: { + variant: "subtle", + size: "md", + colorScheme: "neutral", + }, + sizes, +}) diff --git a/apps/studio/src/theme/components/index.ts b/apps/studio/src/theme/components/index.ts index dc1d99793..56d1d90fb 100644 --- a/apps/studio/src/theme/components/index.ts +++ b/apps/studio/src/theme/components/index.ts @@ -1,5 +1,7 @@ import { Modal } from "./Modal" +import { Table } from "./Table" export const components = { Modal, + Table, } diff --git a/apps/studio/src/theme/foundations/textStyles.ts b/apps/studio/src/theme/foundations/textStyles.ts index 0202307d9..991856064 100644 --- a/apps/studio/src/theme/foundations/textStyles.ts +++ b/apps/studio/src/theme/foundations/textStyles.ts @@ -1,4 +1,8 @@ -export const textStyles = { +import merge from "lodash/merge" + +import { textStyles as generatedTextStyles } from "../generated/textStyles" + +const customTextStyles = { "h3-semibold": { fontWeight: 600, lineHeight: "2.25rem", @@ -7,3 +11,5 @@ export const textStyles = { fontFamily: "body", }, } + +export const textStyles = merge(customTextStyles, generatedTextStyles) diff --git a/apps/studio/src/theme/generated/textStyles.ts b/apps/studio/src/theme/generated/textStyles.ts new file mode 100644 index 000000000..487ec7703 --- /dev/null +++ b/apps/studio/src/theme/generated/textStyles.ts @@ -0,0 +1,215 @@ +/** + * Do not edit directly + * Generated on Thu, 28 Dec 2023 07:03:58 GMT + */ + +export const textStyles = { + "responsive-display": { + "heavy-1280": { + fontWeight: 700, + lineHeight: "4.5rem", + fontSize: "4rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "heavy-480": { + fontWeight: 700, + lineHeight: "4rem", + fontSize: "3.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + heavy: { + fontWeight: 600, + lineHeight: "3rem", + fontSize: "2.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "light-1280": { + fontWeight: 300, + lineHeight: "4.5rem", + fontSize: "4rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "light-480": { + fontWeight: 300, + lineHeight: "4rem", + fontSize: "3.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + light: { + fontWeight: 300, + lineHeight: "3rem", + fontSize: "2.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + }, + "responsive-heading": { + "heavy-1280": { + fontWeight: 600, + lineHeight: "3.5rem", + fontSize: "3rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "heavy-480": { + fontWeight: 600, + lineHeight: "3rem", + fontSize: "2.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + heavy: { + fontWeight: 600, + lineHeight: "2.5rem", + fontSize: "2rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "light-1280": { + fontWeight: 300, + lineHeight: "3.5rem", + fontSize: "3rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + "light-480": { + fontWeight: 300, + lineHeight: "3rem", + fontSize: "2.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + light: { + fontWeight: 300, + lineHeight: "2.5rem", + fontSize: "2rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + }, + h1: { + fontWeight: 600, + lineHeight: "3rem", + fontSize: "2.5rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + h2: { + fontWeight: 600, + lineHeight: "2.75rem", + fontSize: "2.25rem", + letterSpacing: "-0.022em", + fontFamily: "body", + }, + h3: { + fontWeight: 700, + lineHeight: "2.25rem", + fontSize: "1.75rem", + letterSpacing: "-0.019em", + fontFamily: "body", + }, + h4: { + fontWeight: 600, + lineHeight: "2rem", + fontSize: "1.5rem", + letterSpacing: "-0.019em", + fontFamily: "body", + }, + h5: { + fontWeight: 600, + lineHeight: "1.75rem", + fontSize: "1.25rem", + letterSpacing: "-0.014em", + fontFamily: "body", + }, + h6: { + fontWeight: 500, + lineHeight: "1.5rem", + fontSize: "1.125rem", + letterSpacing: "-0.014em", + fontFamily: "body", + }, + "subhead-1": { + fontWeight: 500, + lineHeight: "1.5rem", + fontSize: "1rem", + letterSpacing: "-0.006em", + fontFamily: "body", + }, + "subhead-2": { + fontWeight: 500, + lineHeight: "1.25rem", + fontSize: "0.875rem", + letterSpacing: 0, + fontFamily: "body", + }, + "subhead-3": { + fontWeight: 600, + lineHeight: "1.5rem", + fontSize: "0.875rem", + letterSpacing: "0.080em", + fontFamily: "body", + textTransform: "uppercase", + }, + "body-1": { + fontWeight: 400, + lineHeight: "1.5rem", + fontSize: "1rem", + letterSpacing: "-0.006em", + fontFamily: "body", + }, + "body-2": { + fontWeight: 400, + lineHeight: "1.25rem", + fontSize: "0.875rem", + letterSpacing: 0, + fontFamily: "body", + }, + "body-3": { + fontWeight: 400, + lineHeight: "1.75rem", + fontSize: "1rem", + letterSpacing: "-0.006em", + fontFamily: "body", + }, + "caption-1": { + fontWeight: 500, + lineHeight: "1rem", + fontSize: "0.75rem", + letterSpacing: 0, + fontFamily: "body", + }, + "caption-2": { + fontWeight: 400, + lineHeight: "1rem", + fontSize: "0.75rem", + letterSpacing: 0, + fontFamily: "body", + }, + "code-1": { + fontWeight: 400, + lineHeight: "1.25rem", + fontSize: "0.875rem", + letterSpacing: 0, + fontFamily: "code", + }, + "code-2": { + fontWeight: 400, + lineHeight: "1rem", + fontSize: "0.75rem", + letterSpacing: 0, + fontFamily: "code", + }, + legal: { + fontWeight: 400, + lineHeight: "0.75rem", + fontSize: "0.625rem", + letterSpacing: 0, + fontFamily: "body", + }, +} diff --git a/apps/studio/tests/msw/handlers/page.ts b/apps/studio/tests/msw/handlers/page.ts new file mode 100644 index 000000000..fdecd68ab --- /dev/null +++ b/apps/studio/tests/msw/handlers/page.ts @@ -0,0 +1,138 @@ +import type { DelayMode } from "msw" +import { delay } from "msw" + +import { trpcMsw } from "../mockTrpc" + +const pageListQuery = (wait?: DelayMode | number) => { + return trpcMsw.page.list.query(async () => { + if (wait !== undefined) { + await delay(wait) + } + return [ + { + id: "0001", + name: "Test Page 1", + permalink: "/", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0002", + name: "Test Page 2", + permalink: "/testpage2", + type: "page", + status: "published", + lastEditUser: "user2@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0003", + name: "Test Folder 1", + permalink: "/testfolder1", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0004", + name: "Test Folder 2", + permalink: "/testfolder2", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0005", + name: "Test Page 5", + permalink: "/", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0006", + name: "Test Folder 6", + permalink: "/testfolder6", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0007", + name: "Test Page 7", + permalink: "/testpage7", + type: "page", + status: "published", + lastEditUser: "user7@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0008", + name: "Test Folder 8", + permalink: "/testfolder8", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0009", + name: "Test Folder 9", + permalink: "/testfolder9", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0010", + name: "Test Page 10", + permalink: "/testpage10", + type: "page", + status: "published", + lastEditUser: "user2@test.com", + lastEditDate: new Date("2024-06-15T09:16:46.640Z"), + }, + { + id: "0011", + name: "Test Folder 11", + permalink: "/testfolder11", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + { + id: "0012", + name: "Test Page 12", + permalink: "/testpage12", + type: "page", + status: "draft", + lastEditUser: "user1@test.com", + lastEditDate: new Date("2024-07-15T09:16:46.640Z"), + }, + { + id: "0013", + name: "Test Folder 13", + permalink: "/testfolder13", + type: "folder", + status: "folder", + lastEditUser: "folder", + lastEditDate: "folder", + }, + ] + }) +} + +export const pageHandlers = { + list: { + default: pageListQuery, + loading: () => pageListQuery("infinite"), + }, +} diff --git a/package-lock.json b/package-lock.json index a9b2092bd..8d292b054 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "license": "ISC", "workspaces": [ - "docs", "apps/*", "packages/*", "tooling/*" @@ -44,6 +43,7 @@ "@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1", + "@tanstack/react-table": "^8.19.3", "@tiptap/extension-blockquote": "^2.4.0", "@tiptap/extension-bold": "^2.4.0", "@tiptap/extension-bullet-list": "^2.4.0", @@ -6966,6 +6966,141 @@ "glob": "10.3.10" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz", + "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz", + "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz", + "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz", + "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz", + "integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz", + "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz", + "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz", + "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz", + "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", @@ -11821,6 +11956,25 @@ "node": ">=10" } }, + "node_modules/@tanstack/react-table": { + "version": "8.19.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz", + "integrity": "sha512-MtgPZc4y+cCRtU16y1vh1myuyZ2OdkWgMEBzyjYsoMWMicKZGZvcDnub3Zwb6XF2pj9iRMvm1SO1n57lS0vXLw==", + "dependencies": { + "@tanstack/table-core": "8.19.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.8.2.tgz", @@ -11837,6 +11991,18 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@tanstack/table-core": { + "version": "8.19.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.3.tgz", + "integrity": "sha512-IqREj9ADoml9zCAouIG/5kCGoyIxPFdqdyoxis9FisXFi5vT+iYfEfLosq4xkU/iDbMcEuAj+X8dWRLvKYDNoQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.8.2.tgz", diff --git a/package.json b/package.json index ed85cc98e..8b4244097 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "postinstall": "npm run lint:ws" }, "workspaces": [ - "docs", "apps/*", "packages/*", "tooling/*"