import React, { useCallback, useEffect, useMemo, useState } from "react"; import { createSearchParams, Link, useSearchParams } from "react-router-dom"; import useSWR from "swr"; import "./index.scss"; import { humanizeFlawName } from "../flaw-utils"; import { MainContentContainer } from "../ui/atoms/page-content"; import { Paginator } from "../ui/molecules/paginator"; import { useLocale } from "../hooks"; interface DocumentPopularity { value: number; ranking: number; } interface DocumentFlaws { name: string; value: number | string; countFixable: number; } interface Document { mdn_url: string; modified: string; title: string; popularity: DocumentPopularity; flaws: DocumentFlaws[]; } type Count = { [key: string]: number }; interface FlawsCounts { total: number; fixable: number; type: Count; macros: Count; } interface Counts { found: number; built: number; pages: number; flaws: FlawsCounts; } interface Times { built: number; } interface FlawLevel { name: string; level: string; ignored: boolean; } interface Data { counts: Counts; documents: Document[]; times: Times; flawLevels: FlawLevel[]; } interface Filters { mdn_url: string; title: string; popularity: string; fixableFlaws: string; flaws: string[]; page: number; sort_by: string; sort_reverse: boolean; search_flaws: string[]; } const defaultFilters: Filters = Object.freeze({ mdn_url: "", title: "", popularity: "", fixableFlaws: "", flaws: [], page: 1, sort_by: "popularity", sort_reverse: false, search_flaws: [], }); function withoutDefaultFilters(filters: Filters): Partial { return Object.fromEntries( Object.entries(filters).filter( ([key, value]) => JSON.stringify(defaultFilters[key]) !== JSON.stringify(value) ) ); } /** * Returns an array where * first element is the currently set (or default) filters * second element is a function to update a given set of partial filters. * NOTE: This only changes the given filters, and doesn't reset what is missing */ function useFiltersURL(): [Filters, (filters: Partial) => void] { const [searchParams, setSearchParams] = useSearchParams(); function groupParamsByKey(params: URLSearchParams): any { return [...params.entries()].reduce((acc, tuple) => { // getting the key and value from each tuple const [key, val] = tuple; if (Object.prototype.hasOwnProperty.call(acc, key)) { // if the current key is already an array, we'll add the value to it if (Array.isArray(acc[key])) { acc[key] = [...acc[key], val]; } else { // if it's not an array, but contains a value, we'll convert it into an array // and add the current value to it acc[key] = [acc[key], val]; } } else { // plain assignment if no special case is present acc[key] = val; } return acc; }, {}); } const filters = useMemo(() => { const searchParamsObject = groupParamsByKey(searchParams); if (searchParamsObject.page) { searchParamsObject.page = parseInt(searchParamsObject.page); } return { ...defaultFilters, ...searchParamsObject }; }, [searchParams]); const updateFiltersURL = useCallback( (partialFilters: Partial) => { const newSearchParams = withoutDefaultFilters({ ...filters, ...partialFilters, }) as Record; setSearchParams(newSearchParams); }, [filters, setSearchParams] ); const mustBeArrayKeys = ["flaws", "search_flaws"]; for (const key of mustBeArrayKeys) { if (filters[key] && !Array.isArray(filters[key])) { filters[key] = [filters[key]]; } } return [filters, updateFiltersURL]; } export default function AllFlaws() { const locale = useLocale(); const [filters] = useFiltersURL(); const [lastData, setLastData] = useState(null); useEffect(() => { let title = "Documents with flaws"; if (lastData) { title = `(${lastData.counts.found.toLocaleString()} found) ${title}`; } document.title = title; }, [lastData]); const getAPIUrl = useCallback(() => { const { sort_by, sort_reverse, page, ...restFilters } = filters; const params = createSearchParams({ ...restFilters, page: String(page), locale, sort: sort_by, reverse: JSON.stringify(sort_reverse), }); return `/_flaws?${params.toString()}`; }, [locale, filters]); const { data, error, isValidating } = useSWR( getAPIUrl(), async (url) => { let response; try { response = await fetch(url); } catch (ex) { throw ex; } if (!response.ok) { throw new Error(`${response.status} on ${url}`); } if (!response.headers.get("content-type").includes("application/json")) { throw new Error( `Response is not JSON (${response.headers.get("content-type")})` ); } // Always return a promise! return response.json(); }, { // revalidateOnFocus: false } ); useEffect(() => { if (data) { setLastData(data); } }, [data]); // XXX there's something weird about this logic let loading: React.ReactNode =  ; if (!data && !error) { if (lastData) { loading = Reloading...; } else { loading = Loading...; } } else if (isValidating) { loading = Reloading...; } const { page } = filters; const pageCount = lastData ? lastData.counts.pages : 0; return ( {loading} {error && } {lastData && (
)} {data && data.counts && } {data && }
); } function ShowSearchError({ error }) { return (

Search error

{error.toString()}
); } function BuildTimes({ times }: { times: Times }) { function format(ms: number) { if (ms > 1000) { const s = ms / 1000; return `${s.toFixed(1)} seconds`; } else { return `${Math.trunc(ms)} milliseconds`; } } return (

Time to find built documents {format(times.built)}

); } interface SearchFlawRow { flaw: string; search: string; } function serializeSearchFlaws(rows: SearchFlawRow[]) { return rows.map((row) => `${row.flaw}:${row.search}`); } function deserializeSearchFlaws(list: string[]) { const rows: SearchFlawRow[] = []; for (const row of list) { const [flaw, search] = row.split(":", 2); rows.push({ flaw, search }); } return rows; } function FilterControls({ flawLevels }: { flawLevels: FlawLevel[] }) { const [initialFilters, updateFiltersURL] = useFiltersURL(); const [filters, setFilters] = useState(initialFilters); const [searchFlawsRows, setSearchFlawsRows] = useState( deserializeSearchFlaws(initialFilters.search_flaws) ); useEffect(() => { // A little convenience DOM trick to put focus on the search input // after you've added a row or used the { setFilters({ ...filters, mdn_url: event.target.value }); }} onBlur={refreshFilters} /> { setFilters({ ...filters, title: event.target.value }); }} onBlur={refreshFilters} />

Popularity

{ setFilters({ ...filters, popularity: event.target.value }); }} onBlur={refreshFilters} />

Flaws

Search inside flaws

    {searchFlawsRows.map((row, i) => { return (
  • { setSearchFlawsRows( searchFlawsRows.map((row, j) => { if (i === j) { row.search = event.target.value; } return row; }) ); }} onBlur={() => { const serialized = serializeSearchFlaws(searchFlawsRows); setFilters({ ...filters, search_flaws: serialized }); refreshFilters(); }} />
  • ); })}

Fixable flaws

{ setFilters({ ...filters, fixableFlaws: event.target.value }); }} onBlur={refreshFilters} />

 

