Skip to content

Commit

Permalink
feat: introduce database content layout (#713)
Browse files Browse the repository at this point in the history
* feat: introduce database content layout

* feat: add support for links

* fix: improve performance of searching

* chore: add story for search with no results

* chore: apply design fixes

* fix: update empty state title prose type

* fix: handle undefined title

* chore: remove hyperlink excel function in search key
  • Loading branch information
dcshzj authored Oct 11, 2024
1 parent 5540f2a commit e0fd6bb
Show file tree
Hide file tree
Showing 26 changed files with 1,695 additions and 78 deletions.
15 changes: 15 additions & 0 deletions packages/components/src/interfaces/internal/SearchableTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Static } from "@sinclair/typebox"
import { Type } from "@sinclair/typebox"

import type { IsomerSitemap, LinkComponentType } from "~/types"

export const SearchableTableSchema = Type.Object({
title: Type.Optional(Type.String()),
headers: Type.Array(Type.Union([Type.String(), Type.Number()])),
items: Type.Array(Type.Array(Type.Union([Type.String(), Type.Number()]))),
})

export type SearchableTableProps = Static<typeof SearchableTableSchema> & {
sitemap: IsomerSitemap
LinkComponent?: LinkComponentType
}
4 changes: 4 additions & 0 deletions packages/components/src/interfaces/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export type { NavbarProps } from "./Navbar"
export type { NotificationProps } from "./Notification"
export type { PillProps } from "./Pill"
export type { SearchProps } from "./Search"
export {
SearchableTableSchema,
type SearchableTableProps,
} from "./SearchableTable"
export type { SearchSGInputBoxProps } from "./SearchSGInputBox"
export type { SidePaneProps } from "./SidePane"
export type { SiderailProps } from "./Siderail"
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/schemas/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ArticlePageMetaSchema,
CollectionPageMetaSchema,
ContentPageMetaSchema,
DatabasePageMetaSchema,
FileRefMetaSchema,
HomePageMetaSchema,
LinkRefMetaSchema,
Expand All @@ -15,6 +16,7 @@ import {
const LAYOUT_METADATA_MAP = {
article: ArticlePageMetaSchema,
content: ContentPageMetaSchema,
database: DatabasePageMetaSchema,
homepage: HomePageMetaSchema,
index: ContentPageMetaSchema,
notfound: NotFoundPageMetaSchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/schemas/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ArticlePagePageSchema,
CollectionPagePageSchema,
ContentPagePageSchema,
DatabasePagePageSchema,
FileRefPageSchema,
HomePagePageSchema,
LinkRefPageSchema,
Expand All @@ -15,6 +16,7 @@ import {
const LAYOUT_PAGE_MAP = {
article: ArticlePagePageSchema,
content: ContentPagePageSchema,
database: DatabasePagePageSchema,
homepage: HomePagePageSchema,
index: ContentPagePageSchema,
notfound: NotFoundPagePageSchema,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { IsomerSitemap, LinkComponentType } from "~/types"
import { getReferenceLinkHref } from "~/utils"
import BaseParagraph from "../BaseParagraph"
import { HYPERLINK_EXCEL_FUNCTION } from "./constants"

interface CellContentProps {
content: string | number
sitemap: IsomerSitemap
LinkComponent?: LinkComponentType
}

export const CellContent = ({
content,
sitemap,
LinkComponent,
}: CellContentProps) => {
if (
typeof content === "string" &&
content.startsWith(HYPERLINK_EXCEL_FUNCTION) &&
content.endsWith(")")
) {
const link = content.slice(HYPERLINK_EXCEL_FUNCTION.length, -1)
const [linkHref, linkText] = link.split(",")

return (
<BaseParagraph
content={`<a href=${getReferenceLinkHref(linkHref?.replace(/"/g, ""), sitemap)}>${linkText || linkHref || "Link"}</a>`}
LinkComponent={LinkComponent}
/>
)
}

return (
<BaseParagraph content={String(content)} LinkComponent={LinkComponent} />
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react"

import type { SearchableTableProps } from "~/interfaces"
import SearchableTable from "./SearchableTable"

const meta: Meta<SearchableTableProps> = {
title: "Next/Internal Components/SearchableTable",
component: SearchableTable,
argTypes: {},
parameters: {
themes: {
themeOverride: "Isomer Next",
},
},
}
export default meta
type Story = StoryObj<typeof SearchableTable>

export const Default: Story = {
args: {
title: "This is the title",
headers: ["Header", "Header", "Header", "Header"],
items: [
["Cell copy", "Cell copy", "Cell copy", "Cell copy"],
["Cell copy", "Cell copy", "Cell copy", "Cell copy"],
["Cell copy", "Cell copy", "Cell copy", "Cell copy"],
],
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { SearchableTableProps } from "~/interfaces"
import { HYPERLINK_EXCEL_FUNCTION } from "./constants"
import { SearchableTableClient } from "./SearchableTableClient"

const SearchableTable = ({ items, ...rest }: SearchableTableProps) => {
const cacheItems = items.map((item) => ({
row: item,
key: item
.map((content) => {
if (
typeof content === "string" &&
content.startsWith(HYPERLINK_EXCEL_FUNCTION) &&
content.endsWith(")")
) {
const link = content.slice(HYPERLINK_EXCEL_FUNCTION.length, -1)
const [linkHref, linkText] = link.split(",")

return linkText || linkHref
}

return content
})
.join(" ")
.toLowerCase(),
}))

return <SearchableTableClient items={cacheItems} {...rest} />
}

export default SearchableTable
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"use client"

import { useDeferredValue, useId, useMemo, useRef, useState } from "react"

import type { SearchableTableProps } from "~/interfaces"
import { tv } from "~/lib/tv"
import BaseParagraph from "../BaseParagraph"
import { PaginationControls } from "../PaginationControls"
import { SearchField } from "../Search"
import { CellContent } from "./CellContent"
import { MAX_NUMBER_OF_COLUMNS, PAGINATION_MAX_ITEMS } from "./constants"
import { getFilteredItems } from "./getFilteredItems"
import { getPaginatedItems } from "./getPaginatedItems"

const createSearchableTableStyles = tv({
slots: {
container: "mx-auto w-full",
title: "prose-display-md break-words text-base-content-strong",
search: "mt-9",
tableContainer: "mt-8 overflow-x-auto",
table:
"[&_>_tbody_>_tr:nth-child(even)_>_td]:bg-base-canvas-default w-full border-collapse border-spacing-0 [&_>_tbody_>_tr:nth-child(odd)_>_td]:bg-base-canvas-alt",
tableRow: "text-left",
tableCell:
"max-w-40 break-words border border-base-divider-medium px-4 py-3 align-top last:max-w-full [&_li]:my-0 [&_li]:pl-1 [&_ol]:mt-0 [&_ol]:ps-5 [&_ul]:mt-0 [&_ul]:ps-5",
emptyState:
"flex flex-col items-center justify-center gap-8 self-stretch px-10 py-20 pt-24",
emptyStateHeadings: "text-center",
emptyStateTitle: "prose-headline-lg-regular",
emptyStateSubtitle:
"text-base-content-default prose-headline-lg-regular mt-3",
emptyStateButton:
"prose-headline-base-medium text-link hover:text-link-hover",
pagination: "mt-8 flex w-full justify-center lg:justify-end",
},
variants: {
isHeader: {
true: {
tableCell:
"bg-brand-interaction text-base-content-inverse [&_ol]:prose-label-md-medium [&_p]:prose-label-md-medium",
},
false: {
tableCell: "text-base-content [&_ol]:prose-body-sm [&_p]:prose-body-sm",
},
},
bold: {
true: {
emptyStateTitle: "text-base-content-strong",
},
false: {
emptyStateTitle: "text-base-content-subtle",
},
},
},
})

const compoundStyles = createSearchableTableStyles()

export type SearchableTableClientProps = Omit<SearchableTableProps, "items"> & {
items: {
row: SearchableTableProps["items"][number]
key: string
}[]
}

export const SearchableTableClient = ({
title,
headers,
items,
sitemap,
LinkComponent,
}: SearchableTableClientProps) => {
const [_search, setSearch] = useState("")
const search = useDeferredValue(_search)
const [currPage, setCurrPage] = useState(1)
const titleId = useId()

const maxNoOfColumns = Math.min(
headers.length,
...items.map((row) => row.row.length),
MAX_NUMBER_OF_COLUMNS,
)
const filteredItems = useMemo(
() =>
getFilteredItems({
items,
searchValue: search,
}),
[items, search],
)

const paginatedItems = useMemo(
() =>
getPaginatedItems({
items: filteredItems,
currPage,
itemsPerPage: PAGINATION_MAX_ITEMS,
}),
[currPage, filteredItems],
)

const isInitiallyEmpty = items.length === 0 || maxNoOfColumns === 0
const isFilteredEmpty = items.length !== 0 && filteredItems.length === 0

const sectionTopRef = useRef<HTMLDivElement>(null)
const onPageChange = () => {
sectionTopRef.current?.scrollIntoView({
block: "start",
})
}

return (
<div className={compoundStyles.container()} ref={sectionTopRef}>
{title && (
<h2 id={titleId} className={compoundStyles.title()}>
{title}
</h2>
)}

<div className={compoundStyles.search()}>
<SearchField
aria-label="Search table"
placeholder="Enter a search term"
value={search}
onChange={(value) => {
setSearch(value)
setCurrPage(1)
}}
/>
</div>

{isInitiallyEmpty && (
<div className={compoundStyles.emptyState()}>
<p className={compoundStyles.emptyStateTitle()}>
There are no items to display
</p>
</div>
)}

{isFilteredEmpty && (
<div className={compoundStyles.emptyState()}>
<div className={compoundStyles.emptyStateHeadings()}>
<p className={compoundStyles.emptyStateTitle({ bold: false })}>
No search results for “
<b className={compoundStyles.emptyStateTitle({ bold: true })}>
{search}
</b>
</p>

<p className={compoundStyles.emptyStateSubtitle()}>
Check if you have a spelling error or try a different search term.
</p>
</div>

<button
className={compoundStyles.emptyStateButton()}
onClick={() => {
setSearch("")
setCurrPage(1)
}}
>
Clear search
</button>
</div>
)}

{paginatedItems.length > 0 && (
<div className={compoundStyles.tableContainer()} tabIndex={0}>
<table
className={compoundStyles.table()}
aria-describedby={!!title ? titleId : undefined}
>
<tbody>
<tr className={compoundStyles.tableRow()}>
{headers.slice(0, maxNoOfColumns).map((header, index) => (
<th
key={index}
className={compoundStyles.tableCell({ isHeader: true })}
>
<BaseParagraph content={String(header)} />
</th>
))}
</tr>

{paginatedItems.map((row, rowIndex) => {
return (
<tr key={rowIndex} className={compoundStyles.tableRow()}>
{row.slice(0, maxNoOfColumns).map((cell, cellIndex) => (
<td
key={cellIndex}
className={compoundStyles.tableCell({
isHeader: false,
})}
>
<CellContent
content={cell}
sitemap={sitemap}
LinkComponent={LinkComponent}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)}

{filteredItems.length > 0 && (
<div className={compoundStyles.pagination()}>
<PaginationControls
totalItems={filteredItems.length}
onPageChange={onPageChange}
itemsPerPage={PAGINATION_MAX_ITEMS}
currPage={currPage}
setCurrPage={setCurrPage}
/>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This is the maximum number of columns that the table can have
// 10 was decided because at 1240px, each column will only have 124px which is
// the minimum width for a column to be readable
export const MAX_NUMBER_OF_COLUMNS = 10

export const PAGINATION_MAX_ITEMS = 10

export const HYPERLINK_EXCEL_FUNCTION = "=HYPERLINK("
Loading

0 comments on commit e0fd6bb

Please sign in to comment.