diff --git a/src/assets/images/NoBrokenLinksImage.tsx b/src/assets/images/NoBrokenLinksImage.tsx index 51bcb4077..902dbea0c 100644 --- a/src/assets/images/NoBrokenLinksImage.tsx +++ b/src/assets/images/NoBrokenLinksImage.tsx @@ -3,696 +3,539 @@ export const NoBrokenLinksImage = ( ): JSX.Element => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/PaginateButton.tsx b/src/components/PaginateButton.tsx new file mode 100644 index 000000000..2a760460b --- /dev/null +++ b/src/components/PaginateButton.tsx @@ -0,0 +1,42 @@ +import { Text, HStack } from "@chakra-ui/react" +import { + IconButton, + BxChevronLeft, + BxChevronRight, +} from "@opengovsg/design-system-react" + +import { typography } from "theme/foundations/typography" + +export default function PaginateButton({ + currentPage, + totalPage, + onPageChange, +}: { + currentPage: number + totalPage: number + onPageChange: (arg: number) => void +}) { + return ( + + + Page {currentPage} out of {totalPage}{" "} + + } + onClick={() => onPageChange(currentPage - 1)} + isDisabled={currentPage <= 1} + marginLeft="1.25rem" + marginRight="0.75rem" + /> + } + onClick={() => onPageChange(currentPage + 1)} + isDisabled={currentPage === totalPage} + /> + + ) +} diff --git a/src/layouts/LinkReport/LinksReport.tsx b/src/layouts/LinkReport/LinksReport.tsx index 06849e56d..8fe7681df 100644 --- a/src/layouts/LinkReport/LinksReport.tsx +++ b/src/layouts/LinkReport/LinksReport.tsx @@ -2,6 +2,8 @@ import { Box, BreadcrumbItem, Center, + Grid, + GridItem, HStack, Table, TableContainer, @@ -12,48 +14,41 @@ import { Thead, Tr, VStack, + useDisclosure, + keyframes, } from "@chakra-ui/react" import { useFeatureIsOn } from "@growthbook/growthbook-react" -import { Badge, Breadcrumb, Button, Link } from "@opengovsg/design-system-react" -import { set } from "lodash" -import { useEffect, useState } from "react" -import { useQueryClient } from "react-query" -import { Redirect, useParams } from "react-router-dom" - -import { SITE_LINK_CHECKER_STATUS_KEY } from "constants/queryKeys" +import { + Badge, + Breadcrumb, + Button, + BxRightArrowAlt, +} from "@opengovsg/design-system-react" +import _ from "lodash" +import { useState } from "react" +import { BiLoaderAlt } from "react-icons/bi" +import { useParams, Link as RouterLink } from "react-router-dom" -import { useGetStagingUrl } from "hooks/siteDashboardHooks" import { useGetBrokenLinks } from "hooks/siteDashboardHooks/useGetLinkChecker" import { useRefreshLinkChecker } from "hooks/siteDashboardHooks/useRefreshLinkChecker" import { NoBrokenLinksImage } from "assets" -import { - isBrokenRefError, - NonPermalinkError, - NonPermalinkErrorDto, - RepoError, -} from "types/linkReport" +import { isBrokenRefError, NonPermalinkError } from "types/linkReport" import { SiteViewHeader } from "../layouts/SiteViewLayout/SiteViewHeader" +import { LinkReportModal } from "./components/LinkReportModal/LinkReportModal" + const getBreadcrumb = (viewablePageInCms: string): string => { - /** - * There are four main types of pages - * 1. /folders/parentFolder/subfolders/childFolder/editPage/page.md -> parentFolder/childFolder/page - * 2. /folders/parentFolder/editPage/page.md -> parentFolder/page - * 3. /editPage/page.md -> page - * 4. /resourceRoom/resourceRmName/resourceCategory/resourceCatName/editPage/page.md -> resourceRmName/resourceCatName/page - */ const paths = viewablePageInCms.split("/") let breadcrumb = paths - .filter((_, index) => index % 2 === 0) + .filter((placeholder, index) => index % 2 === 0) .slice(2) .join(" / ") .replace(/-/g, " ") if (breadcrumb.endsWith(".md")) { breadcrumb = breadcrumb.slice(0, -3) } - return breadcrumb } @@ -63,272 +58,75 @@ export const LinksReportBanner = () => { const onClick = () => { refreshLinkChecker(siteName) } + const isBrokenLinksReporterEnabled = useFeatureIsOn( "is_broken_links_report_enabled" ) - const { data: brokenLinks, error: brokenLinksError, isLoading: isBrokenLinksFetching, } = useGetBrokenLinks(siteName, isBrokenLinksReporterEnabled) - const isBrokenLinksLoading = brokenLinks?.status === "loading" || isBrokenLinksFetching - return ( -
- - - - Experimental feature - - - Broken references report - - - - This report contains a list of broken references found in your site. - - - {!brokenLinksError && ( - - )} - -
- ) -} - -const normaliseUrl = (url: string): string => { - let normalisedUrl = url - if (url.endsWith("/")) { - normalisedUrl = url.slice(0, -1) - } - if (url.startsWith("/")) { - normalisedUrl = url.slice(1) - } - return normalisedUrl -} - -const SiteReportCard = ({ - breadcrumb, - links, -}: { - breadcrumb: string - links: NonPermalinkErrorDto[] -}) => { - // can use any link since we know all the links are from the same page - const { viewablePageInStaging, viewablePageInCms } = links[0] - const { siteName } = useParams<{ siteName: string }>() - const { data: stagingUrl, isLoading: isStagingUrlLoading } = useGetStagingUrl( - siteName - ) - - const normalisedStagingUrl = normaliseUrl(stagingUrl || "") - const normalisedViewablePageInStaging = normaliseUrl(viewablePageInStaging) - const viewableLinkInStaging = `${normalisedStagingUrl}/${normalisedViewablePageInStaging}` return ( - - - - {breadcrumb.split("/").map((item) => { - return ( - - {item} - - ) - })} - - - - View on staging - - - Edit page - - - - - - - - - - - - - - {links.map((link) => { - const errorType = link.type - .split("-") - .map( - (word: string) => word.charAt(0).toUpperCase() + word.slice(1) - ) - .join(" ") - - const isBrokenLink = link.type === "broken-link" - - if (isBrokenLink) { - return ( - - - - {link.linkToAsset ? ( - - ) : ( - - )} - - {link.linkedText ? ( - - ) : ( - - )} - - ) - } - - return ( - - - - - - ) - })} - -
Error typeBroken URLLink Text
{errorType}{link.linkToAsset}No URL linked{link.linkedText}Empty link text
{errorType}{link.linkToAsset}Not applicable
-
-
- ) -} - -const LinkContent = ({ brokenLinks }: { brokenLinks: RepoError[] }) => { - const links: NonPermalinkError[] = (brokenLinks.filter((error) => - isBrokenRefError(error) - ) as NonPermalinkErrorDto[]).map((error) => { - return { - ...error, - breadcrumb: getBreadcrumb(error.viewablePageInCms), - } - }) - - const pagesWithBrokenLinks: Map = new Map() - const brokenLink: number = links.filter( - (error) => error.type === "broken-link" - ).length - const brokenImage: number = links.filter( - (error) => error.type === "broken-image" - ).length - // create a set of pairs - const siteToErrorMap = new Map() - links.forEach((error) => { - const { breadcrumb } = error - - if (siteToErrorMap.has(breadcrumb)) { - siteToErrorMap.get(breadcrumb)?.push(error) - } else { - siteToErrorMap.set(breadcrumb, [error]) - pagesWithBrokenLinks.set(breadcrumb, error.viewablePageInStaging) - } - }) - - return ( - - + - - Pages with broken links - - - {Array.from(pagesWithBrokenLinks.keys()).map((page) => ( - // safe to assert as we know the key exists - - {page} - - ))} - - - - - Broken links - {brokenLink} - - - Broken images - {brokenImage} - - - {Array.from(siteToErrorMap.keys()).map((breadcrumb) => { - return ( - - ) - })} - - + + + + + Experimental feature + + Broken references report for {siteName} + + + {!brokenLinksError && ( + + )} + + + + ) } const NoBrokenLinks = () => { + const { siteName } = useParams<{ siteName: string }>() return ( -
- +
+ - - No broken links found + + Hurrah! All your references are nice and sturdy. - - Your site is in good shape. No broken references were found. + + {`We couldn't find any broken references on your site. You can come + back anytime to run the checker again.`} +
) @@ -344,15 +142,10 @@ const ErrorLoading = () => { {`We couldn't generate a broken report for this site.`} - {" "} You might want to try running the check again. If the issue persists, reach out to Isomer Support. -
@@ -361,14 +154,26 @@ const ErrorLoading = () => { } const LoadingLinkChecker = () => { + const spin = keyframes` + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + ` + return ( -
- - - Scanning your site for broken references{" "} +
+ + + + Sniffing out broken links on your site... - - This may take a while... + + Pages with broken references will appear here. This might take a + while.
@@ -376,6 +181,13 @@ const LoadingLinkChecker = () => { } const LinkBody = () => { + const { + isOpen: isLinkReportModalOpen, + onOpen: onLinkReportModalOpen, + onClose: onLinkReportModalClose, + } = useDisclosure() + const [selectedLinkCms, setSelectedLinkCms] = useState("") + const [selectedLinkStaging, setSelectedLinkStaging] = useState("") const isBrokenLinksReporterEnabled = useFeatureIsOn( "is_broken_links_report_enabled" ) @@ -395,31 +207,330 @@ const LinkBody = () => { if (brokenLinks?.status === "success") { if (brokenLinks?.errors?.length === 0) { - return + return ( + <> + +
+ +
+ + ) } - // todo: remove this once design is ready with showing duplicate permalinks const onlyDuplicatePermalinks = brokenLinks.errors.every( (error) => error.type === "duplicate-permalink" ) if (onlyDuplicatePermalinks) { - return + return ( + <> + +
+ +
+ + ) } - return + const links: NonPermalinkError[] = brokenLinks.errors + .filter(isBrokenRefError) + .map((error) => ({ + ...error, + breadcrumb: getBreadcrumb(error.viewablePageInCms), + })) + + const uniqueLinks = [ + ...new Set(links.map((error) => error.viewablePageInCms)), + ] + + const sortedUniqueLinks = _.sortBy( + uniqueLinks, + (e) => links.filter((link) => link.viewablePageInCms === e).length + ).reverse() + + return ( + <> + link.viewablePageInCms === selectedLinkCms + )} + pageCmsUrl={selectedLinkCms} + pageStagingUrl={selectedLinkStaging} + /> + + + + + + {sortedUniqueLinks.length} Pages with {links.length} broken + references + + + + {`Click "Review page" to view a detailed list of + references broken on that page.`} + + + + + + + + + + + + + {sortedUniqueLinks.map((itemUrl) => { + const uniqueBreadcrumb = getBreadcrumb(itemUrl) + return ( + + + + + + ) + })} + +
+ + Page + + + + Broken References + + + + View Details + +
+ + + {uniqueBreadcrumb.split("/").at(-1)} + + + {uniqueBreadcrumb.split("/").map((item) => ( + + {item} + + ))} + + + + + { + links.filter( + (link) => link.viewablePageInCms === itemUrl + ).length + } + + + +
+
+
+
+
+ + ) } - return + return ( + <> + + + + + + + Pages with broken references + + + + {`Click "Review page" to view a detailed list of + references broken on that page.`} + + + + + + + + + + + + + + + + +
+ + Page + + + + Broken References + + + + View Details + +
+ +
+
+
+
+
+ + ) } export const LinksReport = () => { return ( - <> + - + - + ) } diff --git a/src/layouts/LinkReport/components/LinkReportModal/LinkReportModal.tsx b/src/layouts/LinkReport/components/LinkReportModal/LinkReportModal.tsx new file mode 100644 index 000000000..a894cfcbd --- /dev/null +++ b/src/layouts/LinkReport/components/LinkReportModal/LinkReportModal.tsx @@ -0,0 +1,479 @@ +import { + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalProps, + Text, + Box, + VStack, + HStack, + Center, + TableContainer, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + Grid, + GridItem, + keyframes, +} from "@chakra-ui/react" +import { useFeatureIsOn } from "@growthbook/growthbook-react" +import { Button, Link } from "@opengovsg/design-system-react" +import _ from "lodash" +import { useState } from "react" +import { BiLoaderAlt, BiLeftArrowAlt, BiSolidInfoCircle } from "react-icons/bi" +// import { MdInfo } from "react-icons/md"; +import { useParams, Link as RouterLink } from "react-router-dom" + +import { Modal as CustomModal } from "components/Modal" +import PaginateButton from "components/PaginateButton" + +import { useGetBrokenLinks } from "hooks/siteDashboardHooks/useGetLinkChecker" +import { useRefreshLinkChecker } from "hooks/siteDashboardHooks/useRefreshLinkChecker" + +import { NoBrokenLinksImage } from "assets" +import { colors } from "theme/foundations/colors" +import { NonPermalinkError } from "types/linkReport" + +export const LinkReportModal = ({ + props, + linksArr, + pageCmsUrl, + pageStagingUrl, +}: { + props: Omit + linksArr: NonPermalinkError[] + pageCmsUrl: string + pageStagingUrl: string +}): JSX.Element => { + return ( + + + + + + + +
+ +
+
+ +
+
+ ) +} + +const LinkReportModalBanner = (props: Omit) => { + const { siteName } = useParams<{ siteName: string }>() + const { mutate: refreshLinkChecker } = useRefreshLinkChecker(siteName) + const onClick = () => { + refreshLinkChecker(siteName) + } + + const isBrokenLinksReporterEnabled = useFeatureIsOn( + "is_broken_links_report_enabled" + ) + + const { + data: brokenLinks, + error: brokenLinksError, + isLoading: isBrokenLinksFetching, + } = useGetBrokenLinks(siteName, isBrokenLinksReporterEnabled) + + const isBrokenLinksLoading = + brokenLinks?.status === "loading" || isBrokenLinksFetching + + const { onClose } = props + return ( + + + + + + {!brokenLinksError && ( + + )} + + + + ) +} + +const LinksReportDetails = ({ + linksArr, + pageCmsUrl, + pageStagingUrl, + props, +}: { + linksArr: NonPermalinkError[] + pageCmsUrl: string + pageStagingUrl: string + props: Omit +}) => { + const [pageNum, setPageNum] = useState(1) + + // Sort based on error type + const detailedErrorArr = _.sortBy( + generateDetailedError(linksArr), + (err) => err.detailedType + ) + const { siteName } = useParams<{ siteName: string }>() + const isBrokenLinksReporterEnabled = useFeatureIsOn( + "is_broken_links_report_enabled" + ) + const { + data: brokenLinks, + isLoading: isBrokenLinksFetching, + } = useGetBrokenLinks(siteName, isBrokenLinksReporterEnabled) + + const isBrokenLinksLoading = + brokenLinks?.status === "loading" || isBrokenLinksFetching + + return ( + + + + + + {pageCmsUrl.split("/").at(-1)} + + + + + {isBrokenLinksLoading + ? "Re-scanning page..." + : `${detailedErrorArr.length} broken references found`} + + + View page on staging + + + Edit page on CMS + + + {!isBrokenLinksLoading && detailedErrorArr.length === 0 && ( + + )} + + {(isBrokenLinksLoading || detailedErrorArr.length !== 0) && ( + <> + + + {`After fixing the references , you can click the "Run check for this page" in the top right hand corner.`} + + + setPageNum(newPage)} + /> + + + + + + + + + + + {!isBrokenLinksLoading && ( + + {detailedErrorArr + .slice((pageNum - 1) * 5, pageNum * 5) + .map((link) => { + const isBrokenLink = link.type === "broken-link" + return ( + + + + + ) + })} + + )} + {isBrokenLinksLoading && ( + + + + + + )} +
+ + + Type + + + + + Reference and Error + +
+ + {getErrorText(link)} + + + + {isBrokenLink && ( + + {link.linkedText + ? `"${link.linkedText}"` + : "Empty Link Text"} + + )} + + {link.linkToAsset + ? link.linkToAsset + : "No URL linked"} + + + + + {getSuggestion(link)} + + + +
+ +
+
+ + )} +
+
+
+ ) +} + +type DetailedNonPermaLinkError = NonPermalinkError & { + detailedType: + | "broken-image" + | "broken-file" + | "email" + | "missing-https" + | "broken-link" +} + +const isEmailError = (error: NonPermalinkError): boolean => { + return ( + error.type === "broken-link" && + /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(error.linkToAsset) + ) +} + +const isMissingHttpsError = (error: NonPermalinkError): boolean => { + return !/^(https:\/\/|http:\/\/|\/)/.test(error.linkToAsset) +} + +const generateDetailedError = ( + errors: NonPermalinkError[] +): DetailedNonPermaLinkError[] => { + return errors.map((error) => { + if (error.type === "broken-image") + return { ...error, detailedType: "broken-image" } + if (error.type === "broken-file") + return { ...error, detailedType: "broken-file" } + if (isEmailError(error)) return { ...error, detailedType: "email" } + if (isMissingHttpsError(error)) + return { ...error, detailedType: "missing-https" } + return { ...error, detailedType: "broken-link" } + }) +} + +const getErrorText = (error: DetailedNonPermaLinkError) => { + switch (error.detailedType) { + case "broken-image": + return "Image" + case "broken-file": + return "File" + case "missing-https": + case "broken-link": + return "Hyperlink" + case "email": + return "Email" + default: { + const exception: never = error.detailedType + throw new Error(exception) + } + } +} + +const getSuggestion = (error: DetailedNonPermaLinkError): string => { + switch (error.detailedType) { + case "broken-image": + return "Image doesn't exist." + case "broken-file": + return "File doesn't exist." + case "broken-link": + return "Page doesn't exist." + case "missing-https": + return 'Add a "https://".' + case "email": + return 'Add a "mailto:".' + default: { + const exception: never = error.detailedType + throw new Error(exception) + } + } +} + +const LinkReportModalNoBrokenLink = (props: Omit) => { + const { onClose } = props + const { siteName } = useParams<{ siteName: string }>() + return ( +
+ + + + Hurrah! You’ve fixed all broken references on this page. + + + You can come back to scan this page again if you make edits to it. + + + + +
+ ) +} + +const LinkReportModalLoading = () => { + const spin = keyframes` + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + ` + + return ( +
+ + + + Taking another closer look at this page... + + + Broken references will appear here. This might take a while. + + +
+ ) +} diff --git a/src/layouts/SiteDashboard/SiteDashboard.tsx b/src/layouts/SiteDashboard/SiteDashboard.tsx index 01ddc2823..149c279ee 100644 --- a/src/layouts/SiteDashboard/SiteDashboard.tsx +++ b/src/layouts/SiteDashboard/SiteDashboard.tsx @@ -236,7 +236,10 @@ export const SiteDashboard = (): JSX.Element => { {brokenLinks?.status === "success" && - brokenLinks?.errors.length} + brokenLinks?.errors.filter( + (error) => + error.type !== "duplicate-permalink" + ).length} broken references found