diff --git a/apps/studio/public/assets/css/preview-tw.css b/apps/studio/public/assets/css/preview-tw.css index ca673a2f7d..9033bdc0cd 100644 --- a/apps/studio/public/assets/css/preview-tw.css +++ b/apps/studio/public/assets/css/preview-tw.css @@ -1418,10 +1418,6 @@ video { -webkit-line-clamp: 3; } -.\!block { - display: block !important; -} - .block { display: block; } @@ -3156,11 +3152,6 @@ video { color: rgb(26 86 229 / var(--tw-text-opacity)); } -.text-link-hover { - --tw-text-opacity: 1; - color: rgb(21 71 190 / var(--tw-text-opacity)); -} - .text-site-secondary { --tw-text-opacity: 1; color: rgb(78 69 65 / var(--tw-text-opacity)); @@ -5510,12 +5501,6 @@ video { margin-top: 2.25rem; } -.\[\&\:not\(\:first-child\)\]\:first-of-type\:mt-7:first-of-type:not( - :first-child - ) { - margin-top: 1.75rem; -} - .\[\&\:not\(\:last-child\)\]\:mb-0:not(:last-child) { margin-bottom: 0px; } diff --git a/apps/studio/src/components/PageEditor/LinkEditorModal.tsx b/apps/studio/src/components/PageEditor/LinkEditorModal.tsx index 229e8e7e01..b388d443fa 100644 --- a/apps/studio/src/components/PageEditor/LinkEditorModal.tsx +++ b/apps/studio/src/components/PageEditor/LinkEditorModal.tsx @@ -8,7 +8,6 @@ import { ModalFooter, ModalHeader, ModalOverlay, - Text, } from "@chakra-ui/react" import { Button, @@ -29,7 +28,6 @@ import { import { useQueryParse } from "~/hooks/useQueryParse" import { useZodForm } from "~/lib/form" import { getReferenceLink, getResourceIdFromReferenceLink } from "~/utils/link" -import { trpc } from "~/utils/trpc" import { ResourceSelector } from "../ResourceSelector" import { FileAttachment } from "./FileAttachment" @@ -44,31 +42,16 @@ interface PageLinkElementProps { const PageLinkElement = ({ value, onChange }: PageLinkElementProps) => { const { siteId } = useQueryParse(editSiteSchema) - - const selectedResourceId = getResourceIdFromReferenceLink(value) - - const { data: resource } = trpc.resource.getWithFullPermalink.useQuery({ - resourceId: selectedResourceId, - }) - return ( - <> - - onChange(getReferenceLink({ siteId: String(siteId), resourceId })) - } - selectedResourceId={selectedResourceId} - /> - - {!!resource && ( - - - You selected /{resource.fullPermalink} - - - )} - + + onChange(getReferenceLink({ siteId: String(siteId), resourceId })) + } + selectedResourceId={getResourceIdFromReferenceLink(value)} + fileExplorerHeight={12} + /> ) } diff --git a/apps/studio/src/components/ResourceSelector/ResourceItem.tsx b/apps/studio/src/components/ResourceSelector/ResourceItem.tsx index ee8baa1a28..a7bee1f71f 100644 --- a/apps/studio/src/components/ResourceSelector/ResourceItem.tsx +++ b/apps/studio/src/components/ResourceSelector/ResourceItem.tsx @@ -1,108 +1,77 @@ -import type { IconType } from "react-icons" -import { Suspense, useMemo } from "react" -import { Box, HStack, Icon, Skeleton, Text } from "@chakra-ui/react" +import type { ButtonProps } from "@opengovsg/design-system-react" +import { Icon, Skeleton, Text, VStack } from "@chakra-ui/react" import { dataAttr } from "@chakra-ui/utils" import { Button } from "@opengovsg/design-system-react" -import { QueryErrorResetBoundary } from "@tanstack/react-query" -import { ResourceType } from "~prisma/generated/generatedEnums" -import { ErrorBoundary } from "react-error-boundary" -import { BiData, BiFile, BiFolder, BiLink, BiLockAlt } from "react-icons/bi" -import type { RouterOutput } from "~/utils/trpc" +import type { ResourceItemContent } from "~/schemas/resource" +import { getIcon } from "~/utils/resources" -type ResourceItemProps = Pick< - RouterOutput["resource"]["getChildrenOf"]["items"][number], - "permalink" | "type" -> & { - isSelected: boolean - isDisabled: boolean - onResourceItemSelect: () => void +interface ResourceItemProps { + item: ResourceItemContent + isDisabled?: boolean + isHighlighted?: boolean + handleOnClick?: () => void + hasAdditionalLeftPadding?: boolean + isLoading?: boolean } -const SuspendableResourceItem = ({ - permalink, - type, - isSelected, - isDisabled, - onResourceItemSelect, -}: ResourceItemProps) => { - const icon: IconType = useMemo(() => { - switch (type) { - case ResourceType.CollectionLink: - return BiLink - case ResourceType.Folder: - return BiFolder - case ResourceType.CollectionPage: - case ResourceType.Page: - return BiFile - case ResourceType.Collection: - return BiData - } - }, [type]) - +const ResourceItemContainer = (props: ButtonProps) => { return ( - ) -} - -export const ResourceItem = (props: ResourceItemProps) => { - return ( - - {({ reset }) => ( - ( - - There was an error! - - - )} - > - }> - - - - )} - + + {`/${item.permalink}`} + + + ) } diff --git a/apps/studio/src/components/ResourceSelector/ResourceSelector.tsx b/apps/studio/src/components/ResourceSelector/ResourceSelector.tsx index 99f7022182..66046e02c3 100644 --- a/apps/studio/src/components/ResourceSelector/ResourceSelector.tsx +++ b/apps/studio/src/components/ResourceSelector/ResourceSelector.tsx @@ -1,161 +1,239 @@ -import { Suspense, useState } from "react" -import { - Box, - Flex, - HStack, - Icon, - Skeleton, - Spacer, - Text, -} from "@chakra-ui/react" -import { Button, Link } from "@opengovsg/design-system-react" +import { Suspense, useMemo } from "react" +import { Box, Flex, Skeleton, Text, VStack } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" import { ResourceType } from "~prisma/generated/generatedEnums" -import { BiHomeAlt, BiLeftArrowAlt } from "react-icons/bi" -import { trpc } from "~/utils/trpc" -import { ResourceItem } from "./ResourceItem" +import type { ResourceItemContent } from "~/schemas/resource" +import type { SearchResultResource } from "~/server/modules/resource/resource.types" +import { useSearchQuery } from "~/hooks/useSearchQuery" +import { getUserViewableResourceTypes } from "~/utils/resources" +import { + LoadingResourceItemsResults, + SuspendableContent, +} from "./ResourceSelectorContent" +import { LoadingHeader, SuspendableHeader } from "./ResourceSelectorHeader" +import { SearchBar } from "./SearchBar" +import { useResourceQuery } from "./useResourceQuery" +import { useResourceSelector } from "./useResourceSelector" +import { useResourceStack } from "./useResourceStack" + +const FILE_EXPLORER_DEFAULT_HEIGHT_IN_REM = 17.5 + +interface ResourceSelectorProps { + interactionType: "link" | "move" + siteId: number + onChange: (resourceId: string) => void + selectedResourceId?: string + existingResource?: ResourceItemContent + onlyShowFolders?: boolean + fileExplorerHeight?: number +} const SuspensableResourceSelector = ({ + interactionType, siteId, - selectedResourceId, onChange, - isDisabledFn, -}: ResourceSelectorProps) => { - const [ancestryStack] = trpc.resource.getAncestryOf.useSuspenseQuery({ + selectedResourceId, + existingResource, + onlyShowFolders = false, + fileExplorerHeight = FILE_EXPLORER_DEFAULT_HEIGHT_IN_REM, + searchQuery, + isLoading, + matchedResources, + clearSearchValue, +}: ResourceSelectorProps & { + searchQuery: string + isLoading: boolean + matchedResources: SearchResultResource[] + clearSearchValue: () => void +}) => { + const isSearchQueryEmpty: boolean = searchQuery.trim().length === 0 + const hasAdditionalLeftPadding: boolean = isSearchQueryEmpty + + const { + fullPermalink, + moveDest, + parentDest, + resourceStack, + isResourceHighlighted, + setIsResourceHighlighted, + setResourceStack, + removeFromStack, + } = useResourceStack({ siteId, - resourceId: selectedResourceId, + selectedResourceId, + existingResource, + }) + + const { + resourceItemsWithAncestryStack, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useResourceQuery({ + siteId, + moveDest, + parentDest, + isResourceHighlighted, + onlyShowFolders, + resourceIds: isSearchQueryEmpty + ? undefined + : matchedResources.map((resource) => resource.id), }) - const [parentIdStack, setParentIdStack] = useState( - ancestryStack.map((item) => item.id), - ) - const currResourceId = parentIdStack[parentIdStack.length - 1] ?? null - const [data, { fetchNextPage, hasNextPage, isFetchingNextPage }] = - trpc.resource.getChildrenOf.useSuspenseInfiniteQuery( - { - resourceId: currResourceId, - siteId, - limit: 25, - }, - { - getNextPageParam: (lastPage) => lastPage.nextOffset, - }, + const { + isResourceIdHighlighted, + isResourceItemDisabled, + hasParentInStack, + handleClickBackButton, + handleClickResourceItem, + } = useResourceSelector({ + interactionType, + siteId, + moveDest, + resourceStack, + isResourceHighlighted, + setIsResourceHighlighted, + existingResource, + setResourceStack, + removeFromStack, + onChange: (resourceId: string) => { + onChange(resourceId) + clearSearchValue() + }, + }) + + const renderedHeader = useMemo(() => { + return ( + }> + + ) + }, [ + isSearchQueryEmpty, + hasParentInStack, + handleClickBackButton, + resourceItemsWithAncestryStack, + searchQuery, + isLoading, + ]) - const onBack = () => { - setParentIdStack((prev) => prev.slice(0, -1)) - } + const renderedContent = useMemo(() => { + return ( + }> + + + ) + }, [ + resourceItemsWithAncestryStack, + isResourceIdHighlighted, + isResourceItemDisabled, + hasAdditionalLeftPadding, + handleClickResourceItem, + isSearchQueryEmpty, + searchQuery, + clearSearchValue, + isLoading, + ]) return ( - - {parentIdStack.length > 0 ? ( - - - - Back to parent folder - - - ) : ( - - - - / - - - + + {renderedHeader} + {renderedContent} + {hasNextPage && ( + + )} + + + + You selected {fullPermalink} + {existingResource && ( + + The URL for "{existingResource.title}" will change to{" "} + {existingResource.id === moveDest?.id + ? fullPermalink + : `${fullPermalink}/${existingResource.permalink}`} + + )} - )} - - {data.pages.flatMap(({ items }) => items).length === 0 ? ( - - - No matching results - - - ) : ( - data.pages.map(({ items }) => - items.map((child) => { - const isDisabled = isDisabledFn?.(child.id) ?? false - - return ( - { - if ( - child.type === ResourceType.Folder || - child.type === ResourceType.Collection - ) { - setParentIdStack((prev) => [...prev, child.id]) - } else { - onChange(child.id) - } - }} - /> - ) - }), - ) - )} - - {hasNextPage && ( - - )} - + + ) } -interface ResourceSelectorProps { - siteId: string - selectedResourceId?: string - onChange: (resourceId: string) => void - isDisabledFn?: (resourceId: string) => boolean -} - export const ResourceSelector = (props: ResourceSelectorProps) => { + const { + searchValue, + setSearchValue, + debouncedSearchTerm: searchQuery, + isLoading, + matchedResources, + clearSearchValue, + } = useSearchQuery({ + siteId: String(props.siteId), + resourceTypes: props.onlyShowFolders + ? [ResourceType.Folder] + : getUserViewableResourceTypes(), + }) + return ( - }> - - + + + + } + > + + + ) } diff --git a/apps/studio/src/components/ResourceSelector/ResourceSelectorContent.tsx b/apps/studio/src/components/ResourceSelector/ResourceSelectorContent.tsx new file mode 100644 index 0000000000..f36d35a315 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/ResourceSelectorContent.tsx @@ -0,0 +1,150 @@ +import { Text, VStack } from "@chakra-ui/react" +import { Button } from "@opengovsg/design-system-react" + +import type { ResourceItemContent } from "~/schemas/resource" +import { ResourceItem, ResourceItemSkeleton } from "./ResourceItem" +import { lastResourceItemInAncestryStack } from "./utils" + +const NoItemsInFolderResult = () => { + return ( + + This folder is empty. + + ) +} + +const ResourceItemsResults = ({ + resourceItemsWithAncestryStack, + isResourceIdHighlighted, + isResourceItemDisabled, + hasAdditionalLeftPadding, + handleClickResourceItem, +}: { + resourceItemsWithAncestryStack: ResourceItemContent[][] + isResourceIdHighlighted: (resourceId: string) => boolean + isResourceItemDisabled: (resourceItem: ResourceItemContent) => boolean + hasAdditionalLeftPadding: boolean + handleClickResourceItem: ( + resourceItemWithAncestryStack: ResourceItemContent[], + ) => void +}) => { + return resourceItemsWithAncestryStack.map((resourceItemWithAncestryStack) => { + const lastChild = lastResourceItemInAncestryStack( + resourceItemWithAncestryStack, + ) + + if (!lastChild) { + throw new Error( + "Unexpected undefined lastChild from lastResourceItemInAncestryStack", + ) + } + + return ( + + handleClickResourceItem(resourceItemWithAncestryStack) + } + hasAdditionalLeftPadding={hasAdditionalLeftPadding} + /> + ) + }) +} + +const ZeroResult = ({ + searchQuery, + handleClickClearSearch, +}: { + searchQuery: string + handleClickClearSearch: () => void +}) => { + return ( + + + + We can't find anything with +
"{searchQuery}" in title +
+ Try searching for something else. +
+ +
+ ) +} + +export const LoadingResourceItemsResults = () => { + return Array.from({ length: 5 }).map((_, index) => ( + + )) +} + +export const SuspendableContent = ({ + resourceItemsWithAncestryStack, + isResourceIdHighlighted, + isResourceItemDisabled, + hasAdditionalLeftPadding, + handleClickResourceItem, + isSearchQueryEmpty, + searchQuery, + clearSearchValue, + isLoading, +}: { + resourceItemsWithAncestryStack: ResourceItemContent[][] + isResourceIdHighlighted: (resourceId: string) => boolean + isResourceItemDisabled: (resourceItem: ResourceItemContent) => boolean + hasAdditionalLeftPadding: boolean + handleClickResourceItem: ( + resourceItemWithAncestryStack: ResourceItemContent[], + ) => void + isSearchQueryEmpty: boolean + searchQuery: string + clearSearchValue: () => void + isLoading: boolean +}) => { + if (isLoading) { + return + } + if (resourceItemsWithAncestryStack.length === 0) { + return isSearchQueryEmpty ? ( + + ) : ( + + ) + } + return ( + + ) +} diff --git a/apps/studio/src/components/ResourceSelector/ResourceSelectorHeader.tsx b/apps/studio/src/components/ResourceSelector/ResourceSelectorHeader.tsx new file mode 100644 index 0000000000..513d2f45d0 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/ResourceSelectorHeader.tsx @@ -0,0 +1,108 @@ +import { Flex, HStack, Spacer, Text } from "@chakra-ui/react" +import { Link } from "@opengovsg/design-system-react" +import { BiHomeAlt, BiLeftArrowAlt } from "react-icons/bi" + +import type { ResourceItemContent } from "~/schemas/resource" + +const HomeHeader = () => { + return ( + + + + / + + + + Home + + + ) +} + +const BackButtonHeader = ({ handleOnClick }: { handleOnClick: () => void }) => { + return ( + + + + Back to parent folder + + + ) +} + +const SearchResultsHeader = ({ + resultsCount, + searchQuery, +}: { + resultsCount: number + searchQuery: string +}) => { + return ( + + {resultsCount} result{resultsCount > 1 ? "s" : ""} with "{searchQuery}" in + title + + ) +} + +export const LoadingHeader = () => { + return ( + + Searching your website, high and low + + ) +} + +export const SuspendableHeader = ({ + isSearchQueryEmpty, + hasParentInStack, + handleClickBackButton, + resourceItemsWithAncestryStack, + searchQuery, + isLoading, +}: { + isSearchQueryEmpty: boolean + hasParentInStack: boolean + handleClickBackButton: () => void + resourceItemsWithAncestryStack: ResourceItemContent[][] + searchQuery: string + isLoading: boolean +}) => { + if (isLoading) { + return + } + if (isSearchQueryEmpty) { + return hasParentInStack ? ( + + ) : ( + + ) + } + return ( + + ) +} diff --git a/apps/studio/src/components/ResourceSelector/SearchBar.tsx b/apps/studio/src/components/ResourceSelector/SearchBar.tsx new file mode 100644 index 0000000000..8ed72f59d6 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/SearchBar.tsx @@ -0,0 +1,17 @@ +import { Searchbar as OgpSearchBar } from "@opengovsg/design-system-react" + +interface SearchBarProps { + searchValue: string + setSearchValue: (value: string) => void +} +export const SearchBar = ({ searchValue, setSearchValue }: SearchBarProps) => { + return ( + setSearchValue(target.value)} + w="full" + placeholder="Search pages, collections, or folders by name, or choose from the list below" + /> + ) +} diff --git a/apps/studio/src/components/ResourceSelector/useResourceQuery.tsx b/apps/studio/src/components/ResourceSelector/useResourceQuery.tsx new file mode 100644 index 0000000000..5069fb804b --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/useResourceQuery.tsx @@ -0,0 +1,55 @@ +import { type ResourceItemContent } from "~/schemas/resource" +import { trpc } from "~/utils/trpc" + +export const useResourceQuery = ({ + siteId, + moveDest, + parentDest, + isResourceHighlighted, + onlyShowFolders, + resourceIds, +}: { + siteId: number + moveDest: ResourceItemContent | undefined + parentDest: ResourceItemContent | undefined + isResourceHighlighted: boolean + onlyShowFolders: boolean + resourceIds?: ResourceItemContent["id"][] +}) => { + const queryFn = onlyShowFolders + ? trpc.resource.getFolderChildrenOf.useInfiniteQuery + : trpc.resource.getChildrenOf.useInfiniteQuery + const { + data: { pages } = { pages: [{ items: [], nextOffset: null }] }, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = queryFn( + { + resourceId: + (isResourceHighlighted ? parentDest?.id : moveDest?.id) ?? null, + siteId: String(siteId), + limit: 25, + }, + { + getNextPageParam: (lastPage) => lastPage.nextOffset, + }, + ) + + const useResourceIdsFromSearch = !!resourceIds + const [resourceItemsWithAncestryStack] = + trpc.resource.getBatchAncestryWithSelf.useSuspenseQuery({ + siteId: String(siteId), + resourceIds: useResourceIdsFromSearch + ? resourceIds + : pages.flatMap(({ items }) => items).map((item) => item.id), + }) + + return { + resourceItemsWithAncestryStack, + // eslint-disable-next-line @typescript-eslint/no-empty-function + fetchNextPage: useResourceIdsFromSearch ? () => {} : fetchNextPage, + hasNextPage: useResourceIdsFromSearch ? false : hasNextPage, + isFetchingNextPage: useResourceIdsFromSearch ? false : isFetchingNextPage, + } +} diff --git a/apps/studio/src/components/ResourceSelector/useResourceSelector.tsx b/apps/studio/src/components/ResourceSelector/useResourceSelector.tsx new file mode 100644 index 0000000000..6bc42a6742 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/useResourceSelector.tsx @@ -0,0 +1,144 @@ +import { useCallback, useMemo } from "react" +import { ResourceType } from "~prisma/generated/generatedEnums" + +import type { ResourceItemContent } from "~/schemas/resource" +import { isAllowedToHaveChildren } from "~/utils/resources" +import { trpc } from "~/utils/trpc" +import { lastResourceItemInAncestryStack } from "./utils" + +export const useResourceSelector = ({ + interactionType, + siteId, + moveDest, + resourceStack, + isResourceHighlighted, + setIsResourceHighlighted, + existingResource, + setResourceStack, + removeFromStack, + onChange, +}: { + interactionType: "move" | "link" + siteId: number + moveDest: ResourceItemContent | undefined + resourceStack: ResourceItemContent[] + isResourceHighlighted: boolean + setIsResourceHighlighted: (isResourceHighlighted: boolean) => void + existingResource: ResourceItemContent | undefined + setResourceStack: (resourceStack: ResourceItemContent[]) => void + removeFromStack: (numberOfResources: number) => void + onChange: (resourceId: string) => void +}) => { + const isResourceIdHighlighted = useCallback( + (resourceId: string): boolean => { + const curResourceId = moveDest?.id + return isResourceHighlighted && curResourceId === resourceId + }, + [isResourceHighlighted, moveDest?.id], + ) + + const { data: nestedChildrenOfExistingResourceResult } = + trpc.resource.getNestedFolderChildrenOf.useQuery({ + resourceId: String(existingResource?.id), + siteId: String(siteId), + }) + + const nestedChildrenOfExistingResource = useMemo( + (): ResourceItemContent[] => + nestedChildrenOfExistingResourceResult?.items ?? [], + [nestedChildrenOfExistingResourceResult?.items], + ) + + const isResourceItemDisabled = useCallback( + (resourceItem: ResourceItemContent): boolean => { + if (!existingResource) return false + + // Then we are linking the resource and not moving any resource + // Thus, no checks are needed because we can link to any resource + if (interactionType === "link") return false + + // A resource should not be able to move to within itself + if (existingResource.id === resourceItem.id) return true + + // If a resource is not allowed to have children then it is a page-ish resource + // Thus, it can move to within any resource and no further checks are needed + if (!isAllowedToHaveChildren(existingResource.type)) return false + + // A resource should not be able to move to its nested children + return ( + nestedChildrenOfExistingResource.some( + (child) => child.id === resourceItem.id, + ) || false + ) + }, + [existingResource, interactionType, nestedChildrenOfExistingResource], + ) + + const hasParentInStack = useMemo( + () => + (resourceStack.length === 1 && !isResourceHighlighted) || + resourceStack.length > 1, + [resourceStack.length, isResourceHighlighted], + ) + + const handleClickBackButton = useCallback(() => { + if (isResourceHighlighted) { + setIsResourceHighlighted(false) + removeFromStack(2) + } else { + removeFromStack(1) + } + const lastChild = lastResourceItemInAncestryStack(resourceStack) + if (lastChild) { + onChange(lastChild.id) + } + }, [ + isResourceHighlighted, + onChange, + removeFromStack, + resourceStack, + setIsResourceHighlighted, + ]) + + const handleClickResourceItem = useCallback( + (resourceItemWithAncestryStack: ResourceItemContent[]): void => { + const lastChild = lastResourceItemInAncestryStack( + resourceItemWithAncestryStack, + ) + + if (!lastChild) { + throw new Error( + "Unexpected undefined lastChild from lastResourceItemInAncestryStack", + ) + } + + const isItemHighlighted = isResourceIdHighlighted(lastChild.id) + const canClickIntoItem = + lastChild.type === ResourceType.Folder || + lastChild.type === ResourceType.Collection + + if (isItemHighlighted && canClickIntoItem) { + setIsResourceHighlighted(false) + return + } + + setResourceStack(resourceItemWithAncestryStack) + onChange(lastChild.id) + setIsResourceHighlighted(true) + }, + [ + onChange, + setIsResourceHighlighted, + setResourceStack, + isResourceIdHighlighted, + ], + ) + + return { + isResourceIdHighlighted, + isResourceItemDisabled, + hasParentInStack, + handleClickBackButton, + handleClickResourceItem, + } +} diff --git a/apps/studio/src/components/ResourceSelector/useResourceStack.tsx b/apps/studio/src/components/ResourceSelector/useResourceStack.tsx new file mode 100644 index 0000000000..794a3ad5ac --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/useResourceStack.tsx @@ -0,0 +1,59 @@ +import { useCallback, useMemo, useState } from "react" + +import type { ResourceItemContent } from "~/schemas/resource" +import { trpc } from "~/utils/trpc" + +export const useResourceStack = ({ + siteId, + selectedResourceId, + existingResource, +}: { + siteId: number + selectedResourceId: string | undefined + existingResource: ResourceItemContent | undefined +}) => { + const [pendingMovedItemAncestryStack] = + trpc.resource.getAncestryWithSelf.useSuspenseQuery({ + siteId: String(siteId), + resourceId: existingResource?.id, + }) + + // NOTE: This is the stack of user's navigation through the resource tree + // NOTE: We should always start the stack from `/` (root) + // so that the user will see a full overview of their site structure + const [resourceStack, setResourceStack] = useState( + pendingMovedItemAncestryStack, + ) + + const [isResourceHighlighted, setIsResourceHighlighted] = + useState(!!selectedResourceId) + + const moveDest = useMemo( + () => resourceStack[resourceStack.length - 1], // last item in stack + [resourceStack], + ) + const parentDest = useMemo( + () => resourceStack[resourceStack.length - 2], // second last item in stack + [resourceStack], + ) + + const removeFromStack = useCallback((numberOfResources: number): void => { + setResourceStack((prev) => prev.slice(0, -numberOfResources)) + }, []) + + const fullPermalink: string = useMemo(() => { + return resourceStack.map((resource) => resource.permalink).join("/") + }, [resourceStack]) + + // currently do not support fetching next page for search + return { + fullPermalink, + moveDest, + parentDest, + resourceStack, + isResourceHighlighted, + setIsResourceHighlighted, + setResourceStack, + removeFromStack, + } +} diff --git a/apps/studio/src/components/ResourceSelector/utils/index.ts b/apps/studio/src/components/ResourceSelector/utils/index.ts new file mode 100644 index 0000000000..a23ba96cd3 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/utils/index.ts @@ -0,0 +1 @@ +export * from "./lastResourceItemInAncestryStack" diff --git a/apps/studio/src/components/ResourceSelector/utils/lastResourceItemInAncestryStack.ts b/apps/studio/src/components/ResourceSelector/utils/lastResourceItemInAncestryStack.ts new file mode 100644 index 0000000000..bad36cbf98 --- /dev/null +++ b/apps/studio/src/components/ResourceSelector/utils/lastResourceItemInAncestryStack.ts @@ -0,0 +1,7 @@ +import type { ResourceItemContent } from "~/schemas/resource" + +export const lastResourceItemInAncestryStack = ( + resourceItemWithAncestryStack: ResourceItemContent[], +): ResourceItemContent | undefined => { + return resourceItemWithAncestryStack[resourceItemWithAncestryStack.length - 1] +} diff --git a/apps/studio/src/components/Searchbar/SearchModal.tsx b/apps/studio/src/components/Searchbar/SearchModal.tsx index fb060de5b4..4bc08983de 100644 --- a/apps/studio/src/components/Searchbar/SearchModal.tsx +++ b/apps/studio/src/components/Searchbar/SearchModal.tsx @@ -8,10 +8,9 @@ import { 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 { useSearchQuery } from "~/hooks/useSearchQuery" +import { getUserViewableResourceTypes } from "~/utils/resources" import { CommandKey } from "./CommandKey" import { InitialState, @@ -28,40 +27,34 @@ interface SearchModalProps { } export const SearchModal = ({ siteId, isOpen, onClose }: SearchModalProps) => { const [queryCount, setQueryCount] = useState(0) - const [searchValue, setSearchValue] = useState("") - const debouncedSearchTerm = useDebounce(searchValue, 300) - const { data, isLoading } = trpc.resource.search.useInfiniteQuery( - { - siteId, - query: debouncedSearchTerm, + const { + setSearchValue, + debouncedSearchTerm, + matchedResources, + isLoading, + totalResultsCount, + recentlyEditedResources, + } = useSearchQuery({ + siteId, + resourceTypes: getUserViewableResourceTypes(), + onSearchSuccess: () => { + setQueryCount((prev) => prev + 1) }, - { - onSuccess: () => { - setQueryCount((prev) => prev + 1) - }, - }, - ) - const resources: SearchResultResource[] = - data?.pages.flatMap((page) => page.resources) ?? [] + }) const renderModalBody = (): React.ReactNode => { if (!!debouncedSearchTerm) { if (isLoading) { return } - if (resources.length === 0) { + if (matchedResources.length === 0) { return } return ( acc + (page.totalCount ?? 0), - 0, - ) ?? 0 - } + items={matchedResources} + totalResultsCount={totalResultsCount} searchTerm={debouncedSearchTerm} // 3 is an arbitrary number that we are trying out and our guess // of the number of queries the user has to do before they are deemed "lost" @@ -69,12 +62,7 @@ export const SearchModal = ({ siteId, isOpen, onClose }: SearchModalProps) => { /> ) } - return ( - - ) + return } const { minWidth, maxWidth, marginTop } = useSearchStyle() @@ -117,7 +105,7 @@ export const SearchModal = ({ siteId, isOpen, onClose }: SearchModalProps) => { borderBottomRadius="base" > - {resources.length === 0 + {matchedResources.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."} diff --git a/apps/studio/src/components/Searchbar/SearchResult.tsx b/apps/studio/src/components/Searchbar/SearchResult.tsx index 205ed828b3..364c8ab192 100644 --- a/apps/studio/src/components/Searchbar/SearchResult.tsx +++ b/apps/studio/src/components/Searchbar/SearchResult.tsx @@ -2,10 +2,9 @@ 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" +import { getIcon, isAllowedToHaveLastEditedText } from "~/utils/resources" export interface SearchResultProps { siteId: string @@ -116,7 +115,7 @@ export const SearchResult = ({ alignItems="flex-start" > diff --git a/apps/studio/src/features/dashboard/components/DirectorySidebar/DirectorySidebarContent.tsx b/apps/studio/src/features/dashboard/components/DirectorySidebar/DirectorySidebarContent.tsx index e96ca26ef7..6205fee10d 100644 --- a/apps/studio/src/features/dashboard/components/DirectorySidebar/DirectorySidebarContent.tsx +++ b/apps/studio/src/features/dashboard/components/DirectorySidebar/DirectorySidebarContent.tsx @@ -4,8 +4,8 @@ import { Button } from "@opengovsg/design-system-react" import { ResourceType } from "~prisma/generated/generatedEnums" import { getResourceSubpath } from "~/utils/resource" +import { getIcon } from "~/utils/resources" import { trpc } from "~/utils/trpc" -import { ICON_MAPPINGS } from "./constants" import { RowEntry } from "./RowEntry" import { useIsActive } from "./useIsActive" @@ -87,7 +87,7 @@ export const DirectorySidebarContent = ({ > = { - [ResourceType.Page]: BiFile, - [ResourceType.Folder]: BiFolder, - [ResourceType.Collection]: BiData, - [ResourceType.CollectionPage]: BiFile, - [ResourceType.CollectionMeta]: BiCog, - [ResourceType.CollectionLink]: BiLink, - [ResourceType.RootPage]: BiHomeAlt, - [ResourceType.IndexPage]: BiFile, - [ResourceType.FolderMeta]: BiSort, -} diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx index e71dd6dbd0..d5624f1793 100644 --- a/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx +++ b/apps/studio/src/features/dashboard/components/ResourceTable/ResourceTableMenu.tsx @@ -39,7 +39,7 @@ export const ResourceTableMenu = ({ }: ResourceTableMenuProps) => { const setMoveResource = useSetAtom(moveResourceAtom) const handleMoveResourceClick = () => - setMoveResource({ resourceId, title, permalink, parentId }) + setMoveResource({ id: resourceId, title, permalink, parentId, type }) const setResourceModalState = useSetAtom(deleteResourceModalAtom) const setFolderSettingsModalState = useSetAtom(folderSettingsModalAtom) const setPageSettingsModalState = useSetAtom(pageSettingsModalAtom) @@ -96,6 +96,7 @@ export const ResourceTableMenu = ({ as="button" onClick={handleMoveResourceClick} icon={} + aria-label={`Move resource to another location for ${title}`} > Move to... diff --git a/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx b/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx index d322121cb2..863a920c29 100644 --- a/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx +++ b/apps/studio/src/features/dashboard/components/ResourceTable/TitleCell.tsx @@ -3,19 +3,10 @@ import { useMemo } from "react" import NextLink from "next/link" import { HStack, Icon, Text, VStack } from "@chakra-ui/react" import { Link } from "@opengovsg/design-system-react" -import { ResourceType } from "~prisma/generated/generatedEnums" -import { - BiCog, - BiData, - BiFile, - BiFolder, - BiHome, - BiLink, - BiSort, -} from "react-icons/bi" import type { ResourceTableData } from "./types" import { getLinkToResource } from "~/utils/resource" +import { getIcon } from "~/utils/resources" export interface TitleCellProps extends Pick { @@ -34,25 +25,7 @@ export const TitleCell = ({ }, [id, siteId, type]) const ResourceTypeIcon: IconType = useMemo(() => { - switch (type) { - case ResourceType.RootPage: - return BiHome - case ResourceType.IndexPage: - case ResourceType.Page: - return BiFile - case ResourceType.Folder: - return BiFolder - case ResourceType.Collection: - return BiData - case ResourceType.CollectionMeta: - return BiCog - case ResourceType.CollectionPage: - return BiFile - case ResourceType.CollectionLink: - return BiLink - case ResourceType.FolderMeta: - return BiSort - } + return getIcon(type) }, [type]) return ( diff --git a/apps/studio/src/features/editing-experience/atoms.ts b/apps/studio/src/features/editing-experience/atoms.ts index 650a46b40b..088a20806a 100644 --- a/apps/studio/src/features/editing-experience/atoms.ts +++ b/apps/studio/src/features/editing-experience/atoms.ts @@ -1,9 +1,9 @@ import { format } from "date-fns" import { atom } from "jotai" -import type { PendingMoveResource } from "./types" +import type { ResourceItemContent } from "~/schemas/resource" -export const moveResourceAtom = atom(null) +export const moveResourceAtom = atom(null) export interface CollectionLinkProps { ref: string diff --git a/apps/studio/src/features/editing-experience/components/CreateCollectionPageModal/TypeOptionsInput.tsx b/apps/studio/src/features/editing-experience/components/CreateCollectionPageModal/TypeOptionsInput.tsx index 2f292d736b..c7456f96e8 100644 --- a/apps/studio/src/features/editing-experience/components/CreateCollectionPageModal/TypeOptionsInput.tsx +++ b/apps/studio/src/features/editing-experience/components/CreateCollectionPageModal/TypeOptionsInput.tsx @@ -12,9 +12,9 @@ import { } from "@chakra-ui/react" import { Badge } from "@opengovsg/design-system-react" import { ResourceType } from "~prisma/generated/generatedEnums" -import { BiFile, BiLink } from "react-icons/bi" import type { CollectionItemType } from "./constants" +import { getIcon } from "~/utils/resources" import { COLLECTION_ITEM_TYPES } from "./constants" interface TypeTileProps extends UseRadioProps { @@ -34,7 +34,7 @@ const TypeOptionRadio = forwardRef( switch (value) { case ResourceType.CollectionPage: { return { - TileIcon: BiFile, + TileIcon: getIcon(value), title: "Page", description: "Select this option if you want an empty page where you can place article content.", @@ -47,7 +47,7 @@ const TypeOptionRadio = forwardRef( } case ResourceType.CollectionLink: { return { - TileIcon: BiLink, + TileIcon: getIcon(value), title: "Link or file", description: "Select this option if you want to link to an existing page on your site, link an external page, or upload a PDF file.", diff --git a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveItem.tsx b/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveItem.tsx deleted file mode 100644 index a3222cb618..0000000000 --- a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveItem.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Suspense } from "react" -import { Text } from "@chakra-ui/react" -import { Button } from "@opengovsg/design-system-react" -import { QueryErrorResetBoundary } from "@tanstack/react-query" -import { ErrorBoundary } from "react-error-boundary" -import { BiFolder } from "react-icons/bi" - -import type { RouterOutput } from "~/utils/trpc" - -type MoveItemProps = Pick< - RouterOutput["resource"]["getFolderChildrenOf"]["items"][number], - "permalink" -> & { - handleOnClick: () => void - isDisabled?: boolean - isHighlighted?: boolean -} - -const getButtonProps = ({ isHighlighted }: { isHighlighted: boolean }) => { - if (isHighlighted) { - return { - color: "interaction.main.default", - bg: "interaction.muted.main.active", - _hover: { - color: "interaction.main.default", - bg: "interaction.muted.main.active", - }, - } - } - - return { - color: "base.content.default", - } -} - -const SuspendableMoveItem = ({ - permalink, - isHighlighted, - handleOnClick, - ...rest -}: MoveItemProps) => { - const buttonProps = getButtonProps({ - isHighlighted: !!isHighlighted, - }) - - return ( - - ) -} - -export const MoveItem = (props: MoveItemProps) => { - return ( - - {({ reset }) => ( - ( -
- There was an error! - -
- )} - > - - - -
- )} -
- ) -} diff --git a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx b/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx index 62ac661460..52177ea868 100644 --- a/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx +++ b/apps/studio/src/features/editing-experience/components/MoveResourceModal/MoveResourceModal.tsx @@ -1,9 +1,6 @@ import { useState } from "react" import { - Box, Button, - Flex, - HStack, Modal, ModalBody, ModalCloseButton, @@ -12,26 +9,18 @@ import { ModalHeader, ModalOverlay, Skeleton, - Spacer, - Text, VStack, } from "@chakra-ui/react" -import { Infobox, Link, useToast } from "@opengovsg/design-system-react" +import { Infobox, useToast } from "@opengovsg/design-system-react" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { BiHomeAlt, BiLeftArrowAlt } from "react-icons/bi" -import type { PendingMoveResource } from "../../types" +import { ResourceSelector } from "~/components/ResourceSelector/ResourceSelector" import { usePermissions } from "~/features/permissions" import { withSuspense } from "~/hocs/withSuspense" import { useQueryParse } from "~/hooks/useQueryParse" import { sitePageSchema } from "~/pages/sites/[siteId]" import { trpc } from "~/utils/trpc" import { moveResourceAtom } from "../../atoms" -import { MoveItem } from "./MoveItem" - -const generatePermalinkPrefix = (parents: PendingMoveResource[]) => { - return parents.map((parent) => parent.permalink).join("/") -} export const MoveResourceModal = () => { // NOTE: This is what we are trying to move @@ -42,10 +31,7 @@ export const MoveResourceModal = () => { {moveItem && ( - + )} ) @@ -53,36 +39,12 @@ export const MoveResourceModal = () => { const MoveResourceContent = withSuspense( ({ resourceId, onClose }: { resourceId: string; onClose: () => void }) => { - // NOTE: This is the stack of user's navigation through the resource tree - // NOTE: We should always start the stack from `/` (root) - // so that the user will see a full overview of their site structure - const [resourceStack, setResourceStack] = useState( - [], - ) - const [isResourceHighlighted, setIsResourceHighlighted] = - useState(true) + const [curResourceId, setCurResourceId] = useState(null) const { siteId } = useQueryParse(sitePageSchema) const setMovedItem = useSetAtom(moveResourceAtom) const [{ title }] = trpc.resource.getMetadataById.useSuspenseQuery({ resourceId, }) - const moveDest = resourceStack[resourceStack.length - 1] - const parentDest = resourceStack[resourceStack.length - 2] - const curResourceId = moveDest?.resourceId - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - trpc.resource.getFolderChildrenOf.useInfiniteQuery( - { - resourceId: - (isResourceHighlighted - ? parentDest?.resourceId - : moveDest?.resourceId) ?? null, - siteId: String(siteId), - limit: 25, - }, - { - getNextPageParam: (lastPage) => lastPage.nextOffset, - }, - ) const ability = usePermissions() const utils = trpc.useUtils() const toast = useToast({ status: "success" }) @@ -126,7 +88,7 @@ const MoveResourceContent = withSuspense( // and invalidate the new + old folders await utils.folder.getMetadata.invalidate() await utils.resource.getMetadataById.invalidate({ - resourceId: movedItem?.resourceId, + resourceId: movedItem?.id, }) toast({ title: "Resource moved!" }) }, @@ -134,10 +96,6 @@ const MoveResourceContent = withSuspense( const movedItem = useAtomValue(moveResourceAtom) - const shouldShowBackButton: boolean = - (resourceStack.length === 1 && !isResourceHighlighted) || - resourceStack.length > 1 - return ( Move "{title}" to... @@ -147,127 +105,13 @@ const MoveResourceContent = withSuspense( Moving a page or folder changes its URL, effective immediately - - {shouldShowBackButton ? ( - { - if (isResourceHighlighted) { - setIsResourceHighlighted(false) - setResourceStack((prev) => prev.slice(0, -2)) - } else { - setResourceStack((prev) => prev.slice(0, -1)) - } - }} - as="button" - > - - - Back to parent folder - - - ) : ( - - - - / - - - - Home - - - )} - {data?.pages.map(({ items }) => - items.map((item) => { - const isItemDisabled: boolean = - item.id === movedItem?.resourceId - const isItemHighlighted: boolean = - isResourceHighlighted && item.id === curResourceId - - return ( - { - if (isItemDisabled) { - return - } - - if (isItemHighlighted) { - setIsResourceHighlighted(false) - return - } - - const newResource = { - ...item, - parentId: parentDest?.resourceId ?? null, - resourceId: item.id, - } - if (isResourceHighlighted) { - setResourceStack((prev) => [ - ...prev.slice(0, -1), - newResource, - ]) - } else { - setIsResourceHighlighted(true) - setResourceStack((prev) => [...prev, newResource]) - } - }} - /> - ) - }), - )} - {hasNextPage && ( - - )} - - {!!moveDest && ( - - - - You selected {moveDest.permalink} - - - The URL for {movedItem?.title} will change to{" "} - {`${generatePermalinkPrefix(resourceStack)}/${movedItem?.permalink}`} - - - - )} + setCurResourceId(resourceId)} + /> @@ -284,17 +128,17 @@ const MoveResourceContent = withSuspense( // or if the user does not have sufficient permissions to move to the destination isDisabled={ ability.cannot("move", { - parentId: moveDest?.resourceId ?? null, + parentId: curResourceId, }) || ability.cannot("move", { parentId: movedItem?.parentId ?? null }) } isLoading={isLoading} onClick={() => - movedItem?.resourceId && + movedItem?.id && mutate({ siteId, - movedResourceId: movedItem.resourceId, - destinationResourceId: moveDest?.resourceId ?? null, + movedResourceId: movedItem.id, + destinationResourceId: curResourceId, }) } > diff --git a/apps/studio/src/features/editing-experience/types.ts b/apps/studio/src/features/editing-experience/types.ts deleted file mode 100644 index cef3716b47..0000000000 --- a/apps/studio/src/features/editing-experience/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PendingMoveResource { - permalink: string - title: string - resourceId: string - parentId: string | null -} diff --git a/apps/studio/src/hooks/useSearchQuery.ts b/apps/studio/src/hooks/useSearchQuery.ts new file mode 100644 index 0000000000..7eb821aa20 --- /dev/null +++ b/apps/studio/src/hooks/useSearchQuery.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo, useState } from "react" +import { useDebounce } from "@uidotdev/usehooks" + +import type { SearchResultResource } from "~/server/modules/resource/resource.types" +import type { ResourceType } from "~prisma/generated/generatedEnums" +import { trpc } from "~/utils/trpc" + +interface UseSearchQueryProps { + siteId: string + resourceTypes: ResourceType[] + onSearchSuccess?: () => void +} +export const useSearchQuery = ({ + siteId, + resourceTypes, + onSearchSuccess, +}: UseSearchQueryProps) => { + const [searchValue, setSearchValue] = useState("") + const debouncedSearchTerm = useDebounce(searchValue, 300) + + const { data, isLoading } = trpc.resource.search.useInfiniteQuery( + { + siteId, + query: debouncedSearchTerm, + resourceTypes, + }, + { + onSuccess: onSearchSuccess, + }, + ) + + const matchedResources = useMemo((): SearchResultResource[] => { + return data?.pages.flatMap((page) => page.resources) ?? [] + }, [data]) + + const totalResultsCount = useMemo(() => { + return ( + data?.pages.reduce((acc, page) => acc + (page.totalCount ?? 0), 0) ?? 0 + ) + }, [data]) + + const recentlyEditedResources = useMemo((): SearchResultResource[] => { + return data?.pages[0]?.recentlyEdited ?? [] + }, [data]) + + const clearSearchValue = useCallback(() => { + setSearchValue("") + }, [setSearchValue]) + + return { + searchValue, + setSearchValue, + debouncedSearchTerm, + matchedResources, + isLoading, + totalResultsCount, + recentlyEditedResources, + clearSearchValue, + } +} diff --git a/apps/studio/src/schemas/resource.ts b/apps/studio/src/schemas/resource.ts index 4093153048..1b2d3c73bf 100644 --- a/apps/studio/src/schemas/resource.ts +++ b/apps/studio/src/schemas/resource.ts @@ -1,3 +1,4 @@ +import { ResourceType } from "~prisma/generated/generatedEnums" import { z } from "zod" import type { SearchResultResource } from "../server/modules/resource/resource.types" @@ -35,6 +36,20 @@ export const getChildrenSchema = z }) .merge(infiniteOffsetPaginationSchema) +export const getChildrenOutputSchema = z.object({ + items: z.array(z.custom()), + nextOffset: z.number().nullable(), +}) + +export const getNestedFolderChildrenSchema = z.object({ + resourceId: bigIntSchema, + siteId: z.string().min(0), +}) + +export const getNestedFolderChildrenOutputSchema = z.object({ + items: z.array(z.custom()), +}) + export const moveSchema = z.object({ siteId: z.number(), movedResourceId: bigIntSchema, @@ -67,15 +82,40 @@ export const getFullPermalinkSchema = z.object({ resourceId: bigIntSchema, }) -export const getAncestrySchema = z.object({ +export const getAncestryWithSelfSchema = z.object({ siteId: z.string(), resourceId: z.string().optional(), }) +export const getAncestryWithSelfOutputSchema = z.array( + z.custom(), +) + +export const getBatchAncestryWithSelfSchema = z.object({ + siteId: z.string(), + resourceIds: z.array(z.string()), +}) + +export const getBatchAncestryWithSelfOutputSchema = z.array( + z.array(z.custom()), +) + +export interface ResourceItemContent { + title: string + permalink: string + type: ResourceType + id: string + parentId: string | null +} + export const searchSchema = z .object({ siteId: z.string(), - query: z.string().optional(), + query: z.string().trim().optional(), + resourceTypes: z + .array(z.nativeEnum(ResourceType)) + .optional() + .default(Object.values(ResourceType)), }) .merge(infiniteOffsetPaginationSchema) 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 36009a8920..98700efe07 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 @@ -17,6 +17,7 @@ import { } from "tests/integration/helpers/seed" import { createCallerFactory } from "~/server/trpc" +import { getUserViewableResourceTypes } from "~/utils/resources" import { db } from "../../database" import { resourceRouter } from "../resource.router" @@ -735,6 +736,143 @@ describe("resource.router", async () => { it.skip("should throw 403 if user does not have read access to resource", async () => {}) }) + describe("getNestedFolderChildrenOf", () => { + const RESOURCE_FIELDS_TO_PICK = [ + "title", + "permalink", + "type", + "id", + "parentId", + ] as const + + it("should throw 401 if not logged in", async () => { + const unauthedSession = applySession() + const unauthedCaller = createCaller(createMockRequest(unauthedSession)) + + const result = unauthedCaller.getNestedFolderChildrenOf({ + resourceId: "1", + siteId: "1", + }) + + await expect(result).rejects.toThrowError( + new TRPCError({ code: "UNAUTHORIZED" }), + ) + }) + + it("should return 404 if resource does not exist", async () => { + // Arrange + const { site } = await setupSite() + await setupAdminPermissions({ + userId: session.userId, + siteId: site.id, + }) + + // Act + const result = caller.getNestedFolderChildrenOf({ + resourceId: "1", + siteId: String(site.id), + }) + + // Assert + await expect(result).rejects.toThrowError( + new TRPCError({ code: "NOT_FOUND" }), + ) + }) + + it("should return 404 if resource is not a folder", async () => { + // Arrange + const { site } = await setupSite() + await setupAdminPermissions({ + userId: session.userId, + siteId: site.id, + }) + const { page } = await setupPageResource({ + siteId: site.id, + resourceType: "Page", + }) + + // Act + const result = caller.getNestedFolderChildrenOf({ + siteId: String(site.id), + resourceId: page.id, + }) + + // Assert + await expect(result).rejects.toThrowError( + new TRPCError({ code: "NOT_FOUND" }), + ) + }) + + it("should throw 403 if user does not have read access to site", async () => { + // Arrange + const { site, folder } = await setupFolder() + + // Act + const result = caller.getNestedFolderChildrenOf({ + siteId: String(site.id), + resourceId: folder.id, + }) + + // 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 nested folder children (e.g. folders within folders)", async () => { + // Arrange + const { site } = await setupSite() + await setupAdminPermissions({ + userId: session.userId, + siteId: site.id, + }) + const { folder: parentFolder } = await setupFolder({ + siteId: site.id, + parentId: null, + permalink: "parent-folder", + title: "Parent folder", + }) + const { folder: childFolder } = await setupFolder({ + siteId: site.id, + parentId: parentFolder.id, + permalink: "child-folder", + title: "Child folder", + }) + const { folder: grandChildFolder } = await setupFolder({ + siteId: site.id, + parentId: childFolder.id, + permalink: "grand-child-folder", + title: "Grand child folder", + }) + const { folder: grandChildFolder2 } = await setupFolder({ + siteId: site.id, + parentId: childFolder.id, + permalink: "grand-child-folder-2", + title: "Grand child folder 2", + }) + + // Act + const result = await caller.getNestedFolderChildrenOf({ + siteId: String(site.id), + resourceId: parentFolder.id, + }) + + // Assert + const expected = { + items: [childFolder, grandChildFolder, grandChildFolder2].map( + (resource) => pick(resource, RESOURCE_FIELDS_TO_PICK), + ), + } + expect(result).toEqual(expected) + }) + }) + describe("move", () => { it("should throw 401 if not logged in", async () => { const unauthedSession = applySession() @@ -1667,19 +1805,20 @@ describe("resource.router", async () => { it.skip("should throw 403 if user does not have read access to the resource", async () => {}) }) - describe("getAncestryOf", () => { + describe("getAncestryWithSelf", () => { const RESOURCE_FIELDS_TO_PICK = [ "id", "title", "parentId", "permalink", + "type", ] as const it("should throw 401 if not logged in", async () => { const unauthedSession = applySession() const unauthedCaller = createCaller(createMockRequest(unauthedSession)) - const result = unauthedCaller.getAncestryOf({ + const result = unauthedCaller.getAncestryWithSelf({ resourceId: "1", siteId: "1", }) @@ -1694,7 +1833,7 @@ describe("resource.router", async () => { const { site } = await setupSite() // Act - const result = await caller.getAncestryOf({ + const result = await caller.getAncestryWithSelf({ siteId: String(site.id), resourceId: "99999", }) @@ -1710,7 +1849,7 @@ describe("resource.router", async () => { }) // Act - const result = await caller.getAncestryOf({ + const result = await caller.getAncestryWithSelf({ resourceId: page.id, siteId: String(site.id), }) @@ -1724,7 +1863,7 @@ describe("resource.router", async () => { const { site } = await setupSite() // Act - const result = await caller.getAncestryOf({ + const result = await caller.getAncestryWithSelf({ siteId: String(site.id), }) @@ -1732,9 +1871,12 @@ describe("resource.router", async () => { expect(result).toEqual([]) }) - it("should return the ancestry of a nested resource", async () => { + it("should return the ancestry (including self and excluding root page) of a nested resource", async () => { // Arrange - const { folder: parentFolder, site } = await setupFolder({ + const { site } = await setupPageResource({ + resourceType: "RootPage", + }) + const { folder: parentFolder } = await setupFolder({ permalink: "parent-folder", title: "Parent folder", }) @@ -1751,7 +1893,7 @@ describe("resource.router", async () => { }) // Act - const result = await caller.getAncestryOf({ + const result = await caller.getAncestryWithSelf({ resourceId: nestedPage.id, siteId: String(site.id), }) @@ -1760,24 +1902,25 @@ describe("resource.router", async () => { const expected = [ pick(parentFolder, RESOURCE_FIELDS_TO_PICK), pick(nestedFolder, RESOURCE_FIELDS_TO_PICK), + pick(nestedPage, RESOURCE_FIELDS_TO_PICK), ] expect(result).toEqual(expected) }) - it("should return empty array if resource is a root-level resource", async () => { + it("should return empty resource if resource is a root-level resource", async () => { // Arrange const { page, site } = await setupPageResource({ resourceType: "Page", }) // Act - const result = await caller.getAncestryOf({ + const result = await caller.getAncestryWithSelf({ resourceId: page.id, siteId: String(site.id), }) // Assert - expect(result).toEqual([]) + expect(result).toEqual([pick(page, RESOURCE_FIELDS_TO_PICK)]) }) it.skip("should throw 403 if user does not have read access to the resource", async () => {}) @@ -2302,7 +2445,7 @@ describe("resource.router", async () => { expect(result).toEqual(expected) }) - it("should only return user viewable resource types", async () => { + it("should only return user viewable resource types if specified", async () => { // Arrange const { site } = await setupSite() await setupAdminPermissions({ @@ -2332,6 +2475,7 @@ describe("resource.router", async () => { const result = await caller.search({ siteId: String(site.id), query: "test", + resourceTypes: getUserViewableResourceTypes(), }) // Assert diff --git a/apps/studio/src/server/modules/resource/resource.router.ts b/apps/studio/src/server/modules/resource/resource.router.ts index 78c3bf43e9..34ae079ee6 100644 --- a/apps/studio/src/server/modules/resource/resource.router.ts +++ b/apps/studio/src/server/modules/resource/resource.router.ts @@ -7,10 +7,16 @@ import type { PermissionsProps } from "../permissions/permissions.type" import { countResourceSchema, deleteResourceSchema, - getAncestrySchema, + getAncestryWithSelfOutputSchema, + getAncestryWithSelfSchema, + getBatchAncestryWithSelfOutputSchema, + getBatchAncestryWithSelfSchema, + getChildrenOutputSchema, getChildrenSchema, getFullPermalinkSchema, getMetadataSchema, + getNestedFolderChildrenOutputSchema, + getNestedFolderChildrenSchema, getParentSchema, listResourceSchema, moveSchema, @@ -29,6 +35,7 @@ import { } from "../permissions/permissions.service" import { validateUserPermissionsForSite } from "../site/site.service" import { + getBatchAncestryWithSelfQuery, getSearchRecentlyEdited, getSearchResults, getSearchWithResourceIds, @@ -105,8 +112,10 @@ export const resourceRouter = router({ return resource }), + getFolderChildrenOf: protectedProcedure .input(getChildrenSchema) + .output(getChildrenOutputSchema) .query(async ({ input: { siteId, resourceId, cursor: offset, limit } }) => { // Validate site and resourceId exists and is a Folder if (resourceId !== null) { @@ -124,7 +133,7 @@ export const resourceRouter = router({ let query = db .selectFrom("Resource") - .select(["title", "permalink", "type", "id"]) + .select(["title", "permalink", "type", "id", "parentId"]) .where("Resource.type", "in", [ResourceType.Folder]) .where("Resource.siteId", "=", Number(siteId)) .$narrowType<{ @@ -156,6 +165,7 @@ export const resourceRouter = router({ }), getChildrenOf: protectedProcedure .input(getChildrenSchema) + .output(getChildrenOutputSchema) .query(async ({ input: { resourceId, siteId, cursor: offset, limit } }) => { // Validate site and resourceId exists and is a folder if (resourceId !== null) { @@ -176,8 +186,9 @@ export const resourceRouter = router({ } let query = db .selectFrom("Resource") - .select(["title", "permalink", "type", "id"]) + .select(["title", "permalink", "type", "id", "parentId"]) .where("Resource.type", "!=", ResourceType.RootPage) + .where("Resource.type", "!=", ResourceType.IndexPage) .where("Resource.type", "!=", ResourceType.FolderMeta) .where("Resource.type", "!=", ResourceType.CollectionMeta) .where("Resource.siteId", "=", Number(siteId)) @@ -201,7 +212,6 @@ export const resourceRouter = router({ } else { query = query.where("Resource.parentId", "=", String(resourceId)) } - const result = await query.execute() if (result.length > limit) { // Dont' return the last element, it's just for checking if there are more @@ -217,6 +227,59 @@ export const resourceRouter = router({ } }), + getNestedFolderChildrenOf: protectedProcedure + .input(getNestedFolderChildrenSchema) + .output(getNestedFolderChildrenOutputSchema) + .query(async ({ ctx, input: { resourceId, siteId } }) => { + await validateUserPermissionsForSite({ + siteId: Number(siteId), + userId: ctx.user.id, + action: "read", + }) + + const resource = await db + .selectFrom("Resource") + .where("siteId", "=", Number(siteId)) + .where("id", "=", String(resourceId)) + .where("Resource.type", "=", ResourceType.Folder) + .executeTakeFirst() + + if (!resource) { + throw new TRPCError({ code: "NOT_FOUND" }) + } + + return { + items: await db + .withRecursive("NestedResources", (eb) => + eb + .selectFrom("Resource") + .select(["title", "permalink", "type", "id", "parentId"]) + .where("Resource.type", "in", [ResourceType.Folder]) + .where("Resource.siteId", "=", Number(siteId)) + .where("Resource.parentId", "=", String(resourceId)) + .unionAll((eb) => + eb + .selectFrom("Resource") + .innerJoin( + "NestedResources", + "Resource.parentId", + "NestedResources.id", + ) + .select([ + "Resource.title", + "Resource.permalink", + "Resource.type", + "Resource.id", + "Resource.parentId", + ]), + ), + ) + .selectFrom("NestedResources") + .select(["title", "permalink", "type", "id", "parentId"]) + .execute(), + } + }), + move: protectedProcedure .input(moveSchema) .mutation( @@ -477,62 +540,48 @@ export const resourceRouter = router({ return query.select(["role"]).execute() }), - getAncestryOf: protectedProcedure - .input(getAncestrySchema) + getAncestryWithSelf: protectedProcedure + .input(getAncestryWithSelfSchema) + .output(getAncestryWithSelfOutputSchema) .query(async ({ input: { siteId, resourceId } }) => { if (!resourceId) { return [] } + const batchAncestry = await getBatchAncestryWithSelfQuery({ + siteId: Number(siteId), + resourceIds: [resourceId], + }) + return batchAncestry[0] ?? [] + }), - const ancestors = await db - .withRecursive("Resources", (eb) => - eb - .selectFrom("Resource") - .select([ - "Resource.id", - "Resource.title", - "Resource.permalink", - "Resource.parentId", - ]) - .where("Resource.siteId", "=", Number(siteId)) - .where("Resource.id", "=", resourceId) - .unionAll( - eb - .selectFrom("Resource") - .innerJoin("Resources", "Resources.parentId", "Resource.id") - .select([ - "Resource.id", - "Resource.title", - "Resource.permalink", - "Resource.parentId", - ]), - ), - ) - .selectFrom("Resources") - .select([ - "Resources.id", - "Resources.title", - "Resources.permalink", - "Resources.parentId", - ]) - .execute() - - return ancestors.reverse().slice(0, -1) + getBatchAncestryWithSelf: protectedProcedure + .input(getBatchAncestryWithSelfSchema) + .output(getBatchAncestryWithSelfOutputSchema) + .query(async ({ input: { siteId, resourceIds } }) => { + if (resourceIds.length === 0) { + return [] + } + return await getBatchAncestryWithSelfQuery({ + siteId: Number(siteId), + resourceIds, + }) }), search: protectedProcedure .input(searchSchema) .output(searchOutputSchema) .query( - async ({ ctx, input: { siteId, query = "", cursor: offset, limit } }) => { + async ({ + ctx, + input: { siteId, query, resourceTypes, 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() === "") { + if (!query) { return { totalCount: null, resources: [], @@ -547,6 +596,7 @@ export const resourceRouter = router({ query, offset, limit, + resourceTypes, }) return { totalCount: Number(searchResults.totalCount), diff --git a/apps/studio/src/server/modules/resource/resource.service.ts b/apps/studio/src/server/modules/resource/resource.service.ts index 5108ae4cc6..30b6662cb6 100644 --- a/apps/studio/src/server/modules/resource/resource.service.ts +++ b/apps/studio/src/server/modules/resource/resource.service.ts @@ -6,6 +6,7 @@ import { type DB } from "~prisma/generated/generatedTypes" import type { Resource, SafeKysely, Transaction } from "../database" import type { SearchResultResource } from "./resource.types" +import type { ResourceItemContent } from "~/schemas/resource" import { INDEX_PAGE_PERMALINK } from "~/constants/sitemap" import { getSitemapTree } from "~/utils/sitemap" import { publishSite } from "../aws/codebuild.service" @@ -413,6 +414,107 @@ export const publishResource = async ( return addedVersionResult } +interface ResourceItemContentWithPath extends ResourceItemContent { + path: string[] +} +export const getBatchAncestryWithSelfQuery = async ({ + siteId, + resourceIds, +}: { + siteId: number + resourceIds: string[] +}): Promise => { + const resources: ResourceItemContentWithPath[] = await db + .withRecursive("Resources", (eb) => + eb + .selectFrom("Resource") + .select([ + "Resource.id", + "Resource.title", + "Resource.permalink", + "Resource.parentId", + "Resource.type", + sql`1`.as("depth"), // Start with depth 1 for the base case + sql`ARRAY["Resource"."id"]`.as("path"), + ]) + .where("Resource.siteId", "=", Number(siteId)) + .where("Resource.id", "in", resourceIds) + .where("Resource.type", "!=", ResourceType.RootPage) + .unionAll( + eb + .selectFrom("Resource") + .innerJoin("Resources", "Resources.parentId", "Resource.id") + .select([ + "Resource.id", + "Resource.title", + "Resource.permalink", + "Resource.parentId", + "Resource.type", + sql`depth + 1`.as("depth"), // Add 1 to the depth for each level of recursion + sql`ARRAY["Resource"."id"] || "Resources"."path"`.as( + "path", + ), + ]), + ), + ) + .selectFrom("Resources") + .select([ + "Resources.id", + "Resources.title", + "Resources.permalink", + "Resources.parentId", + "Resources.type", + "Resources.path", + ]) + .orderBy("Resources.depth", "desc") // sort by depth in descending order + .execute() + + // Group by paths with shared parents support + const groupByPaths = ( + resources: ResourceItemContentWithPath[], + ): ResourceItemContent[][] => { + const groups = [] + + // Clone the items array to track remaining items + // Cannot be Set because resources might share the same parent + const remainingResources = [...resources] + + while (remainingResources.length > 0) { + const currentResource = remainingResources.shift() + if (!currentResource) break + + // Group all resources that are found in the current resource's path + const group = [currentResource].concat( + currentResource.path + .slice(1) // without the current resource + .map( + (childId) => + remainingResources.find((item) => item.id === childId) ?? + currentResource, + ) + .filter(Boolean), + ) + + // Remove all items in this group from remainingResources + group.forEach((node) => { + const index = remainingResources.findIndex( + (resource) => + resource.id === node.id && + JSON.stringify(resource.path) === JSON.stringify(node.path), + ) + if (index !== -1) remainingResources.splice(index, 1) + }) + + groups.push(group) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return groups.map((group) => group.map(({ path, ...rest }) => rest)) + } + + return groupByPaths(resources) +} + export const getWithFullPermalink = async ({ resourceId, }: { @@ -490,11 +592,13 @@ export const getSearchResults = async ({ query, offset, limit, + resourceTypes, }: { siteId: number query: string offset: number limit: number + resourceTypes: ResourceType[] }): Promise<{ totalCount: number | null resources: SearchResultResource[] @@ -506,14 +610,7 @@ export const getSearchResults = async ({ 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("Resource.type", "in", resourceTypes) .where((eb) => eb.or( searchTerms.map((searchTerm) => diff --git a/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx index 7d6bc249c3..4963fd3fcb 100644 --- a/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditArticlePage.stories.tsx @@ -22,7 +22,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getRolesFor.default(), resourceHandlers.getWithFullPermalink.default(), - resourceHandlers.getAncestryOf.collectionLink(), + resourceHandlers.getAncestryWithSelf.collectionLink(), + resourceHandlers.getBatchAncestryWithSelf.default(), resourceHandlers.getChildrenOf.default(), resourceHandlers.getMetadataById.article(), pageHandlers.readPageAndBlob.article(), diff --git a/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx index 73f1b85500..ba96254837 100644 --- a/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditCollectionLink.stories.tsx @@ -23,7 +23,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getRolesFor.default(), resourceHandlers.getWithFullPermalink.default(), - resourceHandlers.getAncestryOf.collectionLink(), + resourceHandlers.getAncestryWithSelf.collectionLink(), + resourceHandlers.getBatchAncestryWithSelf.default(), resourceHandlers.getChildrenOf.default(), resourceHandlers.getMetadataById.article(), resourceHandlers.getParentOf.collection(), diff --git a/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx b/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx index 3167fc7c23..4dfcc5c75c 100644 --- a/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx +++ b/apps/studio/src/stories/Page/EditPage/EditContentPage.stories.tsx @@ -22,7 +22,8 @@ const COMMON_HANDLERS = [ sitesHandlers.getLocalisedSitemap.default(), resourceHandlers.getChildrenOf.default(), resourceHandlers.getWithFullPermalink.default(), - resourceHandlers.getAncestryOf.collectionLink(), + resourceHandlers.getAncestryWithSelf.collectionLink(), + resourceHandlers.getBatchAncestryWithSelf.default(), resourceHandlers.getMetadataById.content(), resourceHandlers.getRolesFor.default(), pageHandlers.readPageAndBlob.content(), diff --git a/apps/studio/src/stories/Page/MoveResourceModal.stories.tsx b/apps/studio/src/stories/Page/MoveResourceModal.stories.tsx new file mode 100644 index 0000000000..b04b177279 --- /dev/null +++ b/apps/studio/src/stories/Page/MoveResourceModal.stories.tsx @@ -0,0 +1,165 @@ +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(), + resourceHandlers.getWithFullPermalink.default(), + resourceHandlers.getAncestryWithSelf.collectionLink(), + resourceHandlers.getMetadataById.content(), +] + +const meta: Meta = { + title: "Pages/Site Management/Move Resource Modal", + component: SitePage, + parameters: { + getLayout: SitePage.getLayout, + nextjs: { + router: { + query: { + siteId: "1", + }, + }, + }, + }, + decorators: [], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.foldersOnly(), + ], + }, + }, + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const pageMenuButton = await screen.findByRole("button", { + name: "Options for Test page 1", + }) + await userEvent.click(pageMenuButton) + + const moveButton = ( + await within(canvasElement.ownerDocument.body).findByText("Move to...") + ).closest("button") + if (!moveButton) throw new Error("Move button not found") + await userEvent.click(moveButton) + }, +} + +export const SingleClick: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.foldersOnly(), + ], + }, + }, + play: async (context) => { + const { canvasElement } = context + await Default.play?.(context) + + const folder1 = ( + await within(canvasElement.ownerDocument.body).findByText("Folder 1") + ).closest("button") + if (!folder1) throw new Error("Folder 1 not found") + await userEvent.click(folder1) + }, +} + +export const EmptyFolder: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.noResults(), + ], + }, + }, + play: async (context) => { + await Default.play?.(context) + }, +} + +const SearchTemplate: Story = { + play: async (context) => { + const { canvasElement } = context + await Default.play?.(context) + + const searchButton = await within( + canvasElement.ownerDocument.body, + ).findByPlaceholderText( + "Search pages, collections, or folders by name, or choose from the list below", + ) + await userEvent.click(searchButton) + }, +} + +export const Search: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.foldersOnly(), + ], + }, + }, + play: async (context) => { + await SearchTemplate.play?.(context) + + await userEvent.keyboard("folder") + }, +} + +export const SearchLoading: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.foldersOnly(), + resourceHandlers.search.loading(), + ], + }, + }, + play: async (context) => { + await SearchTemplate.play?.(context) + + await userEvent.keyboard("folder") + }, +} + +export const SearchNoResults: Story = { + parameters: { + msw: { + handlers: [ + ...COMMON_HANDLERS, + resourceHandlers.getBatchAncestryWithSelf.noResults(), + ], + }, + }, + play: async (context) => { + await SearchTemplate.play?.(context) + + await userEvent.keyboard("deiofrehioferhfioehfe") + }, +} diff --git a/apps/studio/src/utils/resources.ts b/apps/studio/src/utils/resources.ts index 152dfe15a3..507f9e4a43 100644 --- a/apps/studio/src/utils/resources.ts +++ b/apps/studio/src/utils/resources.ts @@ -1,4 +1,14 @@ +import type { IconType } from "react-icons" import { ResourceType } from "~prisma/generated/generatedEnums" +import { + BiCog, + BiData, + BiFile, + BiFolder, + BiHome, + BiLink, + BiSort, +} from "react-icons/bi" export const isAllowedToHaveChildren = ( resourceType: ResourceType, @@ -10,6 +20,30 @@ export const isAllowedToHaveChildren = ( ) } +export const getIcon = (resourceType: ResourceType): IconType => { + switch (resourceType) { + case ResourceType.Page: + case ResourceType.IndexPage: + case ResourceType.CollectionPage: + return BiFile + case ResourceType.Folder: + return BiFolder + case ResourceType.Collection: + return BiData + case ResourceType.CollectionLink: + return BiLink + case ResourceType.RootPage: + return BiHome + case ResourceType.FolderMeta: + return BiSort + case ResourceType.CollectionMeta: + return BiCog + default: + const _: never = resourceType // exhaustive check + return BiData + } +} + export const isAllowedToHaveLastEditedText = ( resourceType: ResourceType, ): boolean => { @@ -19,3 +53,14 @@ export const isAllowedToHaveLastEditedText = ( resourceType === ResourceType.CollectionPage ) } + +// only show user-viewable resources (excluding root page, folder meta etc.) +export const getUserViewableResourceTypes = (): ResourceType[] => { + return [ + ResourceType.Page, + ResourceType.Folder, + ResourceType.Collection, + ResourceType.CollectionLink, + ResourceType.CollectionPage, + ] +} diff --git a/apps/studio/tests/msw/handlers/resource.ts b/apps/studio/tests/msw/handlers/resource.ts index f836cbfb0a..3a6ff59059 100644 --- a/apps/studio/tests/msw/handlers/resource.ts +++ b/apps/studio/tests/msw/handlers/resource.ts @@ -15,6 +15,7 @@ export const resourceHandlers = { | "CollectionPage", // ID must be unique so infinite loop won't occur id: `${resourceId}-${item.title}-${item.id}`, + parentId: item.parentId, })) return { items, @@ -43,20 +44,92 @@ export const resourceHandlers = { }) }, }, - getAncestryOf: { + getAncestryWithSelf: { collectionLink: () => { - return trpcMsw.resource.getAncestryOf.query(() => { + return trpcMsw.resource.getAncestryWithSelf.query(() => { return [ { parentId: null, id: "1", title: "Homepage", permalink: "/", + type: "RootPage", + }, + { + parentId: "1", + id: "2", + title: "Collection", + permalink: "collection", + type: "Collection", }, ] }) }, }, + getBatchAncestryWithSelf: { + default: () => { + return trpcMsw.resource.getBatchAncestryWithSelf.query(() => { + return [ + [ + { + parentId: null, + id: "1", + title: "Collection 1", + permalink: "collection-1", + type: "Collection", + }, + ], + [ + { + parentId: null, + id: "2", + title: "Folder 1", + permalink: "folder-1", + type: "Folder", + }, + ], + [ + { + parentId: null, + id: "3", + title: "Page 1", + permalink: "page-1", + type: "Page", + }, + ], + ] + }) + }, + foldersOnly: () => { + return trpcMsw.resource.getBatchAncestryWithSelf.query(() => { + return [ + [ + { + parentId: null, + id: "1", + title: "Folder 1", + permalink: "folder-1", + type: "Folder", + }, + ], + [ + { + parentId: null, + id: "2", + title: "Folder 2", + permalink: "folder-2", + type: "Folder", + }, + ], + ] + }) + }, + noResults: () => { + return trpcMsw.resource.getBatchAncestryWithSelf.query(() => { + return [] + }) + }, + }, getWithFullPermalink: { default: () => { return trpcMsw.resource.getWithFullPermalink.query(() => {