diff --git a/package-lock.json b/package-lock.json index 34c9c5a450..f76f5f85db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "marked": "^4.0.12", "moment": "^2.29.4", "moment-timezone": "^0.5.35", + "papaparse": "^5.4.1", "postcss": "^8.4.21", "postcss-loader": "^7.2.4", "prop-types": "^15.8.1", @@ -30198,6 +30199,11 @@ "version": "1.0.11", "license": "(MIT AND Zlib)" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/param-case": { "version": "3.0.4", "dev": true, diff --git a/package.json b/package.json index 3fc7b622c7..0103965e0b 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "marked": "^4.0.12", "moment": "^2.29.4", "moment-timezone": "^0.5.35", + "papaparse": "^5.4.1", "postcss": "^8.4.21", "postcss-loader": "^7.2.4", "prop-types": "^15.8.1", diff --git a/src/assets/images/NoBrokenLinksImage.tsx b/src/assets/images/NoBrokenLinksImage.tsx new file mode 100644 index 0000000000..51bcb40778 --- /dev/null +++ b/src/assets/images/NoBrokenLinksImage.tsx @@ -0,0 +1,698 @@ +export const NoBrokenLinksImage = ( + props: React.SVGProps +): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 53f98f119b..c40375bd7a 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -30,3 +30,4 @@ export * from "./EditorAccordionImage" export * from "./EditorCardsImage" export * from "./EditorDividerImage" export * from "./EditorCardsPlaceholderImage" +export * from "./NoBrokenLinksImage" diff --git a/src/constants/queryKeys.ts b/src/constants/queryKeys.ts index 24e50314b5..e9369c5b95 100644 --- a/src/constants/queryKeys.ts +++ b/src/constants/queryKeys.ts @@ -22,6 +22,7 @@ export const SITE_DASHBOARD_INFO_KEY = "site-dashboard-info" export const SITE_DASHBOARD_REVIEW_REQUEST_KEY = "site-dashboard-review-request" export const SITE_DASHBOARD_COLLABORATORS_KEY = "site-dashboard-collaborators" export const SITE_DASHBOARD_LAUNCH_STATUS_KEY = "site-dashboard-launch-status" +export const SITE_LINK_CHECKER_STATUS_KEY = "site-link-checker-status" export const NOTIFICATIONS_KEY = "notifications-content" export const ALL_NOTIFICATIONS_KEY = "all-notifications" export const LIST_COLLABORATORS_KEY = "list-collaborators" diff --git a/src/hooks/siteDashboardHooks/useGetLinkChecker.ts b/src/hooks/siteDashboardHooks/useGetLinkChecker.ts new file mode 100644 index 0000000000..4eddfbe24f --- /dev/null +++ b/src/hooks/siteDashboardHooks/useGetLinkChecker.ts @@ -0,0 +1,22 @@ +import { UseQueryResult, useQuery } from "react-query" + +import { SITE_LINK_CHECKER_STATUS_KEY } from "constants/queryKeys" + +import * as LinkCheckerService from "services/LinkCheckerService" + +import { RepoErrorDto } from "types/linkReport" + +export const useGetBrokenLinks = ( + siteName: string +): UseQueryResult => { + return useQuery( + [SITE_LINK_CHECKER_STATUS_KEY, siteName], + () => { + return LinkCheckerService.getLinkCheckerStatus({ siteName }) + }, + { + retry: false, + refetchInterval: 1000 * 10, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/useRefreshLinkChecker.ts b/src/hooks/siteDashboardHooks/useRefreshLinkChecker.ts new file mode 100644 index 0000000000..8e7f1c90b6 --- /dev/null +++ b/src/hooks/siteDashboardHooks/useRefreshLinkChecker.ts @@ -0,0 +1,22 @@ +import { AxiosError } from "axios" +import { useMutation, UseMutationResult, useQueryClient } from "react-query" + +import { SITE_LINK_CHECKER_STATUS_KEY } from "constants/queryKeys" + +import * as LinkCheckerService from "services/LinkCheckerService" + +export const useRefreshLinkChecker = ( + siteName: string +): UseMutationResult => { + const queryClient = useQueryClient() + return useMutation( + async () => { + await LinkCheckerService.refreshLinkChecker({ siteName }) + }, + { + onSettled: () => { + queryClient.invalidateQueries([SITE_LINK_CHECKER_STATUS_KEY, siteName]) + }, + } + ) +} diff --git a/src/layouts/LinkReport/LinksReport.tsx b/src/layouts/LinkReport/LinksReport.tsx new file mode 100644 index 0000000000..68a8730b15 --- /dev/null +++ b/src/layouts/LinkReport/LinksReport.tsx @@ -0,0 +1,342 @@ +import { + Box, + BreadcrumbItem, + Center, + HStack, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + VStack, +} from "@chakra-ui/react" +import { Badge, Breadcrumb, Button, Link } from "@opengovsg/design-system-react" +import { Redirect, useParams } 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 { useErrorToast } from "utils" + +import { SiteViewHeader } from "../layouts/SiteViewLayout/SiteViewHeader" + +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 -> Feedback Form + * 4. /resourceRoom/resourceRmName/resourceCategory/resourceCatName/editPage/page.md -> resourceRmName/resourceCatName/page + */ + const paths = viewablePageInCms.split("/") + let breadcrumb = paths + .filter((_, index) => index % 2 === 0) + .slice(2) + .join(" / ") + .replace(/-/g, " ") + if (breadcrumb.endsWith(".md")) { + breadcrumb = breadcrumb.slice(0, -3) + } + + return breadcrumb +} + +export const LinksReportBanner = () => { + const { siteName } = useParams<{ siteName: string }>() + const { mutate: refreshLinkChecker } = useRefreshLinkChecker(siteName) + const onClick = () => { + refreshLinkChecker(siteName) + } + + const { data: brokenLinks } = useGetBrokenLinks(siteName) + + const isBrokenLinksLoading = brokenLinks?.status === "loading" + return ( +
+ + + Broken references report + + Experimental feature + + + + + This report contains a list of broken references found in your site. + + + + +
+ ) +} + +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 viewableLinkInStaging = stagingUrl + viewablePageInStaging.slice(1) // rm the leading `/` + + 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 ( + + ) + })} + + + ) +} + +const NoBrokenLinks = () => { + return ( +
+ + + + No broken links found + + + Your site is in good shape. No broken references were found. + + +
+ ) +} + +const ErrorLoading = () => { + const { siteName } = useParams<{ siteName: string }>() + const errorToast = useErrorToast() + errorToast({ + id: "broken_links_error", + description: `Failed to load broken links for ${siteName}. Please try again later.`, + }) + return +} + +const LinkBody = () => { + const { siteName } = useParams<{ siteName: string }>() + const { data: brokenLinks, isError: isBrokenLinksError } = useGetBrokenLinks( + siteName + ) + + if (isBrokenLinksError || brokenLinks?.status === "error") { + return + } + + if (brokenLinks?.status === "success") { + if (brokenLinks?.errors?.length === 0) { + return + } + + return + } + + return ( +
+ + + Scanning your site for broken references{" "} + + This may take a while... + +
+ ) +} + +export const LinksReport = () => { + return ( + <> + + + + + + + ) +} diff --git a/src/layouts/LinkReport/index.ts b/src/layouts/LinkReport/index.ts new file mode 100644 index 0000000000..abd7ccc3b7 --- /dev/null +++ b/src/layouts/LinkReport/index.ts @@ -0,0 +1 @@ +export { LinksReport } from "./LinksReport" diff --git a/src/layouts/SiteDashboard/SiteDashboard.tsx b/src/layouts/SiteDashboard/SiteDashboard.tsx index e92ef5d487..516ce6d94f 100644 --- a/src/layouts/SiteDashboard/SiteDashboard.tsx +++ b/src/layouts/SiteDashboard/SiteDashboard.tsx @@ -43,6 +43,7 @@ import { useGetCollaboratorsStatistics, useUpdateViewedReviewRequests, } from "hooks/siteDashboardHooks" +import { useGetBrokenLinks } from "hooks/siteDashboardHooks/useGetLinkChecker" import useRedirectHook from "hooks/useRedirectHook" import { getDateTimeFromUnixTime } from "utils/date" @@ -94,6 +95,12 @@ export const SiteDashboard = (): JSX.Element => { mutateAsync: updateViewedReviewRequests, } = useUpdateViewedReviewRequests() + const { + data: brokenLinks, + isError: isBrokenLinksError, + isLoading: isBrokenLinksLoading, + } = useGetBrokenLinks(siteName) + const savedAt = getDateTimeFromUnixTime(siteInfo?.savedAt || 0) const publishedAt = getDateTimeFromUnixTime(siteInfo?.publishedAt || 0) @@ -198,6 +205,50 @@ export const SiteDashboard = (): JSX.Element => { )) )} + + {isBrokenLinksLoading || brokenLinks?.status === "loading" ? ( + + ) : ( + <> + + Your site health + + {`Understand your site's broken references`} + + + + {isBrokenLinksError || brokenLinks?.status === "error" ? ( + + Unable to retrieve broken links report + + ) : ( + + + + + {brokenLinks?.status === "success" && + brokenLinks?.errors.length} + + + broken references found + + + + + View report + + + + )} + + + )} diff --git a/src/routing/RouteSelector.jsx b/src/routing/RouteSelector.jsx index 784dc57105..6f00f9ae71 100644 --- a/src/routing/RouteSelector.jsx +++ b/src/routing/RouteSelector.jsx @@ -17,6 +17,7 @@ import EditHomepage from "layouts/EditHomepage" import EditNavBar from "layouts/EditNavBar" import { EditPage } from "layouts/EditPage/index" import { Folders } from "layouts/Folders" +import { LinksReport } from "layouts/LinkReport/LinksReport" import { LoginPage } from "layouts/Login" import { SgidLoginCallbackPage } from "layouts/Login/SgidLoginCallbackPage" import { Media } from "layouts/Media" @@ -106,6 +107,12 @@ export const RouteSelector = () => { + + + + + + diff --git a/src/services/LinkCheckerService.ts b/src/services/LinkCheckerService.ts new file mode 100644 index 0000000000..ccf9dbc10c --- /dev/null +++ b/src/services/LinkCheckerService.ts @@ -0,0 +1,21 @@ +import { RepoErrorDto } from "types/linkReport" + +import { apiService } from "./ApiService" + +export const getLinkCheckerStatus = async ({ + siteName, +}: { + siteName: string +}): Promise => { + const endpoint = `/sites/${siteName}/getLinkCheckerStatus` + return (await apiService.get(endpoint)).data +} + +export const refreshLinkChecker = async ({ + siteName, +}: { + siteName: string +}): Promise => { + const endpoint = `/sites/${siteName}/checkLinks` + return (await apiService.post(endpoint)).data +} diff --git a/src/types/linkReport.ts b/src/types/linkReport.ts new file mode 100644 index 0000000000..9b0c875c4d --- /dev/null +++ b/src/types/linkReport.ts @@ -0,0 +1,64 @@ +export const RepoErrorTypes = { + BROKEN_LINK: "broken-link", + BROKEN_IMAGE: "broken-image", + BROKEN_FILE: "broken-file", + DUPLICATE_PERMALINK: "duplicate-permalink", +} as const + +export interface BrokenRefError { + linkToAsset: string + viewablePageInCms: string + viewablePageInStaging: string +} + +export interface BrokenLinkError extends BrokenRefError { + type: typeof RepoErrorTypes.BROKEN_LINK + linkedText: string +} + +export interface BrokenImageError extends BrokenRefError { + type: typeof RepoErrorTypes.BROKEN_IMAGE +} + +export interface BrokenFileError extends BrokenRefError { + type: typeof RepoErrorTypes.BROKEN_FILE + linkedText: string +} + +export interface DuplicatePermalinkError { + type: typeof RepoErrorTypes.DUPLICATE_PERMALINK + permalink: string + pagesUsingPermalink: string[] +} + +export type RepoError = + | BrokenLinkError + | BrokenImageError + | BrokenFileError + | DuplicatePermalinkError + +export type NonPermalinkErrorDto = Exclude + +// create a type guard for all errors except for duplicate permalink errors +export function isBrokenRefError( + error: RepoError +): error is NonPermalinkErrorDto { + return ( + error.type === RepoErrorTypes.BROKEN_LINK || + error.type === RepoErrorTypes.BROKEN_IMAGE || + error.type === RepoErrorTypes.BROKEN_FILE + ) +} + +export type NonPermalinkError = NonPermalinkErrorDto & { + breadcrumb: string +} + +export type RepoErrorDto = + | { + status: "error" | "loading" + } + | { + status: "success" + errors: RepoError[] + }