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",