{hasFilters && ( )}
); } function equalObjects(obj1: object, obj2: object) { const keys1 = new Set(Object.keys(obj1)); const keys2 = new Set(Object.keys(obj2)); if (keys1.size !== keys2.size) { return false; } for (const key of keys1) { if (!keys2.has(key)) { return false; } } return Object.entries(obj1).every(([key, value]) => { const value2 = obj2[key]; if (typeof value !== typeof value2) { return false; } if (Array.isArray(value)) { return ( value.length === value2.length && value.every((v, i) => v === value2[i]) ); } else { return value === value2; } }); } function DocumentsTable({ locale, counts, documents, pageCount, page, }: { locale: string; counts: Counts; documents: Document[]; pageCount: number; page: number; }) { const [filters, updateFiltersURL] = useFiltersURL(); function setSort(key: string): void { updateFiltersURL( filters.sort_by === key ? { sort_reverse: !filters.sort_reverse } : { sort_by: key } ); } // https://gist.github.com/jlbruno/1535691/db35b4f3af3dcbb42babc01541410f291a8e8fac function getGetOrdinal(n: number) { const s = ["th", "st", "nd", "rd"]; const v = n % 100; return n.toLocaleString() + (s[(v - 20) % 10] || s[v] || s[0]); } function summarizeFlaws(flaws: DocumentFlaws[]) { // Return a one-liner about all the flaws const totalCountFixable = flaws.reduce( (acc, flaw) => flaw.countFixable + acc, 0 ); const bits = flaws.map((flaw) => { return `${humanizeFlawName(flaw.name)}: ${flaw.value}`; }); return ( <> {bits.join(", ")}{" "} ({totalCountFixable} fixable) ); } function TH({ id, title }: { id: string; title: string }) { return ( setSort(id)} className="sortable"> {title}{" "} {filters.sort_by === id ? (filters.sort_reverse ? "↓" : "↑") : null} ); } function getHighlightedText(text: string, highlight: string) { // Split on highlight term and include term into parts, ignore case const parts = text.split(new RegExp(`(${highlight})`, "gi")); return ( {" "} {parts.map((part, i) => ( {part} ))}{" "} ); } function showBriefURL(uri: string) { const [left, right] = uri.split(/\/docs\//, 2); return ( <> {left}/docs/ {filters.mdn_url ? getHighlightedText(right, filters.mdn_url) : right} ); } return (

Documents with flaws found ({counts.found.toLocaleString()}){" "} {pageCount > 1 && ( page {page}/{pageCount} )}

{!counts.built ? ( ) : (

{counts.built.toLocaleString()} documents built ({locale})

)} {documents.map((doc: Document) => { return ( ); })}
{showBriefURL(doc.mdn_url)} {filters.title ? getHighlightedText(doc.title, filters.title) : doc.title} {!doc.popularity.ranking ? "n/a" : `${getGetOrdinal(doc.popularity.ranking)}`} {summarizeFlaws(doc.flaws)}
); } function WarnAboutNothingBuilt({ locale }) { return (

No documents have been built, so no flaws can be found

Run yarn build --locale {locale.toLowerCase()} to build all documents for the current locale.

); } function AllFlawCounts({ counts }: { counts: FlawsCounts }) { const typesSorted = Object.entries(counts.type).sort((a, b) => { return a[1] - b[1]; }); const macrosSorted = Object.entries(counts.macros).sort((a, b) => { return a[1] - b[1]; }); return (

Flaws counts

Total {counts.total.toLocaleString()}
Fixable {counts.fixable.toLocaleString()}

Breakdown by type

{typesSorted.map(([key, value]) => { return ( ); })}
Type Count
{humanizeFlawName(key)} {value.toLocaleString()}

Breakdown by macros flaws

{macrosSorted.map(([key, value]) => { return ( ); })}
Name Count
{key} {value.toLocaleString()}
); }