diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a60a69f2..6119e7b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.77.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.76.0...v0.77.0) + +- feat/feature-flag-link-checker [`#1811`](https://github.com/isomerpages/isomercms-frontend/pull/1811) +- feat/addBrokenLinksReporter [`#1810`](https://github.com/isomerpages/isomercms-frontend/pull/1810) +- fix(dompurify): further limit src [`#1809`](https://github.com/isomerpages/isomercms-frontend/pull/1809) +- 0.76.0 [`#1803`](https://github.com/isomerpages/isomercms-frontend/pull/1803) +- chore(linksReport): add todo for special pg [`8837e07`](https://github.com/isomerpages/isomercms-frontend/commit/8837e079b04d8958509ecaf6f3945927f45ff1fb) + #### [v0.76.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.75.0...v0.76.0) +> 8 February 2024 + - fix: update manifest [`#1802`](https://github.com/isomerpages/isomercms-frontend/pull/1802) - feat(cards): make the card link optional [`#1801`](https://github.com/isomerpages/isomercms-frontend/pull/1801) - chore: fix broken edit link button [`#1800`](https://github.com/isomerpages/isomercms-frontend/pull/1800) @@ -126,15 +136,16 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). > 6 December 2023 +- fix(embed-views): add a max width [`#1729`](https://github.com/isomerpages/isomercms-frontend/pull/1729) +- fix(blockwrapper): remove padding [`#1728`](https://github.com/isomerpages/isomercms-frontend/pull/1728) - fix(editpagelayout): shift context call elsewhere [`c95b2ab`](https://github.com/isomerpages/isomercms-frontend/commit/c95b2ab7e4909d0b9852e8603bc5995a716dc743) - fix(legacyeditpage): minor style fix so the editor grows [`ad8b36c`](https://github.com/isomerpages/isomercms-frontend/commit/ad8b36cfa09c2bafb4e1c44d65343d2621e2d1c8) #### [v0.63.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.62.0...v0.63.0) -> 6 December 2023 +> 5 December 2023 -- fix(embed-views): add a max width [`#1729`](https://github.com/isomerpages/isomercms-frontend/pull/1729) -- fix(blockwrapper): remove padding [`#1728`](https://github.com/isomerpages/isomercms-frontend/pull/1728) +- chore: swap preview fonts [`#1715`](https://github.com/isomerpages/isomercms-frontend/pull/1715) - feat(editor): enhance image bubble menu with more functions [`#1721`](https://github.com/isomerpages/isomercms-frontend/pull/1721) - feat(editor): feature flag complex blocks [`#1720`](https://github.com/isomerpages/isomercms-frontend/pull/1720) - fix(tables): update table behaviour [`#1722`](https://github.com/isomerpages/isomercms-frontend/pull/1722) @@ -154,12 +165,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat(tiptap): add card grid block [`#1701`](https://github.com/isomerpages/isomercms-frontend/pull/1701) - feat(tiptap): allow inserting of complex blocks [`#1697`](https://github.com/isomerpages/isomercms-frontend/pull/1697) - 0.62.0 to develop [`#1710`](https://github.com/isomerpages/isomercms-frontend/pull/1710) +- fix(media): fix logic for disabling button in create media folder modal [`#1712`](https://github.com/isomerpages/isomercms-frontend/pull/1712) #### [v0.62.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.61.0...v0.62.0) > 28 November 2023 -- fix(media): fix logic for disabling button in create media folder modal [`#1712`](https://github.com/isomerpages/isomercms-frontend/pull/1712) - Chore/update regex [`#1703`](https://github.com/isomerpages/isomercms-frontend/pull/1703) - fix(media): allow creating folders in empty folder [`#1702`](https://github.com/isomerpages/isomercms-frontend/pull/1702) - 0.61.0 (develop) [`#1700`](https://github.com/isomerpages/isomercms-frontend/pull/1700) @@ -175,12 +186,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat(tiptap): add Instagram embed proper node handler [`#1688`](https://github.com/isomerpages/isomercms-frontend/pull/1688) - fix(updateLinkModal): update link + proper default [`#1692`](https://github.com/isomerpages/isomercms-frontend/pull/1692) - 0.60.0 [`#1690`](https://github.com/isomerpages/isomercms-frontend/pull/1690) +- fix(input): empty input validation [`#1691`](https://github.com/isomerpages/isomercms-frontend/pull/1691) #### [v0.60.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.59.0...v0.60.0) > 21 November 2023 -- fix(input): empty input validation [`#1691`](https://github.com/isomerpages/isomercms-frontend/pull/1691) - is-714/chore: add copy and layout changes [`#1687`](https://github.com/isomerpages/isomercms-frontend/pull/1687) - IS-780 fix(height): fix editor + preview heights [`#1686`](https://github.com/isomerpages/isomercms-frontend/pull/1686) - IS-782-Editor-File-text-always-comes-out-as-file-regardless-of-text [`#1685`](https://github.com/isomerpages/isomercms-frontend/pull/1685) @@ -257,13 +268,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat(media): introduce enhancement to media pages [`#1628`](https://github.com/isomerpages/isomercms-frontend/pull/1628) - feat(blocks): rollback handler [`#1603`](https://github.com/isomerpages/isomercms-frontend/pull/1603) - 0.54.0 to develop [`#1622`](https://github.com/isomerpages/isomercms-frontend/pull/1622) +- feat(media): introduce new image preview card common component [`#1620`](https://github.com/isomerpages/isomercms-frontend/pull/1620) +- fix(editpage): change query key from object to array [`#1623`](https://github.com/isomerpages/isomercms-frontend/pull/1623) #### [v0.54.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.53.0...v0.54.0) -> 26 October 2023 +> 25 October 2023 -- feat(media): introduce new image preview card common component [`#1620`](https://github.com/isomerpages/isomercms-frontend/pull/1620) -- fix(editpage): change query key from object to array [`#1623`](https://github.com/isomerpages/isomercms-frontend/pull/1623) - Fix/login page layout [`#1619`](https://github.com/isomerpages/isomercms-frontend/pull/1619) - chore: add sgidlogin page story [`#1595`](https://github.com/isomerpages/isomercms-frontend/pull/1595) - IS-614-Make-it-easy-to-view-staging-on-mobile [`#1617`](https://github.com/isomerpages/isomercms-frontend/pull/1617) @@ -280,16 +291,16 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - feat: allow Isomer admins to edit on email-login [`#1604`](https://github.com/isomerpages/isomercms-frontend/pull/1604) - fix(settings): use env var [`#1609`](https://github.com/isomerpages/isomercms-frontend/pull/1609) - release/v0.52.0 [`#1600`](https://github.com/isomerpages/isomercms-frontend/pull/1600) - -#### [v0.52.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.51.1...v0.52.0) - -> 19 October 2023 - - fix: errors indexes [`#1610`](https://github.com/isomerpages/isomercms-frontend/pull/1610) - fix(navbar): fix race condition when retrieving second level data [`#1608`](https://github.com/isomerpages/isomercms-frontend/pull/1608) - fix(navbar): ensure all queries are loaded first [`#1607`](https://github.com/isomerpages/isomercms-frontend/pull/1607) - fix(media): support replacement of media src with spaces [`#1606`](https://github.com/isomerpages/isomercms-frontend/pull/1606) - chore(hooks): add timeout [`#1605`](https://github.com/isomerpages/isomercms-frontend/pull/1605) + +#### [v0.52.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.51.1...v0.52.0) + +> 18 October 2023 + - fix(media): adjust query key to load image in preview on upload [`#1602`](https://github.com/isomerpages/isomercms-frontend/pull/1602) - Feat/sgid govt rollout [`#1594`](https://github.com/isomerpages/isomercms-frontend/pull/1594) - feat(navbar): upgrade navbar to use v2 endpoints [`#1592`](https://github.com/isomerpages/isomercms-frontend/pull/1592) @@ -303,12 +314,12 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). > 18 October 2023 - fix(mediasselectmodal): update dir name [`#1596`](https://github.com/isomerpages/isomercms-frontend/pull/1596) +- fix(media): add limit [`#1591`](https://github.com/isomerpages/isomercms-frontend/pull/1591) #### [v0.51.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.50.0...v0.51.0) > 16 October 2023 -- fix(media): add limit [`#1591`](https://github.com/isomerpages/isomercms-frontend/pull/1591) - chore(hooks): remove `useGetMediaFolders` [`#1582`](https://github.com/isomerpages/isomercms-frontend/pull/1582) - feat(homepage): allow user to override changes when there is a conflict [`#1588`](https://github.com/isomerpages/isomercms-frontend/pull/1588) - ref(media): update media hooks + components [`#1581`](https://github.com/isomerpages/isomercms-frontend/pull/1581) @@ -355,13 +366,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). > 3 October 2023 +- fix(herocenteredlayout): make dropdown optional [`#1556`](https://github.com/isomerpages/isomercms-frontend/pull/1556) - fix(hero.scss): css fix [`af09212`](https://github.com/isomerpages/isomercms-frontend/commit/af09212f81dbf845eae7579632fecb2a26d3eb1a) #### [v0.47.0](https://github.com/isomerpages/isomercms-frontend/compare/v0.46.0...v0.47.0) > 3 October 2023 -- fix(herocenteredlayout): make dropdown optional [`#1556`](https://github.com/isomerpages/isomercms-frontend/pull/1556) - Fix/text cards design [`#1535`](https://github.com/isomerpages/isomercms-frontend/pull/1535) - chore(cleanup): delete unused styles [`#1548`](https://github.com/isomerpages/isomercms-frontend/pull/1548) - refactor(hero): duplicate changes from template [`#1551`](https://github.com/isomerpages/isomercms-frontend/pull/1551) diff --git a/package-lock.json b/package-lock.json index 34c9c5a45..acb5e35eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms-frontend", - "version": "0.76.0", + "version": "0.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms-frontend", - "version": "0.76.0", + "version": "0.77.0", "hasInstallScript": true, "dependencies": { "@braintree/sanitize-url": "^6.0.1", diff --git a/package.json b/package.json index 3fc7b622c..d1627039c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms-frontend", - "version": "0.76.0", + "version": "0.77.0", "private": true, "engines": { "node": ">=16.0.0" diff --git a/src/assets/images/NoBrokenLinksImage.tsx b/src/assets/images/NoBrokenLinksImage.tsx new file mode 100644 index 000000000..51bcb4077 --- /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 53f98f119..c40375bd7 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/featureFlags.ts b/src/constants/featureFlags.ts index fb17a5e8d..3845616a7 100644 --- a/src/constants/featureFlags.ts +++ b/src/constants/featureFlags.ts @@ -6,6 +6,7 @@ export const FEATURE_FLAGS = { RTE_ENABLED_BLOCKS: "rte_enabled_blocks", TIPTAP_EDITOR: "is-tiptap-enabled", IS_SHOW_STAGING_BUILD_STATUS_ENABLED: "is_show_staging_build_status_enabled", + IS_BROKEN_LINKS_REPORT_ENABLED: "is_broken_links_report_enabled", } as const export type FeatureFlagsType = typeof FEATURE_FLAGS[keyof typeof FEATURE_FLAGS] diff --git a/src/constants/queryKeys.ts b/src/constants/queryKeys.ts index 24e50314b..e9369c5b9 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 000000000..9d4ed94a9 --- /dev/null +++ b/src/hooks/siteDashboardHooks/useGetLinkChecker.ts @@ -0,0 +1,26 @@ +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, + isBrokenLinksReporterEnabled: boolean +): UseQueryResult => { + return useQuery( + [SITE_LINK_CHECKER_STATUS_KEY, siteName], + () => { + return LinkCheckerService.getLinkCheckerStatus({ + siteName, + isBrokenLinksReporterEnabled, + }) + }, + { + retry: false, + refetchInterval: 1000 * 10, + } + ) +} diff --git a/src/hooks/siteDashboardHooks/useRefreshLinkChecker.ts b/src/hooks/siteDashboardHooks/useRefreshLinkChecker.ts new file mode 100644 index 000000000..8e7f1c90b --- /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/EditPage/utils.ts b/src/layouts/EditPage/utils.ts index fbb2f11c0..b9f7efd10 100644 --- a/src/layouts/EditPage/utils.ts +++ b/src/layouts/EditPage/utils.ts @@ -6,6 +6,18 @@ import checkCSP from "utils/cspUtils" import { MediaData } from "types/directory" import { adjustImageSrcs } from "utils" +/** + * While we do have a CSP in place, we want to further restrict the content that + * that the user can input. + * NOTE: Any changes to this list should also be updated in backend code + */ +const ALLOWED_SRC = [ + "//www.instagram.com/embed.js", + "/jquery/resize-tables.js", + "/jquery/jquery.min.js", + "/jquery/bp-menu-new-tab.js", +] + DOMPurify.setConfig({ ADD_TAGS: ["iframe", "#comment", "script"], ADD_ATTR: [ @@ -26,11 +38,19 @@ DOMPurify.setConfig({ }) DOMPurify.addHook("uponSanitizeElement", (node, data) => { // Allow script tags if it has a src attribute - // Script sources are handled by our CSP sanitiser - if ( + const hasUnallowedSrcValue = + data.tagName === "script" && + !( + node.hasAttribute("src") && + node.innerHTML === "" && + ALLOWED_SRC.includes(node.getAttribute("src") ?? "") + ) + + const hasUnallowedScriptAttribute = data.tagName === "script" && - !(node.hasAttribute("src") && node.innerHTML === "") - ) { + (node.hasAttribute("href") || node.hasAttribute("xlink:href")) + + if (hasUnallowedSrcValue || hasUnallowedScriptAttribute) { // Adapted from https://github.com/cure53/DOMPurify/blob/e0970d88053c1c564b6ccd633b4af7e7d9a10375/src/purify.js#L719-L736 DOMPurify.removed.push({ element: node }) try { diff --git a/src/layouts/LinkReport/LinksReport.tsx b/src/layouts/LinkReport/LinksReport.tsx new file mode 100644 index 000000000..a14a5e5a5 --- /dev/null +++ b/src/layouts/LinkReport/LinksReport.tsx @@ -0,0 +1,371 @@ +import { + Box, + BreadcrumbItem, + Center, + HStack, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + VStack, +} from "@chakra-ui/react" +import { useFeatureIsOn } from "@growthbook/growthbook-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 => { + //! TODO: Fix bug for homepage + contact us + /** + * 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 isBrokenLinksReporterEnabled = useFeatureIsOn( + "is_broken_links_report_enabled" + ) + + const { data: brokenLinks } = useGetBrokenLinks( + siteName, + isBrokenLinksReporterEnabled + ) + + const isBrokenLinksLoading = brokenLinks?.status === "loading" + return ( +
+ + + Broken references report + + Experimental feature + + + + + This report contains a list of broken references found in your site. + + + + +
+ ) +} + +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 ( + + ) + })} + + + ) +} + +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 isBrokenLinksReporterEnabled = useFeatureIsOn( + "is_broken_links_report_enabled" + ) + const { siteName } = useParams<{ siteName: string }>() + const { data: brokenLinks, isError: isBrokenLinksError } = useGetBrokenLinks( + siteName, + isBrokenLinksReporterEnabled + ) + + if ( + !isBrokenLinksReporterEnabled || + 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 000000000..abd7ccc3b --- /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 e92ef5d48..aadb7946e 100644 --- a/src/layouts/SiteDashboard/SiteDashboard.tsx +++ b/src/layouts/SiteDashboard/SiteDashboard.tsx @@ -11,6 +11,7 @@ import { useDisclosure, VStack, } from "@chakra-ui/react" +import { useFeatureIsOn, useGrowthBook } from "@growthbook/growthbook-react" import { Button, Link } from "@opengovsg/design-system-react" import _ from "lodash" import { useEffect } from "react" @@ -43,6 +44,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 +96,16 @@ export const SiteDashboard = (): JSX.Element => { mutateAsync: updateViewedReviewRequests, } = useUpdateViewedReviewRequests() + const isBrokenLinksReporterEnabled = useFeatureIsOn( + "is_broken_links_report_enabled" + ) + + const { + data: brokenLinks, + isError: isBrokenLinksError, + isLoading: isBrokenLinksLoading, + } = useGetBrokenLinks(siteName, isBrokenLinksReporterEnabled) + const savedAt = getDateTimeFromUnixTime(siteInfo?.savedAt || 0) const publishedAt = getDateTimeFromUnixTime(siteInfo?.publishedAt || 0) @@ -198,6 +210,54 @@ export const SiteDashboard = (): JSX.Element => { )) )} + {isBrokenLinksReporterEnabled && ( + <> + {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 784dc5710..6f00f9ae7 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 000000000..0ff98aefb --- /dev/null +++ b/src/services/LinkCheckerService.ts @@ -0,0 +1,28 @@ +import { RepoErrorDto } from "types/linkReport" + +import { apiService } from "./ApiService" + +export const getLinkCheckerStatus = async ({ + siteName, + isBrokenLinksReporterEnabled, +}: { + siteName: string + isBrokenLinksReporterEnabled: boolean +}): Promise => { + if (!isBrokenLinksReporterEnabled) { + return { + status: "error", + } + } + 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/featureFlags.ts b/src/types/featureFlags.ts index 838e47aa2..06eee43ad 100644 --- a/src/types/featureFlags.ts +++ b/src/types/featureFlags.ts @@ -13,6 +13,7 @@ export interface FeatureFlags { [FEATURE_FLAGS.RTE_ENABLED_BLOCKS]: { blocks: RTEBlockValues[] } [FEATURE_FLAGS.TIPTAP_EDITOR]: boolean [FEATURE_FLAGS.IS_SHOW_STAGING_BUILD_STATUS_ENABLED]: boolean + [FEATURE_FLAGS.IS_BROKEN_LINKS_REPORT_ENABLED]: boolean } export type GBAttributes = { diff --git a/src/types/linkReport.ts b/src/types/linkReport.ts new file mode 100644 index 000000000..9b0c875c4 --- /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[] + }