diff --git a/apps/studio/package.json b/apps/studio/package.json
index 6c424a6831..d29be5bfd1 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -112,6 +112,7 @@
"@trpc/next": "10.45.0",
"@trpc/react-query": "10.45.0",
"@trpc/server": "10.45.0",
+ "@uidotdev/usehooks": "^2.4.1",
"ajv": "^8.16.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.1.3",
diff --git a/apps/studio/prisma/migrations/20241114155239_resource_title_add_trgm_index/migration.sql b/apps/studio/prisma/migrations/20241114155239_resource_title_add_trgm_index/migration.sql
new file mode 100644
index 0000000000..fda1fbdd84
--- /dev/null
+++ b/apps/studio/prisma/migrations/20241114155239_resource_title_add_trgm_index/migration.sql
@@ -0,0 +1,5 @@
+-- Enable the pg_trgm extension (if not already enabled)
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- Create the GIN index on the "title" column of the "Resource" table
+CREATE INDEX "resource_title_trgm_idx" ON "Resource" USING GIN ("title" gin_trgm_ops);
diff --git a/apps/studio/src/components/AppBanner.tsx b/apps/studio/src/components/AppBanner.tsx
index 94ef9bec4a..0aa02fd3db 100644
--- a/apps/studio/src/components/AppBanner.tsx
+++ b/apps/studio/src/components/AppBanner.tsx
@@ -1,8 +1,15 @@
+import { Box } from "@chakra-ui/react"
import { Banner } from "@opengovsg/design-system-react"
-import { useBanner } from "~/hooks/useBanner"
+import { APP_BANNER_ID, useBanner } from "~/hooks/useBanner"
export const AppBanner = () => {
const banner = useBanner()
- return banner && {banner.message}
+ return (
+ banner && (
+
+ {banner.message}
+
+ )
+ )
}
diff --git a/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx b/apps/studio/src/components/CmsSidebar/CmsContainer.tsx
similarity index 75%
rename from apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx
rename to apps/studio/src/components/CmsSidebar/CmsContainer.tsx
index 2a7fd9b738..c7652198ef 100644
--- a/apps/studio/src/components/CmsSidebar/CmsSidebarContainer.tsx
+++ b/apps/studio/src/components/CmsSidebar/CmsContainer.tsx
@@ -5,24 +5,32 @@ export interface CmsSidebarContainerProps {
children: ReactNode
sidebar: ReactElement
sidenav: ReactElement
+ header: ReactElement
}
-export function CmsSidebarContainer({
+export function CmsContainer({
children,
sidebar,
sidenav,
+ header,
}: CmsSidebarContainerProps) {
return (
+
+ {header}
+
- {children}
+
+ {children}
+
)
diff --git a/apps/studio/src/components/CmsSidebar/CmsSidebar.tsx b/apps/studio/src/components/CmsSidebar/CmsSidebar.tsx
index f5b3586536..cc4bf28936 100644
--- a/apps/studio/src/components/CmsSidebar/CmsSidebar.tsx
+++ b/apps/studio/src/components/CmsSidebar/CmsSidebar.tsx
@@ -1,10 +1,6 @@
-import Image from "next/image"
-import NextLink from "next/link"
import { VStack } from "@chakra-ui/react"
-import { IconButton } from "@opengovsg/design-system-react"
import type { CmsSidebarItem } from "./CmsSidebarItems"
-import { DASHBOARD } from "~/lib/routes"
import CmsSidebarItems from "./CmsSidebarItems"
export interface CmsSidebarProps {
@@ -19,21 +15,6 @@ export function CmsSidebar({
return (
-
- }
- />
diff --git a/apps/studio/src/components/CmsSidebar/index.tsx b/apps/studio/src/components/CmsSidebar/index.tsx
index 91b86249d6..e9f1d3e140 100644
--- a/apps/studio/src/components/CmsSidebar/index.tsx
+++ b/apps/studio/src/components/CmsSidebar/index.tsx
@@ -1,3 +1,3 @@
-export * from "./CmsSidebarContainer"
+export * from "./CmsContainer"
export * from "./CmsSidebar"
export * from "./CmsSidebarOnlyContainer"
diff --git a/apps/studio/src/components/Searchbar/SearchModal.tsx b/apps/studio/src/components/Searchbar/SearchModal.tsx
new file mode 100644
index 0000000000..0dfdec1a15
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/SearchModal.tsx
@@ -0,0 +1,130 @@
+import { useState } from "react"
+import {
+ Modal,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Text,
+} from "@chakra-ui/react"
+import { Searchbar as OgpSearchBar } from "@opengovsg/design-system-react"
+import { useDebounce } from "@uidotdev/usehooks"
+
+import type { SearchResultResource } from "~/server/modules/resource/resource.types"
+import { trpc } from "~/utils/trpc"
+import { isMac } from "./isMac"
+import {
+ InitialState,
+ LoadingState,
+ NoResultsState,
+ SearchResultsState,
+} from "./SearchModalBodyContentStates"
+import { useSearchStyle } from "./useSearchStyle"
+
+interface SearchModalProps {
+ isOpen: boolean
+ onClose: () => void
+ siteId: string
+}
+export const SearchModal = ({ siteId, isOpen, onClose }: SearchModalProps) => {
+ const [searchValue, setSearchValue] = useState("")
+ const debouncedSearchTerm = useDebounce(searchValue, 300)
+ const { data, isLoading } = trpc.resource.search.useInfiniteQuery({
+ siteId,
+ query: debouncedSearchTerm,
+ })
+ const resources: SearchResultResource[] =
+ data?.pages.flatMap((page) => page.resources) ?? []
+
+ const renderModalBody = (): React.ReactNode => {
+ if (!!debouncedSearchTerm) {
+ if (isLoading) {
+ return
+ }
+ if (resources.length === 0) {
+ return
+ }
+ return (
+ acc + (page.totalCount ?? 0),
+ 0,
+ ) ?? 0
+ }
+ searchTerm={debouncedSearchTerm}
+ />
+ )
+ }
+ return (
+
+ )
+ }
+ const { minWidth, maxWidth, marginTop } = useSearchStyle()
+
+ return (
+
+
+
+
+ setSearchValue(target.value)}
+ minW={minWidth}
+ maxW={maxWidth}
+ // border={0}
+ placeholder={`Search pages, collections, or folders by name. e.g. "Speech by Minister"`}
+ />
+
+ {renderModalBody()}
+
+
+ {resources.length === 0
+ ? "Tip: Type in the full title to get the most accurate search results."
+ : "Scroll to see more results. Too many results? Try typing something longer."}
+
+
+ {isMac ? "⌘ + K" : "Ctrl + K"}
+
+
+
+
+ )
+}
diff --git a/apps/studio/src/components/Searchbar/SearchModalBodyContentStates.tsx b/apps/studio/src/components/Searchbar/SearchModalBodyContentStates.tsx
new file mode 100644
index 0000000000..5dd77e981e
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/SearchModalBodyContentStates.tsx
@@ -0,0 +1,148 @@
+import { ModalBody, Text, VStack } from "@chakra-ui/react"
+import { ResourceType } from "~prisma/generated/generatedEnums"
+
+import type { SearchResultProps } from "./SearchResult"
+import type { SearchResultResource } from "~/server/modules/resource/resource.types"
+import { NoSearchResultSvgr } from "../Svg/NoSearchResultSvgr"
+import { SearchResult } from "./SearchResult"
+
+const SearchResults = ({
+ siteId,
+ items,
+ searchTerms,
+ isLoading,
+ shouldHideLastEditedText = false,
+}: Omit & {
+ items: SearchResultResource[]
+ shouldHideLastEditedText?: boolean
+}) => {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+}
+
+const BaseState = ({
+ headerText,
+ content,
+}: {
+ headerText?: string
+ content: React.ReactNode
+}): React.ReactNode => {
+ return (
+
+ {headerText && (
+
+ {headerText}
+
+ )}
+ {content}
+
+ )
+}
+
+export const InitialState = ({
+ siteId,
+ items,
+}: {
+ siteId: string
+ items: SearchResultResource[]
+}) => {
+ return (
+
+ }
+ />
+ )
+}
+
+export const LoadingState = () => {
+ return (
+ ({
+ id: `loading-${index}`,
+ parentId: null,
+ lastUpdatedAt: null,
+ title: `Loading... ${index + 1}`,
+ fullPermalink: "",
+ type: ResourceType.Page,
+ }))}
+ isLoading={true}
+ />
+ }
+ />
+ )
+}
+
+export const SearchResultsState = ({
+ siteId,
+ items,
+ totalResultsCount,
+ searchTerm,
+}: {
+ siteId: string
+ items: SearchResultResource[]
+ totalResultsCount: number
+ searchTerm: string
+}) => {
+ return (
+
+ }
+ />
+ )
+}
+
+export const NoResultsState = () => {
+ return (
+
+
+
+ We’ve looked everywhere, but we’re getting nothing.
+
+ Try searching for something else.
+
+ }
+ />
+ )
+}
diff --git a/apps/studio/src/components/Searchbar/SearchResult.tsx b/apps/studio/src/components/Searchbar/SearchResult.tsx
new file mode 100644
index 0000000000..4fc4215385
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/SearchResult.tsx
@@ -0,0 +1,123 @@
+import { type ReactNode } from "react"
+import { Box, HStack, Icon, Skeleton, Text, VStack } from "@chakra-ui/react"
+
+import type { SearchResultResource } from "~/server/modules/resource/resource.types"
+import { ICON_MAPPINGS } from "~/features/dashboard/components/DirectorySidebar/constants"
+import { formatDate } from "~/utils/formatDate"
+import { getLinkToResource } from "~/utils/resource"
+import { isAllowedToHaveLastEditedText } from "~/utils/resources"
+
+export interface SearchResultProps {
+ siteId: string
+ item: SearchResultResource
+ searchTerms?: string[]
+ isLoading?: boolean
+ shouldHideLastEditedText?: boolean
+}
+
+export const SearchResult = ({
+ siteId,
+ item,
+ searchTerms = [],
+ isLoading = false,
+ shouldHideLastEditedText = false,
+}: SearchResultProps) => {
+ const { id, title, type, fullPermalink, lastUpdatedAt } = item
+ const titleWithHighlightedText: ReactNode[] = title
+ .split(" ")
+ .map((titleWord) => {
+ let matchingSearchTerm: string | null = null
+ for (const searchTerm of searchTerms) {
+ if (titleWord.toLowerCase().startsWith(searchTerm.toLowerCase())) {
+ matchingSearchTerm = searchTerm
+ break
+ }
+ }
+ const highlightedText: string = titleWord.slice(
+ 0,
+ matchingSearchTerm?.length ?? 0,
+ )
+ const nonHighlightedText: string = titleWord.slice(
+ matchingSearchTerm?.length ?? 0,
+ )
+ return (
+
+ {!!highlightedText && (
+
+ {highlightedText}
+
+ )}
+ {!!nonHighlightedText && (
+
+ {nonHighlightedText}
+
+ )}
+
+ )
+ })
+
+ const shouldShowLastEditedText: boolean =
+ !shouldHideLastEditedText && isAllowedToHaveLastEditedText(type)
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ titleWithHighlightedText
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+ `/${fullPermalink}`
+ )}
+
+
+ {!!lastUpdatedAt && shouldShowLastEditedText && (
+
+ {`Last edited ${formatDate(lastUpdatedAt)}`}
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/src/components/Searchbar/Searchbar.tsx b/apps/studio/src/components/Searchbar/Searchbar.tsx
new file mode 100644
index 0000000000..c9d6cab92f
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/Searchbar.tsx
@@ -0,0 +1,98 @@
+import type { ButtonProps } from "@chakra-ui/react"
+import { useEffect } from "react"
+import {
+ Box,
+ chakra,
+ HStack,
+ Icon,
+ Text,
+ useDisclosure,
+ useMultiStyleConfig,
+} from "@chakra-ui/react"
+import { BiSearch } from "react-icons/bi"
+
+import { isMac } from "./isMac"
+import { SearchModal } from "./SearchModal"
+import { useSearchStyle } from "./useSearchStyle"
+
+const SearchButton = (props: ButtonProps) => {
+ const styles = useMultiStyleConfig("Searchbar", {
+ isExpanded: true,
+ size: "md",
+ })
+ const { minWidth, maxWidth } = useSearchStyle()
+
+ return (
+
+
+
+
+
+
+ Search pages, collections, or folders by name. e.g. "Speech by
+ Minister"
+
+
+
+ )
+}
+
+export const Searchbar = ({ siteId }: { siteId: string }) => {
+ const { isOpen, onOpen, onClose } = useDisclosure()
+
+ useEffect(() => {
+ // Trigger the search modal when the user presses
+ // "k" together with the meta key (cmd on mac) or ctrl on windows
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ (isMac && event.key === "k" && event.metaKey) ||
+ (!isMac && event.key === "k" && event.ctrlKey)
+ ) {
+ event.preventDefault()
+ onOpen()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown)
+ }
+ }, [onOpen])
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/apps/studio/src/components/Searchbar/index.ts b/apps/studio/src/components/Searchbar/index.ts
new file mode 100644
index 0000000000..7b08f97eac
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/index.ts
@@ -0,0 +1,2 @@
+export { Searchbar } from "./Searchbar"
+export { useSearchStyle } from "./useSearchStyle"
diff --git a/apps/studio/src/components/Searchbar/isMac.ts b/apps/studio/src/components/Searchbar/isMac.ts
new file mode 100644
index 0000000000..8a7ac0e973
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/isMac.ts
@@ -0,0 +1,3 @@
+export const isMac =
+ typeof window !== "undefined" &&
+ (navigator.userAgent || navigator.platform).toLowerCase().includes("mac")
diff --git a/apps/studio/src/components/Searchbar/useSearchStyle.ts b/apps/studio/src/components/Searchbar/useSearchStyle.ts
new file mode 100644
index 0000000000..328e763078
--- /dev/null
+++ b/apps/studio/src/components/Searchbar/useSearchStyle.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react"
+import { useMediaQuery } from "usehooks-ts"
+
+import { getBannerHeightInPx } from "~/hooks/useBanner"
+
+const topOffsetInPx = 8
+
+export const useSearchStyle = () => {
+ const isDesktop = useMediaQuery("(min-width: 1024px)")
+ const isTablet = useMediaQuery("(min-width: 768px)")
+ const isSmallerThanTablet = !isDesktop && !isTablet
+
+ // Banner height changes from different number of lines of text
+ // and not due to the viewport width
+ const [bannerHeight, setBannerHeight] = useState(getBannerHeightInPx())
+ useEffect(() => {
+ const handleResize = () => {
+ setBannerHeight(getBannerHeightInPx())
+ }
+ window.addEventListener("resize", handleResize)
+ return () => {
+ window.removeEventListener("resize", handleResize)
+ }
+ }, [])
+
+ const minWidth = "30rem"
+ const maxWidth = isDesktop ? "42.5rem" : isTablet ? "35rem" : "30rem"
+ const marginTop = `${bannerHeight + (isSmallerThanTablet ? 0 : topOffsetInPx)}px`
+
+ return { minWidth, maxWidth, marginTop }
+}
diff --git a/apps/studio/src/components/Svg/NoSearchResultSvgr.tsx b/apps/studio/src/components/Svg/NoSearchResultSvgr.tsx
new file mode 100644
index 0000000000..c0bb08e596
--- /dev/null
+++ b/apps/studio/src/components/Svg/NoSearchResultSvgr.tsx
@@ -0,0 +1,125 @@
+import type { SVGProps } from "react"
+import { chakra } from "@chakra-ui/react"
+
+export const NoSearchResultSvgr = chakra((props: SVGProps) => (
+
+))
diff --git a/apps/studio/src/hooks/useBanner.ts b/apps/studio/src/hooks/useBanner.ts
index 936d12faf9..d4eeaa897d 100644
--- a/apps/studio/src/hooks/useBanner.ts
+++ b/apps/studio/src/hooks/useBanner.ts
@@ -3,6 +3,12 @@ import { useFeatureValue } from "@growthbook/growthbook-react"
import { BANNER_FEATURE_KEY } from "~/lib/growthbook"
+export const APP_BANNER_ID = "app-banner"
+
+export const getBannerHeightInPx = (): number => {
+ return document.getElementById(APP_BANNER_ID)?.offsetHeight ?? 0
+}
+
type BannerFeature = Pick & {
message: BannerProps["children"]
}
diff --git a/apps/studio/src/pages/sites/[siteId]/collections/[resourceId].tsx b/apps/studio/src/pages/sites/[siteId]/collections/[resourceId].tsx
index a4d579de2d..ad75646b35 100644
--- a/apps/studio/src/pages/sites/[siteId]/collections/[resourceId].tsx
+++ b/apps/studio/src/pages/sites/[siteId]/collections/[resourceId].tsx
@@ -22,7 +22,7 @@ import { PageSettingsModal } from "~/features/dashboard/components/PageSettingsM
import { CreateCollectionPageModal } from "~/features/editing-experience/components/CreateCollectionPageModal"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type NextPageWithLayout } from "~/lib/types"
-import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
+import { AdminCmsSearchableLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
import { trpc } from "~/utils/trpc"
import { ResourceType } from "../../../../../prisma/generated/generatedEnums"
@@ -118,7 +118,7 @@ CollectionResourceListPage.getLayout = (page) => {
return (
)
}
diff --git a/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx b/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx
index 4a1c285754..1fda7cdc51 100644
--- a/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx
+++ b/apps/studio/src/pages/sites/[siteId]/folders/[folderId]/index.tsx
@@ -27,7 +27,7 @@ import { CreatePageModal } from "~/features/editing-experience/components/Create
import { MoveResourceModal } from "~/features/editing-experience/components/MoveResourceModal"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type NextPageWithLayout } from "~/lib/types"
-import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
+import { AdminCmsSearchableLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
import { trpc } from "~/utils/trpc"
const folderPageSchema = z.object({
@@ -250,7 +250,7 @@ FolderPage.getLayout = (page) => {
return (
)
}
diff --git a/apps/studio/src/pages/sites/[siteId]/index.tsx b/apps/studio/src/pages/sites/[siteId]/index.tsx
index 7f1b21d9e2..2a9fc9dbfb 100644
--- a/apps/studio/src/pages/sites/[siteId]/index.tsx
+++ b/apps/studio/src/pages/sites/[siteId]/index.tsx
@@ -26,7 +26,7 @@ import { MoveResourceModal } from "~/features/editing-experience/components/Move
import { Can } from "~/features/permissions"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type NextPageWithLayout } from "~/lib/types"
-import { AdminCmsSidebarLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
+import { AdminCmsSearchableLayout } from "~/templates/layouts/AdminCmsSidebarLayout"
export const sitePageSchema = z.object({
siteId: z.coerce.number(),
@@ -174,7 +174,7 @@ SitePage.getLayout = (page) => {
return (
)
}
diff --git a/apps/studio/src/schemas/resource.ts b/apps/studio/src/schemas/resource.ts
index f7ad5674b9..a73a40eb79 100644
--- a/apps/studio/src/schemas/resource.ts
+++ b/apps/studio/src/schemas/resource.ts
@@ -1,5 +1,6 @@
import { z } from "zod"
+import type { SearchResultResource } from "../server/modules/resource/resource.types"
import {
infiniteOffsetPaginationSchema,
offsetPaginationSchema,
@@ -70,3 +71,16 @@ export const getAncestrySchema = z.object({
siteId: z.string(),
resourceId: z.string().optional(),
})
+
+export const searchSchema = z
+ .object({
+ siteId: z.string(),
+ query: z.string().optional(),
+ })
+ .merge(infiniteOffsetPaginationSchema)
+
+export const searchOutputSchema = z.object({
+ totalCount: z.number().nullable(),
+ resources: z.array(z.custom()),
+ recentlyEdited: z.array(z.custom()),
+})
diff --git a/apps/studio/src/schemas/site.ts b/apps/studio/src/schemas/site.ts
index 3897a53df1..4de39ef0fb 100644
--- a/apps/studio/src/schemas/site.ts
+++ b/apps/studio/src/schemas/site.ts
@@ -20,3 +20,7 @@ export const setNotificationSchema = z.object({
.max(100, { message: "Notification must be 100 characters or less" }),
notificationEnabled: z.boolean(),
})
+
+export const getNameSchema = z.object({
+ siteId: z.number().min(1),
+})
diff --git a/apps/studio/src/server/modules/resource/__tests__/resource.router.test.ts b/apps/studio/src/server/modules/resource/__tests__/resource.router.test.ts
index a3265cc0ef..36009a8920 100644
--- a/apps/studio/src/server/modules/resource/__tests__/resource.router.test.ts
+++ b/apps/studio/src/server/modules/resource/__tests__/resource.router.test.ts
@@ -7,7 +7,11 @@ import {
} from "tests/integration/helpers/iron-session"
import {
setupAdminPermissions,
+ setupBlob,
+ setupCollection,
+ setupCollectionLink,
setupFolder,
+ setupFolderMeta,
setupPageResource,
setupSite,
} from "tests/integration/helpers/seed"
@@ -1778,4 +1782,988 @@ describe("resource.router", async () => {
it.skip("should throw 403 if user does not have read access to the resource", async () => {})
})
+
+ describe("search", () => {
+ const RESOURCE_FIELDS_TO_PICK = [
+ "id",
+ "title",
+ "type",
+ "parentId",
+ "fullPermalink",
+ ] as const
+
+ it("should throw 401 if not logged in", async () => {
+ const unauthedSession = applySession()
+ const unauthedCaller = createCaller(createMockRequest(unauthedSession))
+
+ const result = unauthedCaller.search({
+ siteId: "1",
+ query: "test",
+ })
+
+ await expect(result).rejects.toThrowError(
+ new TRPCError({ code: "UNAUTHORIZED" }),
+ )
+ })
+
+ it("should throw 403 if user does not have read access to site", async () => {
+ // Arrange
+ const { site } = await setupSite()
+
+ // Act
+ const result = caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ await expect(result).rejects.toThrowError(
+ new TRPCError({
+ code: "FORBIDDEN",
+ message:
+ "You do not have sufficient permissions to perform this action",
+ }),
+ )
+ })
+
+ it.skip("should throw 403 if user does not have read access to resource", async () => {})
+
+ it("should return empty results if no resources exist", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 0,
+ resources: [],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the full permalink of resources", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { folder: folder1 } = await setupFolder({
+ siteId: site.id,
+ })
+ const { folder: folder2 } = await setupFolder({
+ siteId: site.id,
+ parentId: folder1.id,
+ })
+ const { page } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ parentId: folder2.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 3,
+ resources: [
+ {
+ ...pick(page, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${folder1.permalink}/${folder2.permalink}/${page.permalink}`,
+ lastUpdatedAt: page.updatedAt,
+ },
+ {
+ ...pick(folder2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${folder1.permalink}/${folder2.permalink}`,
+ lastUpdatedAt: folder2.updatedAt,
+ },
+ {
+ ...pick(folder1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${folder1.permalink}`,
+ lastUpdatedAt: folder1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should use the draft blob updatedAt datetime if available", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const blob = await setupBlob()
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ permalink: "page-1",
+ blobId: blob.id,
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ permalink: "page-2",
+ })
+ const updatedBlob = await db
+ .updateTable("Blob")
+ .set({ updatedAt: new Date() })
+ .where("id", "=", blob.id)
+ .returningAll()
+ .executeTakeFirstOrThrow()
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 2,
+ resources: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: updatedBlob.updatedAt,
+ },
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return totalCount as a number", async () => {
+ // Arrange
+ const numberOfPages = 15 // arbitrary number above the default limit of 10
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ for (let index = 0; index < numberOfPages; index++) {
+ await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: `page-${index + 1}`,
+ })
+ }
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ expect(result.totalCount).toEqual(numberOfPages)
+ })
+
+ it("should return recentlyEdited as an empty array", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({ resourceType: "Page", siteId: site.id })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ expect(result.recentlyEdited).toEqual([])
+ })
+
+ it("should match and order by splitting the query into an array of search terms", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "apple banana cherry durian", // should match 3 terms
+ permalink: "apple-banana-cherry-durian",
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "apple banana cherry", // should match 2 terms
+ permalink: "apple-banana-cherry",
+ })
+ const { page: page3 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "banana", // should match 1 term
+ permalink: "banana",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "apple banana durian",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 3,
+ resources: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ {
+ ...pick(page3, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page3.permalink}`,
+ lastUpdatedAt: page3.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return resources in order of most recently updated if same search terms", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ permalink: "page-1",
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ permalink: "page-2",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 2,
+ resources: [
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return resources that by prefix for each word in the title", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "shouldnotmatch",
+ permalink: "shouldnotmatch",
+ })
+ const { page } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "match",
+ permalink: "match",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "match",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 1,
+ resources: [
+ {
+ ...pick(page, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page.permalink}`,
+ lastUpdatedAt: page.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should rank results by not double counting ranking order for each search term", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "banana banana apple",
+ permalink: "banana-banana-apple",
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "banana apple",
+ permalink: "banana-apple",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "banana apple",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 2,
+ resources: [
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should rank results by character length of search term matches", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "looooongword",
+ permalink: "looooongword",
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "shortword",
+ permalink: "shortword",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "shortword looooongword",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 2,
+ resources: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should not return resources that do not match the search query", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "whatever",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 0,
+ resources: [],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should not return resources matched by empty space if query terms are separated by spaces", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "test",
+ permalink: "test",
+ })
+ await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "something else",
+ permalink: "something-else",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 1,
+ resources: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should only return user viewable resource types", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { collection: collection1 } = await setupCollection({
+ siteId: site.id,
+ })
+ const { folder: folder1 } = await setupFolder({ siteId: site.id })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ })
+ const { page: collectionPage } = await setupPageResource({
+ resourceType: "CollectionPage",
+ siteId: site.id,
+ })
+ const { collectionLink } = await setupCollectionLink({
+ siteId: site.id,
+ collectionId: collection1.id,
+ })
+ await setupPageResource({ resourceType: "IndexPage", siteId: site.id })
+ await setupFolderMeta({ siteId: site.id, folderId: folder1.id })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 5,
+ resources: [
+ {
+ ...pick(collectionLink, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${collection1.permalink}/${collectionLink.permalink}`,
+ lastUpdatedAt: collectionLink.updatedAt,
+ },
+ {
+ ...pick(collectionPage, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${collectionPage.permalink}`,
+ lastUpdatedAt: collectionPage.updatedAt,
+ },
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ {
+ ...pick(folder1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${folder1.permalink}`,
+ lastUpdatedAt: folder1.updatedAt,
+ },
+ {
+ ...pick(collection1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${collection1.permalink}`,
+ lastUpdatedAt: collection1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should not return resources from another site", async () => {
+ // Arrange
+ const { site: site1 } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site1.id,
+ })
+ const { site: site2 } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site2.id,
+ })
+ await setupPageResource({ resourceType: "Page", siteId: site1.id })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site2.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 0,
+ resources: [],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct values if query is empty string", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct values if query is a string of whitespaces", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: " ",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the correct values if query is not provided", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ })
+
+ // Assert
+ const expected = {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("recentlyEdited should be ordered by lastUpdatedAt", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "page 1",
+ permalink: "page-1",
+ })
+ const { page: page2 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ title: "page 2",
+ permalink: "page-2",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ })
+
+ // Assert
+ const expected = {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: [
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("recentlyEdited should only return page-ish resources", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({ resourceType: "RootPage", siteId: site.id })
+ const { folder: folder1 } = await setupFolder({ siteId: site.id })
+ await setupFolderMeta({ siteId: site.id, folderId: folder1.id })
+ await setupCollection({ siteId: site.id })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ })
+
+ // Assert
+ const expected = {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ describe("limit", () => {
+ it("should return up to 10 most recently edited resources if no limit is provided", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const pages = []
+ for (let index = 0; index < 11; index++) {
+ pages.push(
+ await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: `page-${index + 1}`,
+ }),
+ )
+ }
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 11,
+ resources: pages
+ .reverse()
+ .slice(0, 10)
+ .map((page) => {
+ const { page: pageX } = page
+ return {
+ ...pick(pageX, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${pageX.permalink}`,
+ lastUpdatedAt: pageX.updatedAt,
+ }
+ }),
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return limit number of resources according to the the `limit` parameter", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: "page-1",
+ })
+ const { page: page2 } = await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: "page-2",
+ })
+ const { page: page3 } = await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: "page-3",
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ limit: 2,
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 3,
+ resources: [
+ {
+ ...pick(page3, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page3.permalink}`,
+ lastUpdatedAt: page3.updatedAt,
+ },
+ {
+ ...pick(page2, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page2.permalink}`,
+ lastUpdatedAt: page2.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return all items if limit is greater than the number of items", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const { page: page1 } = await setupPageResource({
+ resourceType: "Page",
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ limit: 2,
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 1,
+ resources: [
+ {
+ ...pick(page1, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${page1.permalink}`,
+ lastUpdatedAt: page1.updatedAt,
+ },
+ ],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+ })
+
+ describe("cursor", () => {
+ it("should return empty results if `cursor` is invalid", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ await setupPageResource({ resourceType: "Page", siteId: site.id })
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ cursor: 600,
+ })
+
+ const expected = {
+ totalCount: 1,
+ resources: [],
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+
+ it("should return the next set of resources if valid `cursor` is provided", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+ const pages = []
+ for (let index = 0; index < 31; index++) {
+ pages.push(
+ await setupPageResource({
+ siteId: site.id,
+ resourceType: "Page",
+ permalink: `page-${index + 1}`,
+ }),
+ )
+ }
+
+ // Act
+ const result = await caller.search({
+ siteId: String(site.id),
+ query: "test",
+ cursor: 10,
+ })
+
+ // Assert
+ const expected = {
+ totalCount: 31,
+ resources: pages
+ .reverse()
+ .slice(10, 20)
+ .map((page) => {
+ const { page: pageX } = page
+ return {
+ ...pick(pageX, RESOURCE_FIELDS_TO_PICK),
+ fullPermalink: `${pageX.permalink}`,
+ lastUpdatedAt: pageX.updatedAt,
+ }
+ }),
+ recentlyEdited: [],
+ }
+ expect(result).toEqual(expected)
+ })
+ })
+ })
})
diff --git a/apps/studio/src/server/modules/resource/resource.router.ts b/apps/studio/src/server/modules/resource/resource.router.ts
index a7d86c4295..a7468ae1d0 100644
--- a/apps/studio/src/server/modules/resource/resource.router.ts
+++ b/apps/studio/src/server/modules/resource/resource.router.ts
@@ -14,15 +14,23 @@ import {
getParentSchema,
listResourceSchema,
moveSchema,
+ searchOutputSchema,
+ searchSchema,
} from "~/schemas/resource"
import { protectedProcedure, router } from "~/server/trpc"
import { publishSite } from "../aws/codebuild.service"
-import { db, ResourceType, sql } from "../database"
+import { db, ResourceType } from "../database"
import { PG_ERROR_CODES } from "../database/constants"
import {
definePermissionsForResource,
validateUserPermissionsForResource,
} from "../permissions/permissions.service"
+import { validateUserPermissionsForSite } from "../site/site.service"
+import {
+ getSearchRecentlyEdited,
+ getSearchResults,
+ getWithFullPermalink,
+} from "./resource.service"
const fetchResource = async (resourceId: string | null) => {
if (resourceId === null) return { parentId: null }
@@ -434,37 +442,7 @@ export const resourceRouter = router({
getWithFullPermalink: protectedProcedure
.input(getFullPermalinkSchema)
.query(async ({ input: { resourceId } }) => {
- const result = await db
- .withRecursive("resourcePath", (eb) =>
- eb
- .selectFrom("Resource as r")
- .select([
- "r.id",
- "r.title",
- "r.permalink",
- "r.parentId",
- "r.permalink as fullPermalink",
- ])
- .where("r.parentId", "is", null)
- .unionAll(
- eb
- .selectFrom("Resource as s")
- .innerJoin("resourcePath as rp", "s.parentId", "rp.id")
- .select([
- "s.id",
- "s.title",
- "s.permalink",
- "s.parentId",
- sql`CONCAT(rp."fullPermalink", '/', s.permalink)`.as(
- "fullPermalink",
- ),
- ]),
- ),
- )
- .selectFrom("resourcePath as rp")
- .select(["rp.id", "rp.title", "rp.fullPermalink"])
- .where("rp.id", "=", resourceId)
- .executeTakeFirst()
+ const result = await getWithFullPermalink({ resourceId })
if (!result) {
throw new TRPCError({ code: "NOT_FOUND" })
@@ -535,4 +513,40 @@ export const resourceRouter = router({
return ancestors.reverse().slice(0, -1)
}),
+
+ search: protectedProcedure
+ .input(searchSchema)
+ .output(searchOutputSchema)
+ .query(
+ async ({ ctx, input: { siteId, query = "", cursor: offset, limit } }) => {
+ await validateUserPermissionsForSite({
+ siteId: Number(siteId),
+ userId: ctx.user.id,
+ action: "read",
+ })
+
+ // check if the query is only whitespaces (including multiple spaces)
+ if (query.trim() === "") {
+ return {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: await getSearchRecentlyEdited({
+ siteId: Number(siteId),
+ }),
+ }
+ }
+
+ const searchResults = await getSearchResults({
+ siteId: Number(siteId),
+ query,
+ offset,
+ limit,
+ })
+ return {
+ totalCount: Number(searchResults.totalCount),
+ resources: searchResults.resources,
+ recentlyEdited: [],
+ }
+ },
+ ),
})
diff --git a/apps/studio/src/server/modules/resource/resource.service.ts b/apps/studio/src/server/modules/resource/resource.service.ts
index 33dff13cdb..0e67ea3efa 100644
--- a/apps/studio/src/server/modules/resource/resource.service.ts
+++ b/apps/studio/src/server/modules/resource/resource.service.ts
@@ -5,10 +5,11 @@ import { TRPCError } from "@trpc/server"
import { type DB } from "~prisma/generated/generatedTypes"
import type { Resource, SafeKysely, Transaction } from "../database"
+import type { SearchResultResource } from "./resource.types"
import { INDEX_PAGE_PERMALINK } from "~/constants/sitemap"
import { getSitemapTree } from "~/utils/sitemap"
import { publishSite } from "../aws/codebuild.service"
-import { db, jsonb, ResourceType } from "../database"
+import { db, jsonb, ResourceType, sql } from "../database"
import { incrementVersion } from "../version/version.service"
import { type Page } from "./resource.types"
@@ -409,3 +410,189 @@ export const publishResource = async (
return addedVersionResult
}
+
+export const getWithFullPermalink = async ({
+ resourceId,
+}: {
+ resourceId: string
+}) => {
+ const result = await db
+ .withRecursive("resourcePath", (eb) =>
+ eb
+ .selectFrom("Resource as r")
+ .select([
+ "r.id",
+ "r.title",
+ "r.permalink",
+ "r.parentId",
+ "r.permalink as fullPermalink",
+ ])
+ .where("r.parentId", "is", null)
+ .unionAll(
+ eb
+ .selectFrom("Resource as s")
+ .innerJoin("resourcePath as rp", "s.parentId", "rp.id")
+ .select([
+ "s.id",
+ "s.title",
+ "s.permalink",
+ "s.parentId",
+ sql`CONCAT(rp."fullPermalink", '/', s.permalink)`.as(
+ "fullPermalink",
+ ),
+ ]),
+ ),
+ )
+ .selectFrom("resourcePath as rp")
+ .select(["rp.id", "rp.title", "rp.fullPermalink"])
+ .where("rp.id", "=", resourceId)
+ .executeTakeFirst()
+
+ return result
+}
+
+const getResourcesWithLastUpdatedAt = ({ siteId }: { siteId: number }) => {
+ return db
+ .selectFrom("Resource")
+ .select([
+ "Resource.id",
+ "Resource.title",
+ "Resource.type",
+ "Resource.parentId",
+ // To handle cases where either the resource or the blob is updated
+ sql`GREATEST("Resource"."updatedAt", "Blob"."updatedAt")`.as(
+ "lastUpdatedAt",
+ ),
+ ])
+ .leftJoin("Blob", "Resource.draftBlobId", "Blob.id")
+ .where("Resource.siteId", "=", siteId)
+}
+
+const getResourcesWithFullPermalink = async ({
+ resources,
+}: {
+ resources: Omit[]
+}): Promise => {
+ return await Promise.all(
+ resources.map(async (resource) => ({
+ ...resource,
+ fullPermalink: await getWithFullPermalink({
+ resourceId: resource.id,
+ }).then((r) => r?.fullPermalink ?? ""),
+ })),
+ )
+}
+
+export const getSearchResults = async ({
+ siteId,
+ query,
+ offset,
+ limit,
+}: {
+ siteId: number
+ query: string
+ offset: number
+ limit: number
+}): Promise<{
+ totalCount: number | null
+ resources: SearchResultResource[]
+}> => {
+ const searchTerms: string[] = Array.from(
+ new Set(query.trim().toLowerCase().split(/\s+/)),
+ )
+
+ const queriedResources = getResourcesWithLastUpdatedAt({
+ siteId: Number(siteId),
+ })
+ .where("Resource.type", "in", [
+ // only show user-viewable resources (excluding root page, folder meta etc.)
+ ResourceType.Page,
+ ResourceType.Folder,
+ ResourceType.Collection,
+ ResourceType.CollectionLink,
+ ResourceType.CollectionPage,
+ ])
+ .where((eb) =>
+ eb.or(
+ searchTerms.map((searchTerm) =>
+ // Match if the search term is at the start of the title
+ eb("Resource.title", "ilike", `${searchTerm}%`).or(
+ // Match if the search term is in the middle of the title (after a space)
+ eb("Resource.title", "ilike", `% ${searchTerm}%`),
+ ),
+ ),
+ ),
+ )
+
+ // Currently ordered by number of words matched
+ // followed by `lastUpdatedAt` if there's a tie-break
+ let orderedResources = queriedResources
+ if (searchTerms.length > 1) {
+ orderedResources = orderedResources.orderBy(
+ sql`(
+ ${sql.join(
+ searchTerms.map(
+ (searchTerm) =>
+ // 1. Match if the search term is at the start of the title
+ // 2. Match if the search term is in the middle of the title (after a space)
+ sql`
+ CASE
+ WHEN (
+ "Resource"."title" ILIKE ${searchTerm + "%"} OR
+ "Resource"."title" ILIKE ${"% " + searchTerm + "%"}
+ )
+ THEN ${searchTerm.length}
+ ELSE 0
+ END
+ `,
+ ),
+ sql` + `,
+ )}
+ ) DESC`,
+ )
+ }
+ orderedResources = orderedResources.orderBy("lastUpdatedAt", "desc")
+
+ const resourcesToReturn: SearchResultResource[] = (await orderedResources
+ .offset(offset)
+ .limit(limit)
+ .execute()) as SearchResultResource[]
+
+ const totalCount: number = (
+ await db
+ .with("queriedResources", () => queriedResources)
+ .selectFrom("queriedResources")
+ .select(db.fn.countAll().as("total_count"))
+ .executeTakeFirstOrThrow()
+ ).total_count as number // needed to cast as the type can be `bigint`
+
+ return {
+ totalCount,
+ resources: await getResourcesWithFullPermalink({
+ resources: resourcesToReturn,
+ }),
+ }
+}
+
+export const getSearchRecentlyEdited = async ({
+ siteId,
+ limit = 5, // Hardcoded for now to be 5
+}: {
+ siteId: number
+ limit?: number
+}): Promise => {
+ return await getResourcesWithFullPermalink({
+ resources: (await getResourcesWithLastUpdatedAt({
+ siteId: Number(siteId),
+ })
+ .where("Resource.type", "in", [
+ // only show page-ish resources
+ ResourceType.Page,
+ ResourceType.CollectionLink,
+ ResourceType.CollectionPage,
+ ])
+ .limit(limit)
+ .orderBy("lastUpdatedAt", "desc")
+ .execute()) as SearchResultResource[],
+ })
+}
diff --git a/apps/studio/src/server/modules/resource/resource.types.ts b/apps/studio/src/server/modules/resource/resource.types.ts
index c8d0549f96..500ad8353f 100644
--- a/apps/studio/src/server/modules/resource/resource.types.ts
+++ b/apps/studio/src/server/modules/resource/resource.types.ts
@@ -3,7 +3,7 @@ import {
type IsomerSiteProps,
} from "@opengovsg/isomer-components"
-import type { Resource } from "~server/db"
+import type { Resource, ResourceType } from "~server/db"
export type PageContent = Omit<
IsomerPageSchemaType,
@@ -17,3 +17,12 @@ export interface Navbar {
}
export type Footer = IsomerSiteProps["footerItems"]
+
+export interface SearchResultResource {
+ id: string
+ title: string
+ type: ResourceType
+ parentId: string | null
+ lastUpdatedAt: Date | null
+ fullPermalink: string
+}
diff --git a/apps/studio/src/server/modules/site/__tests__/site.router.test.ts b/apps/studio/src/server/modules/site/__tests__/site.router.test.ts
index 305debbd6f..31ee44b986 100644
--- a/apps/studio/src/server/modules/site/__tests__/site.router.test.ts
+++ b/apps/studio/src/server/modules/site/__tests__/site.router.test.ts
@@ -129,4 +129,37 @@ describe("site.router", async () => {
])
})
})
+
+ describe("getSiteName", () => {
+ it("should throw 401 if not logged in", async () => {
+ // Arrange
+ const unauthedSession = applySession()
+ const unauthedCaller = createCaller(createMockRequest(unauthedSession))
+
+ // Act
+ const result = unauthedCaller.getSiteName({ siteId: 1 })
+
+ // Assert
+ await expect(result).rejects.toThrowError(
+ new TRPCError({ code: "UNAUTHORIZED" }),
+ )
+ })
+
+ it("should return the site name", async () => {
+ // Arrange
+ const { site } = await setupSite()
+ await setupAdminPermissions({
+ userId: session.userId,
+ siteId: site.id,
+ })
+
+ // Act
+ const result = await caller.getSiteName({ siteId: site.id })
+
+ // Assert
+ expect(result).toEqual({ name: site.name })
+ })
+
+ it.skip("should throw 403 if user does not have read access to the site", async () => {})
+ })
})
diff --git a/apps/studio/src/server/modules/site/site.router.ts b/apps/studio/src/server/modules/site/site.router.ts
index 769c9ea488..5cac5f16cc 100644
--- a/apps/studio/src/server/modules/site/site.router.ts
+++ b/apps/studio/src/server/modules/site/site.router.ts
@@ -1,19 +1,13 @@
-import { TRPCError } from "@trpc/server"
-
-import type {
- CrudResourceActions,
- PermissionsProps,
-} from "../permissions/permissions.type"
import {
getConfigSchema,
getLocalisedSitemapSchema,
+ getNameSchema,
getNotificationSchema,
setNotificationSchema,
} from "~/schemas/site"
import { protectedProcedure, router } from "~/server/trpc"
import { publishSite } from "../aws/codebuild.service"
import { db } from "../database"
-import { definePermissionsForSite } from "../permissions/permissions.service"
import {
getFooter,
getLocalisedSitemap,
@@ -25,27 +19,9 @@ import {
getSiteConfig,
getSiteTheme,
setSiteNotification,
+ validateUserPermissionsForSite,
} from "./site.service"
-const validateUserPermissionsForSite = async ({
- siteId,
- userId,
- action,
-}: Omit & { action: CrudResourceActions }) => {
- const perms = await definePermissionsForSite({
- siteId,
- userId,
- })
-
- // TODO: create should check against the current resource id
- if (perms.cannot(action, "Site")) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "You do not have sufficient permissions to perform this action",
- })
- }
-}
-
export const siteRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
// NOTE: Any role should be able to read site
@@ -57,6 +33,21 @@ export const siteRouter = router({
.groupBy(["Site.id", "Site.name", "Site.config"])
.execute()
}),
+ getSiteName: protectedProcedure
+ .input(getNameSchema)
+ .query(async ({ ctx, input: { siteId } }) => {
+ await validateUserPermissionsForSite({
+ siteId,
+ userId: ctx.user.id,
+ action: "read",
+ })
+
+ return db
+ .selectFrom("Site")
+ .where("Site.id", "=", siteId)
+ .select("name")
+ .executeTakeFirstOrThrow()
+ }),
getConfig: protectedProcedure
.input(getConfigSchema)
.query(async ({ ctx, input }) => {
diff --git a/apps/studio/src/server/modules/site/site.service.ts b/apps/studio/src/server/modules/site/site.service.ts
index eb5e81f0a7..a0ceed2649 100644
--- a/apps/studio/src/server/modules/site/site.service.ts
+++ b/apps/studio/src/server/modules/site/site.service.ts
@@ -1,7 +1,31 @@
import { type IsomerSiteConfigProps } from "@opengovsg/isomer-components"
import { TRPCError } from "@trpc/server"
+import type {
+ CrudResourceActions,
+ PermissionsProps,
+} from "../permissions/permissions.type"
import { db, jsonb, sql } from "../database"
+import { definePermissionsForSite } from "../permissions/permissions.service"
+
+export const validateUserPermissionsForSite = async ({
+ siteId,
+ userId,
+ action,
+}: Omit & { action: CrudResourceActions }) => {
+ const perms = await definePermissionsForSite({
+ siteId,
+ userId,
+ })
+
+ // TODO: create should check against the current resource id
+ if (perms.cannot(action, "Site")) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You do not have sufficient permissions to perform this action",
+ })
+ }
+}
export const getSiteConfig = async (siteId: number) => {
const { config } = await db
diff --git a/apps/studio/src/stories/Flows/CreateCollectionItemFlow.stories.tsx b/apps/studio/src/stories/Flows/CreateCollectionItemFlow.stories.tsx
index 365eaae38d..0439cde567 100644
--- a/apps/studio/src/stories/Flows/CreateCollectionItemFlow.stories.tsx
+++ b/apps/studio/src/stories/Flows/CreateCollectionItemFlow.stories.tsx
@@ -25,6 +25,7 @@ const meta: Meta = {
sitesHandlers.getFooter.default(),
sitesHandlers.getNavbar.default(),
sitesHandlers.getLocalisedSitemap.default(),
+ sitesHandlers.getSiteName.default(),
resourceHandlers.getRolesFor.default(),
resourceHandlers.getChildrenOf.default(),
resourceHandlers.getMetadataById.article(),
diff --git a/apps/studio/src/stories/Flows/CreateNewPageFlow.stories.tsx b/apps/studio/src/stories/Flows/CreateNewPageFlow.stories.tsx
index 8323384176..8cc3c4e48b 100644
--- a/apps/studio/src/stories/Flows/CreateNewPageFlow.stories.tsx
+++ b/apps/studio/src/stories/Flows/CreateNewPageFlow.stories.tsx
@@ -21,6 +21,7 @@ const meta: Meta = {
sitesHandlers.getTheme.default(),
sitesHandlers.getFooter.default(),
sitesHandlers.getNavbar.default(),
+ sitesHandlers.getSiteName.default(),
sitesHandlers.getLocalisedSitemap.default(),
resourceHandlers.getChildrenOf.default(),
resourceHandlers.getRolesFor.default(),
diff --git a/apps/studio/src/stories/Page/Search.stories.tsx b/apps/studio/src/stories/Page/Search.stories.tsx
new file mode 100644
index 0000000000..968976da25
--- /dev/null
+++ b/apps/studio/src/stories/Page/Search.stories.tsx
@@ -0,0 +1,112 @@
+import type { Meta, StoryObj } from "@storybook/react"
+import { userEvent, within } from "@storybook/test"
+import { meHandlers } from "tests/msw/handlers/me"
+import { pageHandlers } from "tests/msw/handlers/page"
+import { resourceHandlers } from "tests/msw/handlers/resource"
+import { sitesHandlers } from "tests/msw/handlers/sites"
+
+import SitePage from "~/pages/sites/[siteId]"
+
+const COMMON_HANDLERS = [
+ meHandlers.me(),
+ pageHandlers.listWithoutRoot.default(),
+ pageHandlers.getRootPage.default(),
+ pageHandlers.countWithoutRoot.default(),
+ pageHandlers.readPage.content(),
+ pageHandlers.updateSettings.collection(),
+ pageHandlers.getPermalinkTree.withParent(),
+ sitesHandlers.getSiteName.default(),
+ resourceHandlers.getChildrenOf.default(),
+ resourceHandlers.getRolesFor.default(),
+]
+
+const meta: Meta = {
+ title: "Pages/Site Management/Search",
+ component: SitePage,
+ parameters: {
+ getLayout: SitePage.getLayout,
+ nextjs: {
+ router: {
+ query: {
+ siteId: "1",
+ },
+ },
+ },
+ },
+ decorators: [],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Initial: Story = {
+ parameters: {
+ msw: {
+ handlers: [...COMMON_HANDLERS, resourceHandlers.search.initial()],
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const searchButton = await screen.findByRole("button", {
+ name: "search-button",
+ })
+ await userEvent.click(searchButton)
+ },
+}
+
+export const Results: Story = {
+ parameters: {
+ msw: {
+ handlers: [...COMMON_HANDLERS, resourceHandlers.search.results()],
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const searchButton = await screen.findByRole("button", {
+ name: "search-button",
+ })
+ await userEvent.click(searchButton)
+ await userEvent.keyboard("covid test")
+ },
+}
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [...COMMON_HANDLERS, resourceHandlers.search.loading()],
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const searchButton = await screen.findByRole("button", {
+ name: "search-button",
+ })
+ await userEvent.click(searchButton)
+ await userEvent.keyboard("covid test")
+ },
+}
+
+export const NoResults: Story = {
+ parameters: {
+ msw: {
+ handlers: [...COMMON_HANDLERS, resourceHandlers.search.initial()],
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const searchButton = await screen.findByRole("button", {
+ name: "search-button",
+ })
+ await userEvent.click(searchButton)
+ await userEvent.keyboard("fwnjebjesnlckgebjeb")
+ },
+}
+
+// Commented out for now because of https://github.com/storybookjs/storybook/issues/25815
+// export const ModalOpenOnShortcut: Story = {
+// play: async ({ canvasElement }) => {
+// const canvas = within(canvasElement)
+// await userEvent.keyboard("{Meta>}{k}{/Meta}") // For Mac
+// await userEvent.keyboard("{Control>}{k}{/Control}") // For non-Mac
+// },
+// }
diff --git a/apps/studio/src/stories/Page/SitePage.stories.tsx b/apps/studio/src/stories/Page/SitePage.stories.tsx
index a7ea4bd2e6..e276b85f0f 100644
--- a/apps/studio/src/stories/Page/SitePage.stories.tsx
+++ b/apps/studio/src/stories/Page/SitePage.stories.tsx
@@ -3,6 +3,7 @@ import { userEvent, waitFor, within } from "@storybook/test"
import { meHandlers } from "tests/msw/handlers/me"
import { pageHandlers } from "tests/msw/handlers/page"
import { resourceHandlers } from "tests/msw/handlers/resource"
+import { sitesHandlers } from "tests/msw/handlers/sites"
import SitePage from "~/pages/sites/[siteId]"
import { createBannerGbParameters } from "../utils/growthbook"
@@ -21,6 +22,7 @@ const meta: Meta = {
pageHandlers.readPage.content(),
pageHandlers.updateSettings.collection(),
pageHandlers.getPermalinkTree.withParent(),
+ sitesHandlers.getSiteName.default(),
resourceHandlers.getChildrenOf.default(),
resourceHandlers.getRolesFor.default(),
],
diff --git a/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx b/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
index 186c851e2c..81a1694c96 100644
--- a/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
+++ b/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
@@ -1,19 +1,82 @@
import type { PropsWithChildren } from "react"
+import Image from "next/image"
+import NextLink from "next/link"
import { useRouter } from "next/router"
-import { Flex } from "@chakra-ui/react"
+import { Flex, Grid, GridItem, Text } from "@chakra-ui/react"
+import { AvatarMenu, IconButton, Menu } from "@opengovsg/design-system-react"
import { BiCog, BiFolder, BiHelpCircle, BiLogOut } from "react-icons/bi"
import { z } from "zod"
import type { CmsSidebarItem } from "~/components/CmsSidebar/CmsSidebarItems"
import { EnforceLoginStatePageWrapper } from "~/components/AuthWrappers"
-import { CmsSidebar, CmsSidebarContainer } from "~/components/CmsSidebar"
+import { CmsContainer, CmsSidebar } from "~/components/CmsSidebar"
import { LayoutHead } from "~/components/LayoutHead"
+import { Searchbar, useSearchStyle } from "~/components/Searchbar"
import { DirectorySidebar } from "~/features/dashboard/components/DirectorySidebar"
import { useMe } from "~/features/me/api"
import { useQueryParse } from "~/hooks/useQueryParse"
+import { DASHBOARD } from "~/lib/routes"
import { type GetLayout } from "~/lib/types"
+import { trpc } from "~/utils/trpc"
-export const AdminCmsSidebarLayout: GetLayout = (page) => {
+interface SearchableHeaderProps {
+ siteId: string
+}
+const SearchableHeader = ({ siteId }: SearchableHeaderProps) => {
+ const { me, logout } = useMe()
+ const [{ name }] = trpc.site.getSiteName.useSuspenseQuery({
+ siteId: Number(siteId),
+ })
+ const { minWidth, maxWidth } = useSearchStyle()
+
+ return (
+
+
+
+ }
+ />
+
+ {name}
+
+
+ {/* NOTE: We are doing this because the searchbar has to be horizontally centered within the Flex */}
+
+
+
+
+
+
+ logout()}>Sign out
+
+
+
+ )
+}
+
+export const AdminCmsSearchableLayout: GetLayout = (page) => {
return (
@@ -70,13 +133,14 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
]
return (
-
}
sidenav={}
+ header={}
>
{children}
-
+
)
}
diff --git a/apps/studio/src/theme/components/Searchbar.ts b/apps/studio/src/theme/components/Searchbar.ts
new file mode 100644
index 0000000000..6c032e532e
--- /dev/null
+++ b/apps/studio/src/theme/components/Searchbar.ts
@@ -0,0 +1,17 @@
+import { createMultiStyleConfigHelpers } from "@chakra-ui/styled-system"
+import { anatomy } from "@chakra-ui/theme-tools"
+
+import { textStyles } from "../generated/textStyles"
+
+const parts = anatomy("searchbar").parts("icon", "field")
+const { defineMultiStyleConfig } = createMultiStyleConfigHelpers(parts.keys)
+
+export const Searchbar = defineMultiStyleConfig({
+ variants: {
+ outline: {
+ field: {
+ ...textStyles["body-2"],
+ },
+ },
+ },
+})
diff --git a/apps/studio/src/theme/components/index.ts b/apps/studio/src/theme/components/index.ts
index b9f58dfd9f..d73630fc66 100644
--- a/apps/studio/src/theme/components/index.ts
+++ b/apps/studio/src/theme/components/index.ts
@@ -1,7 +1,9 @@
import { Infobox } from "./Infobox"
+import { Searchbar } from "./Searchbar"
import { Table } from "./Table"
export const components = {
Table,
Infobox,
+ Searchbar,
}
diff --git a/apps/studio/src/utils/formatDate.ts b/apps/studio/src/utils/formatDate.ts
new file mode 100644
index 0000000000..0c77922d7d
--- /dev/null
+++ b/apps/studio/src/utils/formatDate.ts
@@ -0,0 +1,35 @@
+const getDiffInDays = (date: Date): number => {
+ const now = new Date()
+ const diffInMs = now.getTime() - date.getTime()
+ return Math.floor(diffInMs / (1000 * 60 * 60 * 24))
+}
+
+const displayDateInDDMMMYYYY = (date: Date): string => {
+ const options: Intl.DateTimeFormatOptions = {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }
+ return date
+ .toLocaleDateString("en-GB", options)
+ .replace(/(\d{2}) (\w{3}) (\d{4})/, "$1 $2 $3")
+}
+
+export const formatDate = (date: Date): string => {
+ const diffInDays: number = getDiffInDays(date)
+
+ if (diffInDays === 0) {
+ return "today"
+ }
+ if (diffInDays === 1) {
+ return "yesterday"
+ }
+ if (diffInDays >= 2 && diffInDays <= 6) {
+ return `${diffInDays} days ago`
+ }
+ if (diffInDays >= 7 && diffInDays <= 14) {
+ return "last week"
+ }
+ // Format date as "DD MMM YYYY" for anything beyond 14 days
+ return displayDateInDDMMMYYYY(date)
+}
diff --git a/apps/studio/src/utils/resources.ts b/apps/studio/src/utils/resources.ts
index 0ed1d385ff..152dfe15a3 100644
--- a/apps/studio/src/utils/resources.ts
+++ b/apps/studio/src/utils/resources.ts
@@ -9,3 +9,13 @@ export const isAllowedToHaveChildren = (
resourceType === ResourceType.RootPage
)
}
+
+export const isAllowedToHaveLastEditedText = (
+ resourceType: ResourceType,
+): boolean => {
+ return (
+ resourceType === ResourceType.Page ||
+ resourceType === ResourceType.CollectionLink ||
+ resourceType === ResourceType.CollectionPage
+ )
+}
diff --git a/apps/studio/tests/integration/helpers/seed/index.ts b/apps/studio/tests/integration/helpers/seed/index.ts
index 5ba82f1462..a72cf877a8 100644
--- a/apps/studio/tests/integration/helpers/seed/index.ts
+++ b/apps/studio/tests/integration/helpers/seed/index.ts
@@ -189,6 +189,32 @@ export const setupBlob = async (blobId?: string) => {
.executeTakeFirstOrThrow()
}
+const getFallbackTitle = (resourceType: ResourceType) => {
+ switch (resourceType) {
+ case ResourceType.RootPage:
+ return "Home"
+ case ResourceType.CollectionPage:
+ return "test collection page"
+ case ResourceType.IndexPage:
+ return "test index page"
+ default:
+ return "test page"
+ }
+}
+
+const getFallbackPermalink = (resourceType: ResourceType) => {
+ switch (resourceType) {
+ case ResourceType.RootPage:
+ return ""
+ case ResourceType.CollectionPage:
+ return "test-collection-page"
+ case ResourceType.IndexPage:
+ return "test-index-page"
+ default:
+ return "test-page"
+ }
+}
+
export const setupPageResource = async ({
siteId: siteIdProp,
blobId: blobIdProp,
@@ -214,8 +240,8 @@ export const setupPageResource = async ({
let page = await db
.insertInto("Resource")
.values({
- title: title ?? (resourceType === "RootPage" ? "Home" : "test page"),
- permalink: permalink ?? (resourceType === "RootPage" ? "" : "test-page"),
+ title: title ?? getFallbackTitle(resourceType),
+ permalink: permalink ?? getFallbackPermalink(resourceType),
siteId: site.id,
parentId,
publishedVersionId: null,
@@ -294,6 +320,104 @@ export const setupFolder = async ({
}
}
+export const setupCollection = async ({
+ siteId: siteIdProp,
+ permalink = "test-collection",
+ parentId = null,
+ title = "test collection",
+}: {
+ siteId?: number
+ permalink?: string
+ parentId?: string | null
+ title?: string
+}) => {
+ const { site, navbar, footer } = await setupSite(siteIdProp, !!siteIdProp)
+
+ const collection = await db
+ .insertInto("Resource")
+ .values({
+ permalink,
+ siteId: site.id,
+ parentId,
+ title,
+ draftBlobId: null,
+ state: ResourceState.Draft,
+ type: ResourceType.Collection,
+ publishedVersionId: null,
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow()
+
+ return {
+ site,
+ navbar,
+ footer,
+ collection,
+ }
+}
+
+export const setupCollectionLink = async ({
+ siteId: siteIdProp,
+ permalink = "test-collection-link",
+ collectionId,
+ title = "test collection link",
+}: {
+ siteId?: number
+ permalink?: string
+ collectionId: string
+ title?: string
+}) => {
+ const { site, navbar, footer } = await setupSite(siteIdProp, !!siteIdProp)
+
+ const collectionLink = await db
+ .insertInto("Resource")
+ .values({
+ permalink,
+ siteId: site.id,
+ parentId: collectionId,
+ title,
+ type: ResourceType.CollectionLink,
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow()
+
+ return {
+ site,
+ navbar,
+ footer,
+ collectionLink,
+ }
+}
+
+export const setupFolderMeta = async ({
+ siteId: siteIdProp,
+ folderId,
+}: {
+ siteId?: number
+ folderId: string
+}) => {
+ const { site, navbar, footer } = await setupSite(siteIdProp, !!siteIdProp)
+
+ const folderMeta = await db
+ .insertInto("Resource")
+ .values({
+ siteId: site.id,
+ parentId: folderId,
+ title: "Folder meta",
+ permalink: "folder-meta",
+ type: ResourceType.FolderMeta,
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow()
+
+ return {
+ site,
+ navbar,
+ footer,
+ folderMeta,
+ }
+}
+
export const setUpWhitelist = async ({
email,
expiry,
diff --git a/apps/studio/tests/msw/handlers/resource.ts b/apps/studio/tests/msw/handlers/resource.ts
index 72db929c25..586272dc9b 100644
--- a/apps/studio/tests/msw/handlers/resource.ts
+++ b/apps/studio/tests/msw/handlers/resource.ts
@@ -100,4 +100,89 @@ export const resourceHandlers = {
}
}),
},
+ search: {
+ initial: () => {
+ return trpcMsw.resource.search.query(() => {
+ return {
+ totalCount: null,
+ resources: [],
+ recentlyEdited: Array.from({ length: 5 }, (_, i) => {
+ const title = `testing ${i}`
+ const permalink = title.toLowerCase().replace(/ /g, "-")
+ return {
+ id: (5 - i).toString(),
+ title,
+ permalink,
+ type: "Page",
+ parentId: null,
+ lastUpdatedAt: new Date(`2024-01-0${3 - i}`),
+ fullPermalink: permalink,
+ }
+ }),
+ }
+ })
+ },
+ results: () => {
+ return trpcMsw.resource.search.query(() => {
+ return {
+ totalCount: 4,
+ resources: [
+ {
+ id: "1",
+ title:
+ "covid testing collection link (both terms should be highlighted)",
+ permalink:
+ "covid-testing-collection-link-both-terms-should-be-highlighted",
+ type: "CollectionLink",
+ parentId: null,
+ lastUpdatedAt: new Date("2024-01-01"),
+ fullPermalink:
+ "covid-testing-collection-link-both-terms-should-be-highlighted",
+ },
+ {
+ id: "2",
+ title:
+ "super duper unnecessary long title why is this even so long but the matching word covid is near the end",
+ permalink:
+ "super-duper-unnecessary-long-title-why-is-this-even-so-long-but-the-matching-word-covid-is-near-the-end",
+ type: "Page",
+ parentId: null,
+ lastUpdatedAt: new Date("2024-01-01"),
+ fullPermalink:
+ "super-duper-unnecessary-long-title-why-is-this-even-so-long-but-the-matching-word-covid-is-near-the-end",
+ },
+ {
+ id: "3",
+ title: "covid folder that should not display lastUpdatedAt",
+ permalink: "covid-folder-that-should-not-display-lastupdatedat",
+ type: "Folder",
+ parentId: null,
+ lastUpdatedAt: new Date("2024-01-01"),
+ fullPermalink:
+ "covid-folder-that-should-not-display-lastupdatedat",
+ },
+ {
+ id: "4",
+ title: "covid collection that should not display lastUpdatedAt",
+ permalink:
+ "covid-collection-that-should-not-display-lastupdatedat",
+ type: "Collection",
+ parentId: null,
+ lastUpdatedAt: new Date("2024-01-01"),
+ fullPermalink:
+ "covid-collection-that-should-not-display-lastupdatedat",
+ },
+ ],
+ recentlyEdited: [],
+ }
+ })
+ },
+ loading: () => {
+ return trpcMsw.resource.search.query(() => {
+ return new Promise(() => {
+ // Never resolve to simulate infinite loading
+ })
+ })
+ },
+ },
}
diff --git a/apps/studio/tests/msw/handlers/sites.ts b/apps/studio/tests/msw/handlers/sites.ts
index a38c21730d..5f72decd4e 100644
--- a/apps/studio/tests/msw/handlers/sites.ts
+++ b/apps/studio/tests/msw/handlers/sites.ts
@@ -41,6 +41,13 @@ export const sitesHandlers = {
loading: () => siteListQuery({ wait: "infinite" }),
empty: () => siteListQuery({ isEmpty: true }),
},
+ getSiteName: {
+ default: () => {
+ return trpcMsw.site.getSiteName.query(() => {
+ return { name: "Isomer" }
+ })
+ },
+ },
getTheme: {
default: () => {
return trpcMsw.site.getTheme.query(() => {
diff --git a/package-lock.json b/package-lock.json
index d100db51f7..09e62694d7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -85,6 +85,7 @@
"@trpc/next": "10.45.0",
"@trpc/react-query": "10.45.0",
"@trpc/server": "10.45.0",
+ "@uidotdev/usehooks": "^2.4.1",
"ajv": "^8.16.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.1.3",
@@ -14427,6 +14428,19 @@
"@ucast/mongo": "^2.4.0"
}
},
+ "node_modules/@uidotdev/usehooks": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
+ "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
@@ -33177,7 +33191,7 @@
"version": "0.0.13",
"license": "ISC",
"dependencies": {
- "@datadog/browser-rum": "^5.28.1",
+ "@datadog/browser-rum": "^5.29.1",
"@govtechsg/sgds": "^2.3.3",
"@govtechsg/sgds-react": "^2.5.1",
"@headlessui/react": "^2.1.2",