diff --git a/.eslintrc.js b/.eslintrc.js index 0c6d891c..72d14929 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,22 +3,24 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], + project: ['./tsconfig.json'] }, env: { - jest: true, + jest: true }, plugins: [ '@typescript-eslint', - 'import', + 'import' ], extends: [ - 'plugin:@next/next/recommended', + 'next', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', - 'prettier', + 'plugin:import/typescript', + 'standard' ], rules: { + camelcase: 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-argument': 'off', @@ -26,9 +28,8 @@ module.exports = { '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/no-unused-vars': ['error', { 'destructuredArrayIgnorePattern': '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { destructuredArrayIgnorePattern: '^_' }], '@typescript-eslint/restrict-template-expressions': 'off', - 'arrow-body-style': 'off', 'arrow-body-style': 'warn', 'implicit-arrow-linebreak': 'off', 'jsx-a11y/alt-text': 'off', @@ -41,16 +42,32 @@ module.exports = { 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', 'import/no-unused-modules': [2, { - "unusedExports": true, - "ignoreExports": [ - 'pages/', - 'components/roadmap/', + unusedExports: true, + ignoreExports: [ + 'hooks/useEffectDebugger.ts', 'lib/backend/saveIssueDataToFile.ts', 'lib/mergeStarMapsErrorGroups.ts', 'lib/addStarMapsErrorsToStarMapsErrorGroups.ts', - 'playwright.config.ts', + 'pages/', + 'playwright.config.ts' ] - }] + }], + 'import/order': [ + 'error', + { + groups: [ + 'builtin', // Built-in types are first + ['external', 'unknown'], + ['parent', 'sibling', 'internal', 'index'] + ], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + caseInsensitive: true + }, + warnOnUnassignedImports: true + } + ] }, settings: { 'import/parsers': { @@ -60,8 +77,8 @@ module.exports = { typescript: {}, node: { extensions: ['.jsx', '.jsx', '.ts', '.tsx'], - moduleDirectory: ['node_modules', 'lib/', 'components/', 'hooks/', 'pages/'], + moduleDirectory: ['node_modules', 'lib/', 'components/', 'hooks/', 'pages/'] } } } -}; +} diff --git a/components/RoadmapForm.tsx b/components/RoadmapForm.tsx index dbd4b09e..f347e577 100644 --- a/components/RoadmapForm.tsx +++ b/components/RoadmapForm.tsx @@ -1,34 +1,34 @@ -import { Router, useRouter } from 'next/router'; -import { Button, FormControl, FormErrorMessage, Input, InputGroup, InputLeftElement, InputRightElement, Text } from '@chakra-ui/react'; import { SearchIcon } from '@chakra-ui/icons' -import React, { useEffect, useState } from 'react'; +import { Button, FormControl, FormErrorMessage, Input, InputGroup, InputLeftElement, InputRightElement, Text } from '@chakra-ui/react' +import { isEmpty } from 'lodash' +import { Router, useRouter } from 'next/router' +import React, { useEffect, useState } from 'react' -import { useGlobalLoadingState } from '../hooks/useGlobalLoadingState'; +import useCheckMobileScreen from '../hooks/useCheckSmallScreen' +import { setCurrentIssueUrl, useCurrentIssueUrl } from '../hooks/useCurrentIssueUrl' +import { useGlobalLoadingState } from '../hooks/useGlobalLoadingState' +import { useViewMode } from '../hooks/useViewMode' +import { ViewMode } from '../lib/enums' +import { getValidUrlFromInput } from '../lib/getValidUrlFromInput' +import { paramsFromUrl } from '../lib/paramsFromUrl' import styles from './RoadmapForm.module.css' import theme from './theme/constants' -import { setCurrentIssueUrl, useCurrentIssueUrl } from '../hooks/useCurrentIssueUrl'; -import { paramsFromUrl } from '../lib/paramsFromUrl'; -import { getValidUrlFromInput } from '../lib/getValidUrlFromInput'; -import { useViewMode } from '../hooks/useViewMode'; -import { ViewMode } from '../lib/enums'; -import { isEmpty } from 'lodash'; -import useCheckMobileScreen from '../hooks/useCheckSmallScreen'; -export function RoadmapForm() { - const router = useRouter(); - const globalLoadingState = useGlobalLoadingState(); - const currentIssueUrl = useCurrentIssueUrl(); - const [issueUrl, setIssueUrl] = useState(); - const [error, setError] = useState(null); - const [isInputBlanked, setIsInputBlanked] = useState(false); - const [isSmallScreen] = useCheckMobileScreen(); - const viewMode = useViewMode() as ViewMode; +export function RoadmapForm () { + const router = useRouter() + const globalLoadingState = useGlobalLoadingState() + const currentIssueUrl = useCurrentIssueUrl() + const [issueUrl, setIssueUrl] = useState() + const [error, setError] = useState(null) + const [isInputBlanked, setIsInputBlanked] = useState(false) + const [isSmallScreen] = useCheckMobileScreen() + const viewMode = useViewMode() as ViewMode useEffect(() => { if (!isInputBlanked && isEmpty(currentIssueUrl) && window.location.pathname.length > 1) { try { - const urlObj = getValidUrlFromInput(window.location.pathname.replace('/roadmap', '')); - setCurrentIssueUrl(urlObj.toString()); + const urlObj = getValidUrlFromInput(window.location.pathname.replace('/roadmap', '')) + setCurrentIssueUrl(urlObj.toString()) } catch {} } }, [currentIssueUrl, isInputBlanked]) @@ -36,84 +36,88 @@ export function RoadmapForm() { useEffect(() => { const asyncFn = async () => { if (router.isReady) { - if (!issueUrl) return; + if (!issueUrl) return try { - const params = paramsFromUrl(issueUrl); + const params = paramsFromUrl(issueUrl) if (params) { - const { owner, repo, issue_number } = params; - setIssueUrl(null); + const { owner, repo, issue_number } = params + setIssueUrl(null) if (window.location.pathname.includes(`github.com/${owner}/${repo}/issues/${issue_number}`)) { setTimeout(() => { /** * Clear the error after a few seconds. */ - setError(null); - }, 5000); - throw new Error('Already viewing this issue'); + setError(null) + }, 5000) + throw new Error('Already viewing this issue') } - await router.push(`/roadmap/github.com/${owner}/${repo}/issues/${issue_number}#${viewMode}`); - globalLoadingState.stop(); + await router.push(`/roadmap/github.com/${owner}/${repo}/issues/${issue_number}#view=${viewMode}`) + globalLoadingState.stop() } } catch (err) { - setError(err as Error); - globalLoadingState.stop(); + setError(err as Error) + globalLoadingState.stop() } } - }; - asyncFn(); - }, [router, issueUrl, viewMode, globalLoadingState]); + } + asyncFn() + }, [router, issueUrl, viewMode, globalLoadingState]) const formSubmit = (e) => { - e.preventDefault(); - globalLoadingState.start(); - setError(null); + e.preventDefault() + globalLoadingState.start() + setError(null) try { if (currentIssueUrl == null) { - throw new Error('currentIssueUrl is null'); + throw new Error('currentIssueUrl is null') } - const newUrl = getValidUrlFromInput(currentIssueUrl); - setIssueUrl(newUrl.toString()); + const newUrl = getValidUrlFromInput(currentIssueUrl) + setIssueUrl(newUrl.toString()) } catch (err) { - setError(err as Error); - globalLoadingState.stop(); + setError(err as Error) + globalLoadingState.stop() } } const inputRightElement = ( - - ); + ) const onChangeHandler = (e) => { - setIsInputBlanked(true); + setIsInputBlanked(true) setCurrentIssueUrl(e.target.value ?? '') - }; + } - Router.events.on('routeChangeStart', (...events) => { - globalLoadingState.start(); - const path = events[0]; - if (path === '/') { - setIsInputBlanked(true); - setCurrentIssueUrl(''); - return; + useEffect(() => { + const handleRouteChange = (...events) => { + globalLoadingState.start() + const path = events[0] + if (path === '/') { + setIsInputBlanked(true) + setCurrentIssueUrl('') + return + } + const currentUrl = getValidUrlFromInput(path.split('#')[0].replace('/roadmap/', '')) + currentUrl.searchParams.delete('crumbs') + setCurrentIssueUrl(currentUrl.toString()) } - const currentUrl = getValidUrlFromInput(path.split('#')[0].replace('/roadmap/', '')); - currentUrl.searchParams.delete('crumbs'); - setCurrentIssueUrl(currentUrl.toString()); - }); + Router.events.on('routeChangeStart', handleRouteChange) + + return () => { + Router.events.off('routeChangeStart', handleRouteChange) + } + }, [globalLoadingState]) return ( - isSmallScreen ? - : -
+ isSmallScreen + ? + : - } - /> + - + {inputRightElement} {error?.message} - ); + ) } diff --git a/components/RoadmapList/BulletConnector.tsx b/components/RoadmapList/BulletConnector.tsx index f66e353e..aea1b815 100644 --- a/components/RoadmapList/BulletConnector.tsx +++ b/components/RoadmapList/BulletConnector.tsx @@ -1,5 +1,6 @@ -import React from 'react'; -import styles from './BulletConnector.module.css'; +import React from 'react' + +import styles from './BulletConnector.module.css' /** * The vertical line connecting each bulletIcon across all rows diff --git a/components/RoadmapList/BulletIcon.tsx b/components/RoadmapList/BulletIcon.tsx index 4503583f..f7e3c474 100644 --- a/components/RoadmapList/BulletIcon.tsx +++ b/components/RoadmapList/BulletIcon.tsx @@ -1,5 +1,6 @@ import { CircularProgress } from '@chakra-ui/react' import React from 'react' + import styles from './BulletIcon.module.css' export default function BulletIcon ({ completion_rate }: {completion_rate: number}) { diff --git a/components/RoadmapList/RoadmapListItemDefault.tsx b/components/RoadmapList/RoadmapListItemDefault.tsx index 2dd3a97d..d5a719d3 100644 --- a/components/RoadmapList/RoadmapListItemDefault.tsx +++ b/components/RoadmapList/RoadmapListItemDefault.tsx @@ -1,18 +1,18 @@ -import { Grid, GridItem, Center, Link, HStack, Text, Skeleton } from '@chakra-ui/react'; -import { LinkIcon } from '@chakra-ui/icons'; -import NextLink from 'next/link'; -import { useRouter } from 'next/router'; -import React from 'react'; +import { LinkIcon } from '@chakra-ui/icons' +import { Grid, GridItem, Center, Link, HStack, Text, Skeleton } from '@chakra-ui/react' +import NextLink from 'next/link' +import { useRouter } from 'next/router' +import React from 'react' -import SvgGitHubLogo from '../icons/svgr/SvgGitHubLogo'; -import BulletConnector from './BulletConnector'; -import BulletIcon from './BulletIcon'; -import { paramsFromUrl } from '../../lib/paramsFromUrl'; -import { dayjs } from '../../lib/client/dayjs'; -import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild'; -import { ViewMode } from '../../lib/enums'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import { ListIssueViewModel } from './types'; +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import { dayjs } from '../../lib/client/dayjs' +import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild' +import { ViewMode } from '../../lib/enums' +import { paramsFromUrl } from '../../lib/paramsFromUrl' +import SvgGitHubLogo from '../icons/svgr/SvgGitHubLogo' +import BulletConnector from './BulletConnector' +import BulletIcon from './BulletIcon' +import { ListIssueViewModel } from './types' interface RoadmapListItemDefaultProps { issue: ListIssueViewModel @@ -22,7 +22,7 @@ interface RoadmapListItemDefaultProps { function TitleText ({ hasChildren, issue }: Pick & {hasChildren: boolean}) { return ( - + {hasChildren ? : null} {issue.title} ) @@ -45,7 +45,7 @@ function TitleTextMaybeLink ({ issue, hasChildren, index, childLink }: Pick - - + + {owner}/{repo}#{issue_number} diff --git a/components/RoadmapList/index.tsx b/components/RoadmapList/index.tsx index be986888..ecc80429 100644 --- a/components/RoadmapList/index.tsx +++ b/components/RoadmapList/index.tsx @@ -1,14 +1,10 @@ -import React, { useState } from 'react'; -import { Radio, RadioGroup, Stack, Text } from '@chakra-ui/react'; +import { Radio, RadioGroup, Stack, Text } from '@chakra-ui/react' +import React, { ReactElement, useContext, useState } from 'react' -import { IssueDataViewInput } from '../../lib/types'; -import RoadmapListItemDefault from './RoadmapListItemDefault'; -import styles from './RoadmapList.module.css'; -import { getTimeFromDateString } from '../../lib/helpers'; - -interface RoadmapListProps extends IssueDataViewInput { - maybe?: unknown -} +import { getTimeFromDateString } from '../../lib/helpers' +import { IssueDataStateContext } from '../roadmap/contexts' +import styles from './RoadmapList.module.css' +import RoadmapListItemDefault from './RoadmapListItemDefault' /** * Sorts milestones by due date, in ascending order (2022-01-01 before 2023-01-01) with invalid dates at the end. @@ -27,16 +23,22 @@ function sortMilestones (a, b) { return aTime - bTime } -export default function RoadmapList({ issueDataState }: RoadmapListProps): JSX.Element { +export default function RoadmapList (): ReactElement | null { const [groupBy, setGroupBy] = useState('directChildren') + const issueDataState = useContext(IssueDataStateContext) - const [isDevModeGroupBy, _setIsDevModeGroupBy] = useState(false); - const [isDevModeDuplicateDates, _setIsDevModeDuplicateDates] = useState(false); - const [dupeDateToggleValue, setDupeDateToggleValue] = useState('show'); - const flattenedIssues = issueDataState.children.flatMap((issueData) => issueData.get({ noproxy: true })) + // eslint-disable-next-line no-unused-vars + const [isDevModeGroupBy, _setIsDevModeGroupBy] = useState(false) + // eslint-disable-next-line no-unused-vars + const [isDevModeDuplicateDates, _setIsDevModeDuplicateDates] = useState(false) + const [dupeDateToggleValue, setDupeDateToggleValue] = useState('show') + if (issueDataState.ornull === null) { + return null + } + const flattenedIssues = issueDataState.ornull.children.flatMap((issueData) => issueData.get({ noproxy: true })) const sortedIssuesWithDueDates = flattenedIssues.sort(sortMilestones) - let groupByToggle: JSX.Element | null = null + let groupByToggle: ReactElement | null = null if (isDevModeGroupBy) { groupByToggle = ( @@ -51,7 +53,7 @@ export default function RoadmapList({ issueDataState }: RoadmapListProps): JSX.E ) } - let dupeDateToggle: JSX.Element | null = null + let dupeDateToggle: ReactElement | null = null if (isDevModeDuplicateDates) { dupeDateToggle = ( diff --git a/components/RoadmapList/types.ts b/components/RoadmapList/types.ts index e74a3e7a..402bb04d 100644 --- a/components/RoadmapList/types.ts +++ b/components/RoadmapList/types.ts @@ -1,5 +1,6 @@ -import { ImmutableObject } from '@hookstate/core'; -import { IssueData } from '../../lib/types'; +import { ImmutableObject } from '@hookstate/core' + +import { IssueData } from '../../lib/types' // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ListIssueViewModel extends Pick, 'html_url' | 'title' | 'completion_rate' | 'due_date' | 'description' | 'children' | 'parent'> { diff --git a/components/breadcrumb/StarmapsBreadcrumbItem.tsx b/components/breadcrumb/StarmapsBreadcrumbItem.tsx index 66e7a6fd..fe65edbc 100644 --- a/components/breadcrumb/StarmapsBreadcrumbItem.tsx +++ b/components/breadcrumb/StarmapsBreadcrumbItem.tsx @@ -1,25 +1,26 @@ -import { BreadcrumbItem, BreadcrumbItemProps, BreadcrumbLink } from '@chakra-ui/react'; -import React from 'react'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; +import { BreadcrumbItem, BreadcrumbItemProps, BreadcrumbLink } from '@chakra-ui/react' +import React from 'react' + +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' interface StarmapsBreadcrumbItemProps extends BreadcrumbItemProps { title: string; url: string; } -export function StarmapsBreadcrumbItem({ title, url, ...props }: StarmapsBreadcrumbItemProps) { - const globalLoadingState = useGlobalLoadingState(); +export function StarmapsBreadcrumbItem ({ title, url, ...props }: StarmapsBreadcrumbItemProps) { + const globalLoadingState = useGlobalLoadingState() const breadcrumbItemProps = { ...props, color: '#4987BD', onClick: () => { - globalLoadingState.start(); - setTimeout(() => globalLoadingState.stop(), 5000); + globalLoadingState.start() + setTimeout(() => globalLoadingState.stop(), 5000) }, className: 'js-breadcrumbItem js-breadcrumbItem-link', isCurrentPage: false, - cursor: 'pointer', + cursor: 'pointer' } return ( @@ -28,5 +29,5 @@ export function StarmapsBreadcrumbItem({ title, url, ...props }: StarmapsBreadcr {title} - ); + ) } diff --git a/components/breadcrumb/index.tsx b/components/breadcrumb/index.tsx index b380ca90..199916f3 100644 --- a/components/breadcrumb/index.tsx +++ b/components/breadcrumb/index.tsx @@ -1,12 +1,13 @@ import { - Breadcrumb, + Breadcrumb } from '@chakra-ui/react' -import { useRouter } from 'next/router'; +import { useRouter } from 'next/router' import React, { useMemo } from 'react' -import { useViewMode } from '../../hooks/useViewMode'; -import { getCrumbDataFromCrumbDataArray, routerQueryToCrumbArrayData } from '../../lib/breadcrumbs'; -import { ViewMode } from '../../lib/enums'; -import { StarmapsBreadcrumbItem } from './StarmapsBreadcrumbItem'; + +import { useViewMode } from '../../hooks/useViewMode' +import { getCrumbDataFromCrumbDataArray, routerQueryToCrumbArrayData } from '../../lib/breadcrumbs' +import { ViewMode } from '../../lib/enums' +import { StarmapsBreadcrumbItem } from './StarmapsBreadcrumbItem' /** * Test with "http://localhost:3000/roadmap/github.com/ipfs/ipfs-gui/issues/110?crumbs=%5B%5B%22ipfs%2Fipfs-gui%23106%22%2C%22IPFS+Ignite+Roadmap+-+2022-%3E2023%22%5D%2C%5B%22ipfs%2Fipfs-gui%23124%22%2C%22Theme%3A+UX+%26+UI+Improvements%22%5D%5D#simple" @@ -15,46 +16,46 @@ interface StarmapsBreadcrumbProps { currentTitle: string; } -export function StarmapsBreadcrumb({ currentTitle }: StarmapsBreadcrumbProps) { - const router = useRouter(); - const viewMode = useViewMode(); +export function StarmapsBreadcrumb ({ currentTitle }: StarmapsBreadcrumbProps) { + const router = useRouter() + const viewMode = useViewMode() - const urlCrumbDataArray = routerQueryToCrumbArrayData(router.query); + const urlCrumbDataArray = routerQueryToCrumbArrayData(router.query) const parents = useMemo(() => { - if (urlCrumbDataArray.length === 0) { - return [] - } - try { - const parentsFromQuery = getCrumbDataFromCrumbDataArray(urlCrumbDataArray, viewMode as ViewMode) - /** + if (urlCrumbDataArray.length === 0) { + return [] + } + try { + const parentsFromQuery = getCrumbDataFromCrumbDataArray(urlCrumbDataArray, viewMode as ViewMode) + /** * We don't add the currently viewed item unless there are already breadcrumbs */ - if (parentsFromQuery.length !== 0) { - parentsFromQuery.push({ url: router.asPath, title: currentTitle }); - } - return parentsFromQuery; - } catch (err) { - return [] + if (parentsFromQuery.length !== 0) { + parentsFromQuery.push({ url: router.asPath, title: currentTitle }) } - }, - [currentTitle, router.asPath, urlCrumbDataArray, viewMode], - ); + return parentsFromQuery + } catch (err) { + return [] + } + }, + [currentTitle, router.asPath, urlCrumbDataArray, viewMode] + ) if (parents.length === 0) { /** * TODO: Need a placeholder */ - return null; + return null } return ( /} fontSize={16} fontWeight={600}> + mr={{ base: '30px', sm: '30px', md: '60px', lg: '120px' }} + ml={{ base: '30px', sm: '30px', md: '60px', lg: '120px' }} + padding={{ base: '0 0 20px 0' }} separator={/} fontSize={16} fontWeight={600}> {parents.map(({ title, url }, index) => ( ))} - ); + ) } diff --git a/components/errors/ErrorBoundary.tsx b/components/errors/ErrorBoundary.tsx index aeec92a9..b1b7306e 100644 --- a/components/errors/ErrorBoundary.tsx +++ b/components/errors/ErrorBoundary.tsx @@ -1,28 +1,28 @@ -import { Component } from 'react'; +import { Component } from 'react' export class ErrorBoundary extends Component<{children: any}, {hasError: boolean}> { - constructor(props) { - super(props); - this.state = { hasError: false }; + constructor (props) { + super(props) + this.state = { hasError: false } } - static getDerivedStateFromError() { + static getDerivedStateFromError () { // Update state so the next render will show the fallback UI. - return { hasError: true }; + return { hasError: true } } - componentDidCatch(error, errorInfo) { - console.log(`error, errorInfo: `, error, errorInfo); + componentDidCatch (error, errorInfo) { + console.log('error, errorInfo: ', error, errorInfo) // You can also log the error to an error reporting service // logErrorToMyService(error, errorInfo); } - render() { + render () { if (this.state.hasError) { // You can render any custom fallback UI - return

Something went wrong. Please try to refresh the page.

; + return

Something went wrong. Please try to refresh the page.

} - return this.props.children; + return this.props.children } } diff --git a/components/errors/ErrorLineItemDescription.tsx b/components/errors/ErrorLineItemDescription.tsx index 1232423c..f498cc93 100644 --- a/components/errors/ErrorLineItemDescription.tsx +++ b/components/errors/ErrorLineItemDescription.tsx @@ -1,15 +1,15 @@ -import { Box, Text } from '@chakra-ui/react'; -import { ImmutableObject } from '@hookstate/core'; +import { Box, Text } from '@chakra-ui/react' +import { ImmutableObject } from '@hookstate/core' -import { StarMapsIssueErrorsGrouped } from '../../lib/types'; -import styles from './ErrorLineItemDescription.module.css'; +import { StarMapsIssueErrorsGrouped } from '../../lib/types' +import styles from './ErrorLineItemDescription.module.css' -export function ErrorLineItemDescription({ error }: {error: ImmutableObject}) { +export function ErrorLineItemDescription ({ error }: {error: ImmutableObject}) { return ( {error.errors.map((errItem, index) => (  {errItem.message}; - ))} + ))} ) } diff --git a/components/errors/ErrorNotificationBody.tsx b/components/errors/ErrorNotificationBody.tsx index 7949cf30..3b3a2784 100644 --- a/components/errors/ErrorNotificationBody.tsx +++ b/components/errors/ErrorNotificationBody.tsx @@ -1,18 +1,18 @@ -import { Box, SimpleGrid, Text, Link } from '@chakra-ui/react'; -import { ImmutableArray } from '@hookstate/core'; -import NextLink from 'next/link'; +import { Box, SimpleGrid, Text, Link } from '@chakra-ui/react' +import { ImmutableArray } from '@hookstate/core' +import NextLink from 'next/link' -import { StarMapsIssueErrorsGrouped } from '../../lib/types'; -import { ErrorLineItemDescription } from './ErrorLineItemDescription'; -import styles from './ErrorNotificationBody.module.css'; +import { StarMapsIssueErrorsGrouped } from '../../lib/types' +import { ErrorLineItemDescription } from './ErrorLineItemDescription' +import styles from './ErrorNotificationBody.module.css' interface ErrorNotificationBodyProps { isExpanded: boolean; errors: ImmutableArray; } -export function ErrorNotificationBody({ isExpanded, errors }: ErrorNotificationBodyProps) { +export function ErrorNotificationBody ({ isExpanded, errors }: ErrorNotificationBodyProps) { if (!isExpanded) { - return null; + return null } return @@ -30,8 +30,8 @@ export function ErrorNotificationBody({ isExpanded, errors }: ErrorNotificationB - ))} + ))} - ; + } diff --git a/components/errors/ErrorNotificationDisplay.tsx b/components/errors/ErrorNotificationDisplay.tsx index a7e16786..dad33fde 100644 --- a/components/errors/ErrorNotificationDisplay.tsx +++ b/components/errors/ErrorNotificationDisplay.tsx @@ -1,12 +1,12 @@ -import { Box, Center } from '@chakra-ui/react'; -import type { ImmutableArray, State } from '@hookstate/core'; -import React, { useMemo, useState } from 'react'; +import { Box, Center } from '@chakra-ui/react' +import type { ImmutableArray, State } from '@hookstate/core' +import React, { useMemo, useState } from 'react' -import { IssueData, StarMapsIssueErrorsGrouped } from '../../lib/types'; -import { ErrorNotificationHeader } from './ErrorNotificationHeader'; -import { ErrorNotificationBody } from './ErrorNotificationBody'; -import { errorFilters } from '../../lib/client/errorFilters'; -import { useViewMode } from '../../hooks/useViewMode'; +import { useViewMode } from '../../hooks/useViewMode' +import { errorFilters } from '../../lib/client/errorFilters' +import { IssueData, StarMapsIssueErrorsGrouped } from '../../lib/types' +import { ErrorNotificationBody } from './ErrorNotificationBody' +import { ErrorNotificationHeader } from './ErrorNotificationHeader' interface ErrorNotificationDisplayProps { errorsState: State; @@ -14,31 +14,31 @@ interface ErrorNotificationDisplayProps { } export function ErrorNotificationDisplay ({ errorsState, issueDataState }: ErrorNotificationDisplayProps) { - const [isExpanded, setIsExpanded] = useState(true); + const [isExpanded, setIsExpanded] = useState(false) - const viewMode = useViewMode(); + const viewMode = useViewMode() const filteredErrors: ImmutableArray = useMemo(() => { if (errorsState.ornull == null) { - return []; + return [] } - const errors = errorsState.ornull.value; + const errors = errorsState.ornull.value if (viewMode != null && issueDataState.ornull != null && errorFilters[viewMode] != null) { return errorFilters[viewMode](errors, issueDataState.ornull.value) } - return errors; - }, [errorsState.ornull, viewMode, issueDataState.ornull]); + return errors + }, [errorsState.ornull, viewMode, issueDataState.ornull]) if (filteredErrors?.length === 0) { - return null; + return null } - const handleToggle = () => setIsExpanded(!isExpanded); + const handleToggle = () => setIsExpanded(!isExpanded) return
diff --git a/components/errors/ErrorNotificationHeader.tsx b/components/errors/ErrorNotificationHeader.tsx index ade7ee76..7fe2f2a5 100644 --- a/components/errors/ErrorNotificationHeader.tsx +++ b/components/errors/ErrorNotificationHeader.tsx @@ -1,20 +1,21 @@ -import { Box, Center, Flex, Spacer, Spinner, Text } from '@chakra-ui/react'; import { ChevronDownIcon, ChevronUpIcon, WarningTwoIcon } from '@chakra-ui/icons' -import React from 'react'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; +import { Box, Center, Flex, Spacer, Spinner, Text } from '@chakra-ui/react' +import React from 'react' + +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' interface ErrorNotificationHeaderProps { isExpanded: boolean; toggle: () => void; errorCount: number; } -export function ErrorNotificationHeader({ isExpanded, toggle, errorCount }: ErrorNotificationHeaderProps) { - const globalLoadingState = useGlobalLoadingState(); +export function ErrorNotificationHeader ({ isExpanded, toggle, errorCount }: ErrorNotificationHeaderProps) { + const globalLoadingState = useGlobalLoadingState() - const iconWandH = 8; + const iconWandH = 8 const icon = isExpanded ? - : ; + : return ( - ); + ) } diff --git a/components/icons/svgr/StarMapsLogo.tsx b/components/icons/svgr/StarMapsLogo.tsx index 8b706b62..391639c2 100644 --- a/components/icons/svgr/StarMapsLogo.tsx +++ b/components/icons/svgr/StarMapsLogo.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import * as React from 'react' const SvgStarMapsLogo = (props) => ( @@ -16,5 +16,5 @@ const SvgStarMapsLogo = (props) => ( /> -); -export default SvgStarMapsLogo; +) +export default SvgStarMapsLogo diff --git a/components/icons/svgr/SvgDesktopIcon.tsx b/components/icons/svgr/SvgDesktopIcon.tsx index d292e59e..57f09dfa 100644 --- a/components/icons/svgr/SvgDesktopIcon.tsx +++ b/components/icons/svgr/SvgDesktopIcon.tsx @@ -1,7 +1,7 @@ -import * as React from 'react'; +import * as React from 'react' const SvgDesktopIcon = (props) => ( -); -export default SvgDesktopIcon; +) +export default SvgDesktopIcon diff --git a/components/icons/svgr/SvgDetailViewIcon.tsx b/components/icons/svgr/SvgDetailViewIcon.tsx index dbd9154e..a1d72c14 100644 --- a/components/icons/svgr/SvgDetailViewIcon.tsx +++ b/components/icons/svgr/SvgDetailViewIcon.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import * as React from 'react' const SvgDetailViewIcon = () => ( @@ -9,6 +9,6 @@ const SvgDetailViewIcon = () => ( -); +) -export default SvgDetailViewIcon; +export default SvgDetailViewIcon diff --git a/components/icons/svgr/SvgEyeClosedIcon.tsx b/components/icons/svgr/SvgEyeClosedIcon.tsx index f33f1f94..b9d07047 100644 --- a/components/icons/svgr/SvgEyeClosedIcon.tsx +++ b/components/icons/svgr/SvgEyeClosedIcon.tsx @@ -1,12 +1,12 @@ -import * as React from 'react'; +import * as React from 'react' const SvgEyeClosedIcon = (props) => ( - + - ) +) -export default SvgEyeClosedIcon; +export default SvgEyeClosedIcon diff --git a/components/icons/svgr/SvgEyeOpenIcon.tsx b/components/icons/svgr/SvgEyeOpenIcon.tsx index bd4f4ed3..659714b5 100644 --- a/components/icons/svgr/SvgEyeOpenIcon.tsx +++ b/components/icons/svgr/SvgEyeOpenIcon.tsx @@ -1,10 +1,10 @@ -import * as React from 'react'; +import * as React from 'react' const SvgEyeOpenIcon = (props) => ( - + - ) +) -export default SvgEyeOpenIcon; +export default SvgEyeOpenIcon diff --git a/components/icons/svgr/SvgGitHubLogo.tsx b/components/icons/svgr/SvgGitHubLogo.tsx index 4902891e..4601a5d4 100644 --- a/components/icons/svgr/SvgGitHubLogo.tsx +++ b/components/icons/svgr/SvgGitHubLogo.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import * as React from 'react' const SvgGitHubLogo = (props) => ( @@ -11,5 +11,5 @@ const SvgGitHubLogo = (props) => ( /> -); -export default SvgGitHubLogo; +) +export default SvgGitHubLogo diff --git a/components/icons/svgr/SvgGitHubLogoWithTooltip.tsx b/components/icons/svgr/SvgGitHubLogoWithTooltip.tsx index e9e47b60..959bb2ef 100644 --- a/components/icons/svgr/SvgGitHubLogoWithTooltip.tsx +++ b/components/icons/svgr/SvgGitHubLogoWithTooltip.tsx @@ -1,14 +1,16 @@ -import { Link, Tooltip } from '@chakra-ui/react'; -import React from 'react'; +import { Link, Tooltip } from '@chakra-ui/react' +import React from 'react' -import SvgGitHubLogo from './SvgGitHubLogo'; -import styles from './SvgGitHubLogoWithTooltip.module.css'; +import SvgGitHubLogo from './SvgGitHubLogo' +import styles from './SvgGitHubLogoWithTooltip.module.css' -const LogoWithTooltip = React.forwardRef(({ children, ...rest }, ref) => ( +const LogoWithTooltip = React.forwardRef(function LogoWithTooltipFn ({ children, ...rest }, ref) { + return ( {children} -)) + ) +}) export const SvgGitHubLogoWithTooltip = (props) => { const onClickHandler = (event) => { diff --git a/components/icons/svgr/SvgListViewIcon.tsx b/components/icons/svgr/SvgListViewIcon.tsx index 71b33877..953333b1 100644 --- a/components/icons/svgr/SvgListViewIcon.tsx +++ b/components/icons/svgr/SvgListViewIcon.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import * as React from 'react' const SvgDetailViewIcon = () => ( /** @@ -13,6 +13,6 @@ const SvgDetailViewIcon = () => ( -); +) -export default SvgDetailViewIcon; +export default SvgDetailViewIcon diff --git a/components/icons/svgr/SvgOverviewIcon.tsx b/components/icons/svgr/SvgOverviewIcon.tsx index 949c3bea..80bc156a 100644 --- a/components/icons/svgr/SvgOverviewIcon.tsx +++ b/components/icons/svgr/SvgOverviewIcon.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import * as React from 'react' const SvgOverviewIcon = () => ( @@ -6,6 +6,6 @@ const SvgOverviewIcon = () => ( -); +) -export default SvgOverviewIcon; +export default SvgOverviewIcon diff --git a/components/icons/svgr/SvgRotateScreenIcon.tsx b/components/icons/svgr/SvgRotateScreenIcon.tsx index dbe860f2..840e99f6 100644 --- a/components/icons/svgr/SvgRotateScreenIcon.tsx +++ b/components/icons/svgr/SvgRotateScreenIcon.tsx @@ -1,9 +1,9 @@ -import * as React from 'react'; +import * as React from 'react' const SvgRotateScreenIcon = (props) => ( -); -export default SvgRotateScreenIcon; +) +export default SvgRotateScreenIcon diff --git a/components/inputs/NumSlider.tsx b/components/inputs/NumSlider.tsx deleted file mode 100644 index 90add054..00000000 --- a/components/inputs/NumSlider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Slider from 'react-input-slider'; - -interface NumSelectorProps { - value: number; - min: number; - max: number; - setValue: (value: number) => void; - msg?: string; - step?: number; -} - -function NumSlider({ value, min, max, setValue, msg, step }: NumSelectorProps) { - const message = msg ?? `Select a number: `; - step = step ?? 1; - return ( - <> - {message} - setValue(x)} />({value}) - - ); -} - -export default NumSlider; diff --git a/components/layout/PageHeader.tsx b/components/layout/PageHeader.tsx index f798082d..5fbb27d3 100644 --- a/components/layout/PageHeader.tsx +++ b/components/layout/PageHeader.tsx @@ -1,45 +1,47 @@ -import { Box, Center, Flex, Link, Spacer, Text } from '@chakra-ui/react'; -import NextLink from 'next/link'; -import React from 'react'; +import { Box, Center, Flex, Link, Spacer, Text } from '@chakra-ui/react' +import NextLink from 'next/link' +import React from 'react' -import { ErrorBoundary } from '../errors/ErrorBoundary'; -import SvgStarMapsLogo from '../icons/svgr/StarMapsLogo'; -import { RoadmapForm } from '../RoadmapForm'; -import theme from '../theme/constants'; -import styles from './PageHeader.module.css'; -import SmallScreenModal from '../modal/SmallScreenModal'; -import useStarmapContentUpdated from '../../hooks/useStarmapContentUpdated'; +import useStarmapContentUpdated from '../../hooks/useStarmapContentUpdated' +import { ErrorBoundary } from '../errors/ErrorBoundary' +import SvgStarMapsLogo from '../icons/svgr/StarMapsLogo' +import { LegacyButton } from '../legacy/LegacyButton' +import SmallScreenModal from '../modal/SmallScreenModal' +import { RoadmapForm } from '../RoadmapForm' +import theme from '../theme/constants' +import styles from './PageHeader.module.css' -function PageHeader() { - useStarmapContentUpdated(); +function PageHeader () { + useStarmapContentUpdated() return ( <> - - - - - -
- - Star - map -
- -
-
+ + + + + +
+ + Star + map +
+ +
+
+
+ +
- +
- ); + ) } -export default PageHeader; +export default PageHeader diff --git a/components/legacy/LegacyButton.tsx b/components/legacy/LegacyButton.tsx new file mode 100644 index 00000000..4a4484d4 --- /dev/null +++ b/components/legacy/LegacyButton.tsx @@ -0,0 +1,24 @@ +import { Center, Text } from '@chakra-ui/react' +import { useCallback } from 'react' + +import { useViewMode } from '../../hooks/useViewMode' + +export function LegacyButton () { + const viewMode = useViewMode() + const onLegacyStarmapLinkClick = useCallback(() => { + const url = new URL(window.location.href) + url.host = 'legacy.starmap.site' + url.port = '' + + // set the url hash to the viewMode (the only valid hash string on legacy.starmap.site) + url.hash = viewMode as string + + window.location.href = url.toString() + }, [viewMode]) + + return ( +
+ View legacy starmap +
+ ) +} diff --git a/components/modal/SmallScreenModal.tsx b/components/modal/SmallScreenModal.tsx index d5c33627..92e7d6b7 100644 --- a/components/modal/SmallScreenModal.tsx +++ b/components/modal/SmallScreenModal.tsx @@ -1,17 +1,18 @@ import { - Box, Center, - Flex, Link, Modal, ModalBody, - ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Text -} from '@chakra-ui/react'; -import useCheckMobileScreen from '../../hooks/useCheckSmallScreen'; -import SvgStarMapsLogo from '../icons/svgr/StarMapsLogo'; -import SvgDesktopIcon from '../icons/svgr/SvgDesktopIcon'; -import SvgRotateScreenIcon from '../icons/svgr/SvgRotateScreenIcon'; -import styles from './SmallScreenModal.module.css'; + Box, Center, + Flex, Link, Modal, ModalBody, + ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Text +} from '@chakra-ui/react' -export default function SmallScreenModal() { +import useCheckMobileScreen from '../../hooks/useCheckSmallScreen' +import SvgStarMapsLogo from '../icons/svgr/StarMapsLogo' +import SvgDesktopIcon from '../icons/svgr/SvgDesktopIcon' +import SvgRotateScreenIcon from '../icons/svgr/SvgRotateScreenIcon' +import styles from './SmallScreenModal.module.css' + +export default function SmallScreenModal () { const [isSmallScreen, setAcknowledgeSmallScreen] = useCheckMobileScreen() - const closeHandler = () => setAcknowledgeSmallScreen(true); + const closeHandler = () => setAcknowledgeSmallScreen(true) return ( @@ -48,5 +49,5 @@ export default function SmallScreenModal() { - ); -}; + ) +} diff --git a/components/roadmap-grid/RoadmapDetailedView.tsx b/components/roadmap-grid/RoadmapDetailedView.tsx deleted file mode 100644 index ace47cc1..00000000 --- a/components/roadmap-grid/RoadmapDetailedView.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { Box, Spinner, Stack, Skeleton } from '@chakra-ui/react'; -import { hookstate, useHookstateMemo } from '@hookstate/core'; -import type { Dayjs } from 'dayjs'; -import _ from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; - -import { getTicks } from '../../lib/client/getTicks'; -import { ViewMode } from '../../lib/enums'; -import { DetailedViewGroup, IssueDataViewInput } from '../../lib/types'; -import { useViewMode } from '../../hooks/useViewMode'; -import styles from './Roadmap.module.css'; -import { Grid } from './grid'; -import { GridHeader } from './grid-header'; -import { GridRow } from './grid-row'; -import { GroupHeader } from './group-header'; -import { GroupWrapper } from './group-wrapper'; -import { Headerline } from './headerline'; -import NumSlider from '../inputs/NumSlider'; -import { dayjs } from '../../lib/client/dayjs'; -import { DEFAULT_TICK_COUNT } from '../../config/constants'; -import { globalTimeScaler } from '../../lib/client/TimeScaler'; -import { convertIssueDataStateToDetailedViewGroupOld } from '../../lib/client/convertIssueDataToDetailedViewGroup'; -import { useRouter } from 'next/router'; -import { ErrorBoundary } from '../errors/ErrorBoundary'; -import { useShowTodayMarker } from '../../hooks/useShowTodayMarker'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; - -export function RoadmapDetailed({ - issueDataState -}: IssueDataViewInput) { - /** - * Don't commit setting this to true.. just a simple toggle so we can debug things. - */ - const [isDevMode, _setIsDevMode] = useState(false); - const viewMode = useViewMode() as ViewMode; - const router = useRouter(); - const globalLoadingState = useGlobalLoadingState(); - const query = router.query - const showTodayMarker = useShowTodayMarker(); - const memoGrouped = useHookstateMemo( - () => convertIssueDataStateToDetailedViewGroupOld(issueDataState, viewMode, query), - [viewMode, query, issueDataState] - ) - const issuesGroupedState = hookstate(memoGrouped) - - /** - * Magic numbers that just seem to work are: - * * 5 for number of header ticks - * * we actually want 5 visible ticks. - * - * * 45 for number of grid columns - * * It usually works best if grid columns is easily divisble by the number of header ticks) - * - * * 1.09 for a multiple of the number of grid columns - * * otherwise the timeScale we get back doesn't map to gridColumns well. - */ - const [numHeaderTicks, setNumHeaderTicks] = useState(5); - const [numGridCols, setNumGridCols] = useState(45); - - // for preventing dayjsDates from being recalculated if it doesn't need to be - const issuesGroupedId = issuesGroupedState.value.map((g) => g.groupName).join(','); - /** - * Collect all due dates from all issues, as DayJS dates. - */ - const dayjsDates: Dayjs[] = useHookstateMemo(() => { - const today = dayjs(); - let innerDayjsDates: Dayjs[] = [] - try { - innerDayjsDates = issuesGroupedState.value - .flatMap((group) => group.items.map((item) => dayjs(item.due_date).utc())) - .filter((d) => d.isValid()); - } catch { - innerDayjsDates = [] - } - /** - * Add today - */ - innerDayjsDates.push(today); - - /** - * TODO: We need to modify today.subtract and today.add based on the current DateGranularityState - */ - let minDate = dayjs.min([...innerDayjsDates, today.subtract(1, 'month')]); - let maxDate = dayjs.max([...innerDayjsDates, today.add(1, 'month')]); - let incrementMax = false - - /** - * This is a hack to make sure that the first and last ticks are always visible. - * TODO: Perform in constant time based on current DateGranularity - */ - while (maxDate.diff(minDate, 'months') < (3 * DEFAULT_TICK_COUNT)) { - if (incrementMax) { - maxDate = maxDate.add(1, 'quarter'); - } else { - minDate = minDate.subtract(1, 'quarter'); - } - incrementMax = !incrementMax; - } - - /** - * Add minDate and maxDate so that the grid is not cut off. - */ - innerDayjsDates.push(minDate) - innerDayjsDates.push(maxDate) - - return innerDayjsDates; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issuesGroupedState, issuesGroupedId]); - - /** - * * Ensure that the dates are - * * converted back to JS Date objects. - * * sorted - d3 timescale requires it to function properly - */ - const dates = useMemo(() => dayjsDates - .map((date) => date.toDate()) - .sort((a, b) => a.getTime() - b.getTime()), [dayjsDates]); - - useEffect(() => { - globalTimeScaler.setScale(dates, numGridCols * 1.09); - }, [dates, numGridCols]); - - - // return early while loading. - if (globalLoadingState.get()) { - return ( - - - - - - - - ) - } - const invalidGroups = issuesGroupedState.filter((group) => group.ornull == null || group.items.ornull == null) - if (issuesGroupedState.value.length === 0 || invalidGroups.length > 0) { - if (invalidGroups.length > 0) { - invalidGroups.forEach((g) => { - console.warn('Found an invalid group: ', g.value); - }); - } - return ; - } - - /** - * Current getTicks function returns 1 less than the number of ticks we want. - */ - const ticks = getTicks(dates, numGridCols - 1); - const ticksHeader = getTicks(dates, numHeaderTicks - 1); - - return ( - <> - {isDevMode && } - {isDevMode && } - - - - {ticksHeader.map((tick, index) => ( - - - ))} - - - - - {issuesGroupedState.map((group, index) => ( - - - {group.ornull != null && group.items.ornull != null && - _.sortBy(group.items.ornull, ['title']).map((item, index) => )} - - - ))} - - - - ); -} diff --git a/components/roadmap-grid/RoadmapTabbedView.tsx b/components/roadmap-grid/RoadmapTabbedView.tsx deleted file mode 100644 index 234c2b10..00000000 --- a/components/roadmap-grid/RoadmapTabbedView.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Box, - Center, - Flex, - Link, - Skeleton, - Tab, - TabList, - TabPanel, - TabPanels, - Tabs, - Spacer, -} from '@chakra-ui/react'; -import { useRouter } from 'next/router'; -import React from 'react'; -import { ReactElement } from 'react-markdown/lib/react-markdown'; -import SvgDetailViewIcon from '../icons/svgr/SvgDetailViewIcon'; -import SvgOverviewIcon from '../icons/svgr/SvgOverviewIcon'; - -import { TodayMarkerToggle } from './today-marker-toggle'; -import { setViewMode, useViewMode } from '../../hooks/useViewMode'; -import { DEFAULT_INITIAL_VIEW_MODE } from '../../lib/defaults'; -import { ViewMode } from '../../lib/enums'; -import { IssueDataViewInput } from '../../lib/types'; -import Header from './header'; -import styles from './Roadmap.module.css'; -import { RoadmapDetailed } from './RoadmapDetailedView'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import SvgListViewIcon from '../icons/svgr/SvgListViewIcon'; -import RoadmapList from '../RoadmapList'; - -export function RoadmapTabbedView({ - issueDataState, -}: IssueDataViewInput): ReactElement { - const globalLoadingState = useGlobalLoadingState(); - const viewMode = useViewMode() || DEFAULT_INITIAL_VIEW_MODE; - const router = useRouter(); - - // Defining what tabs to show and in what order - const tabs = ['Detailed View', 'Overview', 'List'] as const; - - // Mapping the views to the tabs - const tabViewMap: Record = { - 'Detailed View': ViewMode.Detail, - 'Overview': ViewMode.Simple, - 'List': ViewMode.List, - }; - - // Mapping the tabs to the views - const tabViewMapInverse: Record = tabs.reduce((acc, tab, index) => { - acc[tabViewMap[tab]] = index; - return acc; - }, {} as Record); - - const tabIndexFromViewMode = tabViewMapInverse[viewMode] - - const handleTabChange = (index: number) => { - setViewMode(tabViewMap[tabs[index]]); - router.push({ - hash: tabViewMap[tabs[index]], - }, undefined, { shallow: true }); - } - - const renderTab = (title: typeof tabs[number], index: number) => { - let TabIcon = SvgDetailViewIcon - - if (title == "Overview") { - TabIcon = SvgOverviewIcon - } else if (title == "List") { - TabIcon = SvgListViewIcon - } - - return ( - - -
- - {title} -
-
-
- ) - }; - - const renderTabPanel = (title: typeof tabs[number], index: number) => { - let component = - if (title === 'List') { - component = - } - return ( - - {component} - - ) - }; - - return ( - <> - -
- - - - - {tabs.map(renderTab)} - - - - - - {tabs.map(renderTabPanel)} - - - - - - ); -} diff --git a/components/roadmap-grid/grid-header.tsx b/components/roadmap-grid/grid-header.tsx deleted file mode 100644 index 8cdf6650..00000000 --- a/components/roadmap-grid/grid-header.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; - -import { useDateGranularity } from '../../hooks/useDateGranularity'; -import { dayjs } from '../../lib/client/dayjs'; -import getDateAsQuarter from '../../lib/client/getDateAsQuarter'; -import { DateGranularityState } from '../../lib/enums'; -import { ErrorBoundary } from '../errors/ErrorBoundary'; -import styles from './Roadmap.module.css'; -import { Text } from '@chakra-ui/react'; - -interface GridHeaderProps { - tick: Date; - index: number; - numGridCols: number; - numHeaderTicks: number; -} - -/** - * This is the labels for the grid. The quarters and the top with the tick. - */ -export function GridHeader({ tick, index, numGridCols, numHeaderTicks }: GridHeaderProps) { - const dateGranularity = useDateGranularity(); - const date = dayjs(tick).utc(); - - let label = ''; - switch (dateGranularity) { - case DateGranularityState.Quarters: - label = getDateAsQuarter(date); - break; - case DateGranularityState.Months: - case DateGranularityState.Weeks: - default: - label = date.format('DD MMM YYYY'); - break; - } - - return ( - - -
- {label} -
-
- ); -} diff --git a/components/roadmap-grid/grid-row.tsx b/components/roadmap-grid/grid-row.tsx deleted file mode 100644 index 7786eb86..00000000 --- a/components/roadmap-grid/grid-row.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import NextLink from 'next/link'; -import { Flex, Text, Box, Center } from '@chakra-ui/react'; -import React, { useState } from 'react'; -import { State, useHookstateEffect, useHookstateMemo } from '@hookstate/core' - -import { dayjs } from '../../lib/client/dayjs'; -import { IssueData, IssueDataViewInput } from '../../lib/types'; -import styles from './Roadmap.module.css'; -import { SvgGitHubLogoWithTooltip } from '../icons/svgr/SvgGitHubLogoWithTooltip'; -import { globalTimeScaler } from '../../lib/client/TimeScaler'; -import { ReactElement } from 'react-markdown/lib/react-markdown'; -import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild'; -import { useRouter } from 'next/router'; -import { useViewMode } from '../../hooks/useViewMode'; -import { paramsFromUrl } from '../../lib/paramsFromUrl'; - -interface GridRowProps extends IssueDataViewInput { - milestone: State; - index: number; - timelineTicks: Date[]; - numGridCols: number; - numHeaderItems: number; -} - -export function GridRow({ - milestone, - index, - timelineTicks, - numGridCols, - numHeaderItems, - issueDataState -}: GridRowProps): ReactElement | null { - const viewMode = useViewMode(); - const routerQuery = useRouter().query; - const [closestDateIdx, setClosestDateIdx] = useState(Math.round(globalTimeScaler.getColumn(dayjs.utc(milestone.due_date.get()).toDate()))); - useHookstateEffect(() => { - setClosestDateIdx(Math.round(globalTimeScaler.getColumn(dayjs.utc(milestone.due_date.get()).toDate()))); - }, [milestone.due_date, ...globalTimeScaler.getDomain()]); - const span = Math.max(4, numGridCols / timelineTicks.length); - const closest = span * (closestDateIdx - 1); - const childLink = useHookstateMemo(() => getLinkForRoadmapChild({ viewMode, issueData: milestone.get(), query: routerQuery, currentRoadmapRoot: issueDataState.value }), [issueDataState, milestone, routerQuery, viewMode]); - const clickable = milestone.children.length > 0; - - if (milestone == null || milestone.ornull == null) { - return null; - } - /** - * Do not render milestone items if their ETAs are invalid. - */ - if (milestone.root_issue.value !== true) { - if (!dayjs(milestone.due_date.value).isValid()) { - return null; - } - - if (milestone.labels.ornull == null) { - return null; - } - } - const gridColumnEnd = closest === span ? closest : closest - 1 - - if (span > gridColumnEnd) { - // TODO: Handle this error - console.error('Span size is greater than gridColumnEnd', milestone.get({ noproxy: true })) - } - if (closestDateIdx > numGridCols) { - /** - * TODO: Handle this error - * This error is sometimes happening for milestones that are currently being used as a group item. - * i.e. we shouldn't be attempting to render those at all. - */ - console.error('closestDateIdx is greater than numGridCols', milestone.get({ noproxy: true })) - } - - let className = ''; - try { - const { owner, repo, issue_number } = paramsFromUrl(milestone.html_url.value); - className = `js-milestoneCard-${owner}-${repo}-${issue_number}` - } catch {} - - const rowItem = ( -
- - - {milestone.title.value} - - - - - -

{dayjs(milestone.due_date.value).format('DD-MMM-YY')}

-
- -
-
- -
-
- ); - - if (clickable) { - return ( - - {rowItem} - - ); - } - - return rowItem; -} diff --git a/components/roadmap-grid/grid.tsx b/components/roadmap-grid/grid.tsx deleted file mode 100644 index 793ffd46..00000000 --- a/components/roadmap-grid/grid.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styles from './Roadmap.module.css'; -import { TodayMarker } from './today-marker'; - -export function Grid({ children, ticksLength, scroll = false, renderTodayLine = false }) { - const styleClass = scroll ? styles.scrollable : styles.grid; - return ( - <> - {renderTodayLine ? : null} -
- {children} -
- - ); -} diff --git a/components/roadmap-grid/group-header.tsx b/components/roadmap-grid/group-header.tsx deleted file mode 100644 index 6eb41d2d..00000000 --- a/components/roadmap-grid/group-header.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Text } from '@chakra-ui/react'; -import { isEmpty } from 'lodash'; -import NextLink from 'next/link'; -import { useRouter } from 'next/router'; -import React from 'react'; -import { useViewMode } from '../../hooks/useViewMode'; -import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild'; - -import { ViewMode } from '../../lib/enums'; -import { GroupHeaderProps } from '../../lib/types'; -import styles from './Roadmap.module.css'; - -/** - * This is the component for the Group header (themes) - * @returns {JSX.Element} - */ -export function GroupHeader({ group, issueDataState }: GroupHeaderProps) { - const viewMode = useViewMode(); - const router = useRouter(); - let groupNameElement: JSX.Element | null = null; - const issueData: Parameters[0]['issueData'] = { - html_url: group.url.value.replace('/roadmap/', ''), - children: group.items.value, - }; - if (viewMode === ViewMode.Detail) { - if (isEmpty(group.url)) { - groupNameElement = {group.groupName.value} - } else { - const groupHeaderLink = getLinkForRoadmapChild({ - issueData: issueData, - currentRoadmapRoot: issueDataState.value, - viewMode, - query: router.query, - replaceOrigin: false, - }); - groupNameElement = {group.groupName.value} - } - } - - return ( -
-
{groupNameElement}
-
- ); -} diff --git a/components/roadmap-grid/group-wrapper.tsx b/components/roadmap-grid/group-wrapper.tsx deleted file mode 100644 index 64e5b9f3..00000000 --- a/components/roadmap-grid/group-wrapper.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useViewMode } from '../../hooks/useViewMode'; -import { ViewMode } from '../../lib/enums'; -import styles from './Roadmap.module.css'; - -export function GroupWrapper({ children, cssName = '' }) { - const viewMode = useViewMode(); - let viewModeClass = 'simpleView'; - if (viewMode === ViewMode.Detail) { - viewModeClass = 'detailedView'; - } - - return ( -
- {children} -
- ); -} diff --git a/components/roadmap-grid/header.tsx b/components/roadmap-grid/header.tsx deleted file mode 100644 index d17e09a3..00000000 --- a/components/roadmap-grid/header.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Center, Flex, Link, Skeleton, Spacer, Text } from '@chakra-ui/react'; -import Image from 'next/image'; -import NextLink from 'next/link'; -import React from 'react'; - -import { ReactElement } from 'react-markdown/lib/react-markdown'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import { IssueDataViewInput } from '../../lib/types'; -import GitHubSvgIcon from '../icons/GitHubLogo.svg'; -import themes from '../theme/constants'; - -export default function Header({ - issueDataState -}: IssueDataViewInput): ReactElement | null { - const globalLoadingState = useGlobalLoadingState(); - if (issueDataState.html_url.value == null || typeof issueDataState.html_url.value !== 'string') { - console.log('error with issueData', issueDataState.get({ noproxy: true })) - return null; - } - - return ( - - - - {issueDataState.title.value} - - -
- - -
- View in GitHub - GitHub Logo -
- -
-
-
-
- ); -} diff --git a/components/roadmap-grid/headerline.tsx b/components/roadmap-grid/headerline.tsx deleted file mode 100644 index 0211f083..00000000 --- a/components/roadmap-grid/headerline.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { GridItem } from '@chakra-ui/react'; - -import { GroupWrapper } from './group-wrapper'; -import styles from './Roadmap.module.css'; - -interface HeaderlineProps { - numGridCols: number; - ticksRatio?: number -} -export function Headerline({ numGridCols, ticksRatio = 1 }: HeaderlineProps) { - const numberOfHeaderItems = 5; - const firstLabeledTick = 2; - const headerItems = Array.from({ length: numberOfHeaderItems * ticksRatio + 1 }, (_, i) => { - const gridColumnEnd = numGridCols / numberOfHeaderItems / ticksRatio - - const isTaller = i === firstLabeledTick || i !== 0 && (i-firstLabeledTick) % ticksRatio === 0 ? true : false - - if (i === 0) { - return null; - } - - return ( -
- ) - }); - - return ( - - - {headerItems} - - ); -} diff --git a/components/roadmap-grid/today-marker-toggle.tsx b/components/roadmap-grid/today-marker-toggle.tsx deleted file mode 100644 index 52fbe789..00000000 --- a/components/roadmap-grid/today-marker-toggle.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Button, Skeleton, Text } from '@chakra-ui/react'; -import React from 'react'; -import SvgEyeClosedIcon from '../icons/svgr/SvgEyeClosedIcon'; -import SvgEyeOpenIcon from '../icons/svgr/SvgEyeOpenIcon'; - -import { setShowTodayMarker, useShowTodayMarker } from '../../hooks/useShowTodayMarker'; - - -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import styles from './today-marker.module.css'; - -export function TodayMarkerToggle() { - const showTodayMarker = useShowTodayMarker(); - const globalLoadingState = useGlobalLoadingState(); - - return ( - - - - ); -} diff --git a/components/roadmap-grid/today-marker.tsx b/components/roadmap-grid/today-marker.tsx deleted file mode 100644 index f0fe30b7..00000000 --- a/components/roadmap-grid/today-marker.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { dayjs } from '../../lib/client/dayjs'; - -import { globalTimeScaler } from '../../lib/client/TimeScaler'; -import styles from './today-marker.module.css'; -import { setShowTodayMarker, useShowTodayMarker } from '../../hooks/useShowTodayMarker'; - -export function TodayMarker({ ticksLength }: {ticksLength:number}) { - const today = dayjs(); - const [percentLeft, setPercentLeft] = useState(0); - const showTodayMarker = useShowTodayMarker(); - - useEffect(() => { - const percentage = Number((globalTimeScaler.getPercentile(today.toDate()) * 100)) - setPercentLeft(percentage); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setPercentLeft, today.format('YYYY-MM-DD'), globalTimeScaler.getDomain(), ticksLength]); - - return ( -
setShowTodayMarker(!showTodayMarker)}> -
-
-
- ); -} diff --git a/components/roadmap/AxisTop.tsx b/components/roadmap/AxisTop.tsx deleted file mode 100644 index 851858bd..00000000 --- a/components/roadmap/AxisTop.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ScaleTime, axisTop, select, timeWeek } from 'd3'; -import dayjs from 'dayjs'; -import { useEffect, useRef } from 'react'; - -import { useWeekTicks } from '../../hooks/useWeekTicks'; - -interface AxisTopProps { - scale: ScaleTime; - transform: string; - dates?: Date[]; -} - -function AxisTop({ scale, transform }: AxisTopProps) { - const ref = useRef(null); - const numWeeks = useWeekTicks(); - - useEffect(() => { - if (ref.current) { - const axis = axisTop(scale) - .tickSizeInner(-20) - .ticks(timeWeek.every(numWeeks)) - .tickFormat((d) => dayjs(d.toString()).format('YYYY MMM DD')); - // change size of ticks text - - select(ref.current).call(axis); - } - }, [scale, numWeeks]); - - return ; -} - -export default AxisTop; diff --git a/components/roadmap/BinPackedMilestoneItem.tsx b/components/roadmap/BinPackedMilestoneItem.tsx new file mode 100644 index 00000000..679c46f9 --- /dev/null +++ b/components/roadmap/BinPackedMilestoneItem.tsx @@ -0,0 +1,81 @@ +import { Box } from '@chakra-ui/react' +import { Text } from '@visx/text' +import NextLink from 'next/link' +import { useRouter } from 'next/router' +import React, { useId, useRef } from 'react' + +import { useViewMode } from '../../hooks/useViewMode' +import { dayjs } from '../../lib/client/dayjs' +import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild' +import { paramsFromUrl } from '../../lib/paramsFromUrl' +import { SvgGitHubLogoWithTooltip } from '../icons/svgr/SvgGitHubLogoWithTooltip' +import styles from './Roadmap.module.css' +import { ItemContainerSvg } from './svg/ItemContainerSvg' + +const MAX_TITLE_LENGTH = 80 + +// D3 milestone item +export default function BinPackedMilestoneItem ({ + item +}: { + item: ItemContainerSvg; +}) { + const itemRef = useRef(null) + const uniqId = useId() + const viewMode = useViewMode() + + const classNames = [ + 'js-milestoneCard' + ] + + if (item.data.children.length > 0) { + classNames.push(styles['d3__milestoneItem-clickable']) + } + + try { + const { owner, repo, issue_number } = paramsFromUrl(item.data.html_url) + classNames.push(`js-milestoneCard-${owner}-${repo}-${issue_number}`) + } catch {} + + const truncatedTitle = item.data.title.length > MAX_TITLE_LENGTH ? `${item.data.title.slice(0, MAX_TITLE_LENGTH - 3)}...` : item.data.title + + return ( + + + + + {truncatedTitle} + + + + + + + + {dayjs(item.data.due_date).format('MMM DD, YYYY')} + + + + + + + ) +} diff --git a/components/roadmap/NewRoadMapHeader.tsx b/components/roadmap/NewRoadMapHeader.tsx new file mode 100644 index 00000000..2988f100 --- /dev/null +++ b/components/roadmap/NewRoadMapHeader.tsx @@ -0,0 +1,68 @@ +import { ScaleTime } from 'd3' +import { Dayjs } from 'dayjs' +import { useMemo } from 'react' + +import { dayjs } from '../../lib/client/dayjs' +import { TimeUnit } from '../../lib/enums' +import NewRoadmapHeaderTick from './NewRoadMapHeaderTick' + +interface NewRoadmapHeaderProps { + scale: ScaleTime; + yMin: number; + leftMostX: number; + rightMostX: number; + width: number; + maxHeight: number; +} + +export default function NewRoadmapHeader ({ scale, yMin, leftMostX, rightMostX, width, maxHeight }: NewRoadmapHeaderProps) { + const maxDate = scale.invert(rightMostX) + const minDate = scale.invert(leftMostX) + + /** + * This should be the current number of months that would be visible on the screen if we set + * the timeUnit to TimeUnit.Month. This is used to determine the timeUnit to use. + */ + const monthDiff = useMemo(() => dayjs(maxDate).diff(dayjs(minDate), 'months'), [minDate, maxDate]) + const monthsPerQuarter = 3 + const roughMonthTextPixelWidth = 100 + const maxMonthsOnScreen = width / roughMonthTextPixelWidth + + const timeUnit = useMemo(() => { + if (monthDiff >= maxMonthsOnScreen * monthsPerQuarter) { + return TimeUnit.Year + } + if (monthDiff >= maxMonthsOnScreen) { + return TimeUnit.Quarter + } + + return TimeUnit.Month + }, [maxMonthsOnScreen, monthDiff]) + + /** + * Get an array of dates representing each timeUnit between the min and max dates + */ + const newTicks: Dayjs[] = useMemo(() => { + const monthsDuration = dayjs.duration({ months: timeUnit === 'month' ? 0.5 : 2 }) + // limitExpansion is the amount of time to add to the min and max dates to ensure that the first and last ticks are visible + const limitExpansion = dayjs.duration({ months: monthDiff }) + const timeUnitStart = dayjs(minDate).startOf(timeUnit).subtract(limitExpansion) + const ticks: Dayjs[] = [timeUnitStart] + let timeUnitEnd = timeUnitStart.endOf(timeUnit) + let middleOfTimeUnit = dayjs(timeUnitStart).add(monthsDuration) + const maximumTickDate = dayjs(maxDate).add(limitExpansion) + while (middleOfTimeUnit.isBefore(maximumTickDate)) { + ticks.push(middleOfTimeUnit) + middleOfTimeUnit = dayjs(timeUnitEnd).add(monthsDuration) + timeUnitEnd = middleOfTimeUnit.endOf(timeUnit) + } + ticks.push(middleOfTimeUnit) + return ticks + }, [maxDate, minDate, timeUnit, monthDiff]) + + return + {newTicks.map((date, i) => ())} + {/* Render a border on the bottom of all of the labels */} + + +} diff --git a/components/roadmap/NewRoadMapHeaderTick.tsx b/components/roadmap/NewRoadMapHeaderTick.tsx new file mode 100644 index 00000000..f90f05b0 --- /dev/null +++ b/components/roadmap/NewRoadMapHeaderTick.tsx @@ -0,0 +1,43 @@ +import { ScaleTime } from 'd3' +import { Dayjs } from 'dayjs' + +import { useMaxHeight } from '../../hooks/useMaxHeight' +import getDateAsQuarter from '../../lib/client/getDateAsQuarter' +import { TimeUnit } from '../../lib/enums' + +interface NewRoadmapHeaderTickProps { + scale: ScaleTime; + date: Dayjs; + y: number; + height: number; + timeUnit: TimeUnit; + maxHeight?: number; +} + +export default function NewRoadmapHeaderTick ({ date, y, height, scale, timeUnit, maxHeight }: NewRoadmapHeaderTickProps) { + const maxH = Math.max(maxHeight ?? 0, useMaxHeight()) + const startX = scale(date.startOf(timeUnit).toDate()) + const endX = scale(date.endOf(timeUnit).toDate()) + const width = (endX - startX) + if (width < 0) return null + + let dateLabel = date.format('MMM YYYY') + if (timeUnit === TimeUnit.Quarter) { + dateLabel = getDateAsQuarter(date) + } else if (timeUnit === TimeUnit.Year) { + dateLabel = date.format('YYYY') + } + + return + + {dateLabel} + + + {/* Render a small line at the start of each timeUnit */} + + {/* Render a small line at the end of each timeUnit */} + + {/* Render a dotted line spanning the full height of the svg */} + + +} diff --git a/components/roadmap/NewRoadmap.tsx b/components/roadmap/NewRoadmap.tsx index c4550f57..0646c272 100644 --- a/components/roadmap/NewRoadmap.tsx +++ b/components/roadmap/NewRoadmap.tsx @@ -1,68 +1,341 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Center, Spinner } from '@chakra-ui/react'; +import { Center, Spinner } from '@chakra-ui/react' +import { scaleTime, select, zoom as d3Zoom, D3ZoomEvent, ZoomTransform } from 'd3' +import { useRouter } from 'next/router' +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { scaleTime } from 'd3'; -import React, { useEffect, useRef, useState } from 'react'; +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import { useMaxHeight } from '../../hooks/useMaxHeight' +import { useViewMode } from '../../hooks/useViewMode' +import { dayjs } from '../../lib/client/dayjs' +import { getDates } from '../../lib/client/getDates' +import { BinPackedGroup } from '../../lib/types' +import { IssueDataStateContext, IssuesGroupedContext } from './contexts' +import { binPack, getDefaultZoomTransform, getHashFromZoomTransform } from './lib' +import NewRoadmapHeader from './NewRoadMapHeader' +import styles from './Roadmap.module.css' +import RoadmapGroupRenderer from './RoadmapGroupRenderer' +import TodayLine from './TodayLine' -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import { useMaxHeight, setMaxHeight } from '../../hooks/useMaxHeight'; -import { dayjs } from '../../lib/client/dayjs'; -import { IssueData } from '../../lib/types'; -import AxisTop from './AxisTop'; -import RoadmapHeader from './RoadmapHeader'; -import RoadmapItem from './RoadmapItem'; -import TodayLine from './TodayLine'; +/** + * @todo: be smarter about choosing yZoomMin (large timespan roadmaps can't zoom out far enough) + */ +const yZoomMin = 0.2 // zoom OUT limit +const yZoomMax = 3 // zoom IN limit +const roadmapItemWidth = 350 -function NewRoadmap({ issueData }: { issueData: IssueData; isLocal: boolean }) { - if (!issueData) return null; +function NewRoadmap () { + const router = useRouter() + const issueDataState = useContext(IssueDataStateContext) + const issuesGroupedState = useContext(IssuesGroupedContext) + // eslint-disable-next-line no-unused-vars + const [isDevMode, _setIsDevMode] = useState(false) + const [leftMostMilestoneX, setLeftMostMilestoneX] = useState(0) + const [rightMostMilestoneX, setRightMostMilestoneX] = useState(0) + const [, setTopMostMilestoneY] = useState(0) + const [bottomMostMilestoneY, setBottomMostMilestoneY] = useState(0) - const ref = useRef(null); - const dates = issueData.children.map((issue) => issue.due_date).filter((dateString) => !!dateString); - const [maxW, setMaxW] = useState(1000); - const maxH = useMaxHeight(); + // const defaultZoomTransform = new ZoomTransform(1, 0, 0) + const viewMode = useViewMode() + const defaultZoomSet = useRef(false) + const [zoomTransform, setZoomTransform] = useState(getDefaultZoomTransform(defaultZoomSet)) + const zoomHash = useMemo(() => getHashFromZoomTransform(zoomTransform), [zoomTransform]) useEffect(() => { - setMaxW(window.innerWidth); - setMaxHeight(window.innerHeight / 2); - }, []); - - const dayjsDates = dates.map((date) => dayjs(date)); - const startDate = dayjs().subtract(3, 'months'); - const endDate = dayjs().add(3, 'months'); - const earliestEta = dayjs.min(dayjsDates) ?? startDate; - const latestEta = dayjs.max(dayjsDates.concat(dayjs())) ?? endDate; - const minMaxDiff = Math.max(latestEta.diff(earliestEta, 'days'), 10); - const margin = { top: 0, right: 0, bottom: 20, left: 0 }; - const width = maxW - margin.left - margin.right; - const height = maxH - margin.top - margin.bottom; - const globalLoadingState = useGlobalLoadingState(); - - const scaleX = scaleTime() - .domain([earliestEta.subtract(minMaxDiff / 4, 'days').toDate(), latestEta.add(minMaxDiff / 6, 'days').toDate()]) - .range([0, width]); - - if (globalLoadingState.get()) { + const asyncFn = async () => { + /** + * if the router isn't ready, or defaultZoomSet.current is false, then we + * don't want to update the url yet. + */ + if (!router.isReady || !defaultZoomSet.current) { + return + } + try { + await router.replace({ hash: zoomHash }, undefined, { shallow: true }) + } catch { + // catch, but ignore cancelled route errors. + } + } + asyncFn() + // Ignore the react-hooks/exhaustive-deps warning for 'router' only, otherwise we would repeatedly call router.replace + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomHash]) + + /** + * Using useState and attachRef as done at https://github.com/facebook/react/issues/11258#issuecomment-495262508 + * in order to get around a bug where reference is null on the last re-render, + * which causes d3 zoom/pan other event handlers not to work. + */ + const [ref, attachRef] = useState(null) + const [maxW, setMaxW] = useState(1000) + const maxH = useMaxHeight() + + const dayjsDates = useMemo(() => getDates({ issuesGroupedState }), [issuesGroupedState]) + const earliestEta = useMemo(() => dayjs.min(dayjsDates.concat(dayjs().subtract(1, 'month'))).toDate(), [dayjsDates]) + const latestEta = useMemo(() => dayjs.max(dayjsDates.concat(dayjs().add(1, 'month'))).toDate(), [dayjsDates]) + const margin = { top: 0, right: 0, bottom: 20, left: 0 } + + const maxScaleRangeX = useMemo(() => { + if (ref) { + const rect = ref.getBoundingClientRect() + return rect.width + } + return maxW + }, [maxW, ref]) + + const height = useMemo(() => maxH - margin.top - margin.bottom, [margin.bottom, margin.top, maxH]) + + const globalLoadingState = useGlobalLoadingState() + + useEffect(() => { + window.addEventListener('resize', () => { + setMaxW(window.innerWidth) + }) + }, []) + + const zoomBehavior = useMemo(() => { + function getEventDetails (event) { + const isMouseEvent = event.type.includes('mouse') + const isHorizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY) + const isZoomAttempt = event.type === 'wheel' && (event.ctrlKey || event.metaKey) + const isZoom = isZoomAttempt && !isHorizontal + const isVerticalScroll = !isZoom && !isMouseEvent && !isHorizontal + + const isPan = !isZoom && (isMouseEvent || isHorizontal) + return { isMouseEvent, isZoom, isPan, isHorizontal, isVerticalScroll } + } + const zoomFilter = (event) => { + event.preventDefault() + event.stopImmediatePropagation() + let keepEvent = false + + const { isZoom, isPan, isVerticalScroll } = getEventDetails(event) + + if (isZoom || isPan) { + keepEvent = true + } else if (isVerticalScroll) { + window.scrollBy(0, event.deltaY) + event.stopImmediatePropagation() + keepEvent = false + } + + return keepEvent + } + // const translateExtent: [[number, number], [number, number]] = [[-maxScaleRangeX, 0], [maxScaleRangeX*2, 0]] + return d3Zoom() + .on('start', (event) => { + // event.preventDefault(); + const domEvent = event.sourceEvent + if (domEvent) { + // event.sourceEvent is empty when we call zoomBehavior.scaleTo + domEvent.preventDefault() + domEvent.stopImmediatePropagation() + if (domEvent.type !== 'wheel') { + select(ref).style('cursor', 'grabbing') + } + } + }) + .on('end', (event) => { + const domEvent = event.sourceEvent + + if (domEvent) { + // event.sourceEvent is empty when we call zoomBehavior.scaleTo + if (domEvent.type !== 'wheel') { + select(ref).style('cursor', 'grab') + } + } + }) + .on('zoom', (event: D3ZoomEvent) => { + const domEvent = event.sourceEvent + if (domEvent == null) { + // programmatic zoom event. + setZoomTransform(event.transform) + return + } + const { isMouseEvent, isZoom, isPan, isVerticalScroll } = getEventDetails(domEvent) + + if (isZoom) { + setZoomTransform(event.transform) + } else if (isPan) { + if (isMouseEvent) { + setZoomTransform((oldTransform) => new ZoomTransform(oldTransform.k, event.transform.x, oldTransform.y)) + } else { + // horizontal scroll + // on mac, it frequently tries to go back when I want to prevent it from doing so + event.sourceEvent.preventDefault() + setZoomTransform((oldTransform) => new ZoomTransform(oldTransform.k, oldTransform.x - domEvent.deltaX, oldTransform.y)) + } + } else if (isVerticalScroll) { + /** + * vertical scrolling needs to be done manually because we are using + * preventDefault + */ + window.scrollBy(0, -domEvent.deltaY / 10) + } + }) + // .translateExtent(translateExtent) + .scaleExtent([yZoomMin, yZoomMax]) + .extent([[0, 0], [maxScaleRangeX, 0]]) + .filter(zoomFilter) + }, [maxScaleRangeX, ref]) + + const scaleX = useMemo(() => { + const scaleRange = [0, maxScaleRangeX] + const scaleDomain = [earliestEta, latestEta] + const scale = scaleTime() + .domain(scaleDomain) + .range(scaleRange) + + if (zoomTransform) { + // rescales the dates based on the zoom transform + const newScale = zoomTransform.rescaleX(scale) + + scale.domain(newScale.domain()) + } + + return scale + }, [earliestEta, latestEta, maxScaleRangeX, zoomTransform]) + + useEffect(() => { + if (ref != null) { + select(ref) + .call(zoomBehavior) + // .call(zoomBehavior, zoomTransform) + } + }, [zoomBehavior, ref, zoomTransform]) + + const titlePadding = 30 + const binPackedGroups: BinPackedGroup[] = useMemo(() => { + let leftMostX = Infinity + let rightMostX = -Infinity + let topMostY = -Infinity + let bottomMostY = 40 + const newGroups: BinPackedGroup[] = [] + const shouldAddYMinBuffer = issuesGroupedState.length > 1 + issuesGroupedState.forEach((issueGroup) => { + const { items } = issueGroup + const [binPackedIssues, stats] = binPack(items, { + scale: scaleX, + width: roadmapItemWidth, + height: 80, + ySpacing: 5, + xSpacing: 0, + yMin: bottomMostY + (shouldAddYMinBuffer ? titlePadding : 0) + }) + + leftMostX = Math.min(leftMostX, stats.left) + rightMostX = Math.max(rightMostX, stats.right) + topMostY = Math.min(topMostY, stats.top) + bottomMostY = Math.max(bottomMostY, stats.bottom) + + newGroups.push({ + ...issueGroup, + items: binPackedIssues + }) + }) + + setLeftMostMilestoneX(leftMostX) + setRightMostMilestoneX(rightMostX) + setTopMostMilestoneY(topMostY) + setBottomMostMilestoneY(bottomMostY) + return newGroups + }, [issuesGroupedState, scaleX]) + + const visibleLeftX = useMemo(() => scaleX(scaleX.domain()[0]), [scaleX]) + const visibleRightX = useMemo(() => scaleX(scaleX.domain()[1]), [scaleX]) + const isLeftMilestoneVisible = useMemo(() => leftMostMilestoneX > visibleLeftX, [leftMostMilestoneX, visibleLeftX]) + const isRightMilestoneVisible = useMemo(() => rightMostMilestoneX < visibleRightX, [rightMostMilestoneX, visibleRightX]) + const zoomKStep = 0.1 + const zoomPanStep = maxScaleRangeX / 10 + const increaseZoomK = useCallback(() => { + if (ref) { + zoomBehavior.scaleTo(select(ref), zoomTransform.k + zoomKStep) + } + // setZoomTransform((oldTransform) => new ZoomTransform(oldTransform.x, oldTransform.y, oldTransform.k + zoomKStep)) + }, [ref, zoomBehavior, zoomTransform.k]) + const decreaseZoomK = useCallback(() => { + if (ref) { + zoomBehavior.scaleTo(select(ref), zoomTransform.k - zoomKStep) + } + // setZoomTransform((oldTransform) => new ZoomTransform(oldTransform.x, oldTransform.y, oldTransform.k - zoomKStep)) + }, [ref, zoomBehavior, zoomTransform.k]) + const panRight = useCallback((amount: number | null = null) => { + if (ref) { + zoomBehavior.translateBy(select(ref), amount ?? zoomPanStep, 0) + } + }, [ref, zoomBehavior, zoomPanStep]) + const panLeft = useCallback((amount: number | null = null) => { + if (ref) { + zoomBehavior.translateBy(select(ref), (amount ?? zoomPanStep) * -1, 0) + } + }, [ref, zoomBehavior, zoomPanStep]) + + useEffect(() => { + // this effect handles automatically zooming out and panning to create a default view + // it will continuously run until defaultZoomSet.current === true + if (!defaultZoomSet.current) { + const visibleWidth = visibleRightX - visibleLeftX + const milestoneWidth = rightMostMilestoneX - leftMostMilestoneX + if (isLeftMilestoneVisible && isRightMilestoneVisible) { + // update zoomTransform so that all milestones are visible + defaultZoomSet.current = true + } else { + if (milestoneWidth > visibleWidth) { + // focus on zooming out first. then pan left or right + decreaseZoomK() + } else { + const rightPanAmount = visibleRightX - rightMostMilestoneX + const leftPanAmount = leftMostMilestoneX - visibleLeftX + // calculate how much to pan left or right so that all milestones are visible + // pan left leftPanAmount + const xPaddingAvailable = visibleWidth - milestoneWidth + if (Math.abs(rightPanAmount) > Math.abs(leftPanAmount)) { + panRight(rightPanAmount - xPaddingAvailable / 2) + } else { + panLeft(Math.abs(leftPanAmount) + xPaddingAvailable / 2) + } + } + } + } + }, [decreaseZoomK, isLeftMilestoneVisible, isRightMilestoneVisible, leftMostMilestoneX, panLeft, panRight, rightMostMilestoneX, visibleLeftX, visibleRightX]) + + // we set the height to the max value of either the bottom most milestone or the height of the container + const calcHeight = Math.max(bottomMostMilestoneY + 5, height) + // fixes issue with dotted lines from header ticks not extending to bottom of container + // when calcHeight > maxH, however, it also overrides the minimum height of the container + // useEffect(() => {setMaxHeight(calcHeight)}, [calcHeight]); + + if (globalLoadingState.get() || issueDataState.ornull === null) { return (
- ); + ) } return ( <> - - {/* {isLocal && } */} - - - - - {issueData.children.map((childIssue, index) => ( - - ))} - + {isDevMode && } + {isDevMode && } + {isDevMode && } + {isDevMode && } + {isDevMode && } +
+ + + + + +
- ); + ) } -export default NewRoadmap; +export default NewRoadmap diff --git a/components/roadmap-grid/Roadmap.module.css b/components/roadmap/Roadmap.module.css similarity index 76% rename from components/roadmap-grid/Roadmap.module.css rename to components/roadmap/Roadmap.module.css index 058603e7..c609ab68 100644 --- a/components/roadmap-grid/Roadmap.module.css +++ b/components/roadmap/Roadmap.module.css @@ -245,3 +245,66 @@ button.gridViewTab { background: #F9FCFF; border: 1px solid #A2D0DE; } + +/** + D3 Roadmap Styles + Note that some css properties for SVG elements are not recognized by cssLint + and you will need to modify `css.lint.validProperties` in + `./.vscode/settings.json` to remove errors for valid properties. +*/ +.d3-draggable { + cursor: grab; +} + +/** + Any direct child should reset it's cursor back to default. +*/ +.d3-draggable > * { + cursor: default; +} + +.d3__groupTitle { + font-size: 18px; +} + +.d3__milestoneItem { + fill: var(--chakra-colors-background); +} +.d3__milestoneItem.d3__milestoneItem-clickable { + cursor: pointer; +} +.d3__milestoneItem .d3__milestoneItem__rect { + stroke: var(--chakra-colors-inactiveAccent); +} +.d3__milestoneItem:hover.d3__milestoneItem-clickable .d3__milestoneItem__rect { + stroke: var(--chakra-colors-spotLightBlue); +} + +.d3__milestoneItem__rect { + stroke-width: 1; + rx: 10; +} + +.d3__milestoneItem__title { + font-weight: 500; + fill: var(--chakra-colors-text); + font-size: 16px; +} + +.d3__milestoneItem-clickable .d3__milestoneItem__title { + fill: var(--chakra-colors-linkBlue); +} + +.d3__milestoneItem__eta { + font-size: 13px; + fill: var(--chakra-colors-textMuted); + font-weight: 300; + line-height: 1; +} + +.d3__milestoneItem__githubLogo { + fill: var(--chakra-colors-textMuted); +} +.d3__milestoneItem__githubLogo:hover { + fill: var(--chakra-colors-text); +} diff --git a/components/roadmap/RoadmapGroup.tsx b/components/roadmap/RoadmapGroup.tsx new file mode 100644 index 00000000..778941ea --- /dev/null +++ b/components/roadmap/RoadmapGroup.tsx @@ -0,0 +1,24 @@ +import { BinPackedGroup } from '../../lib/types' +import BinPackedMilestoneItem from './BinPackedMilestoneItem' +import { BinPackedGroupHeader } from './group-header' +import { ItemContainerSvg } from './svg/ItemContainerSvg' + +export default function RoadmapGroup ({ binPackedGroup, index }: {binPackedGroup: BinPackedGroup, index: number}) { + /** + * @todo: support collapsing/expanding groups + */ + if (binPackedGroup.items.length === 0) { + console.warn(`Not rendering empty group for ${binPackedGroup.groupName}`) + return null + } + return ( + + + + + {binPackedGroup.items.map((item, itemIndex) => ( + + ))} + + ) +} diff --git a/components/roadmap/RoadmapGroupRenderer.tsx b/components/roadmap/RoadmapGroupRenderer.tsx new file mode 100644 index 00000000..9c79e0d1 --- /dev/null +++ b/components/roadmap/RoadmapGroupRenderer.tsx @@ -0,0 +1,23 @@ +import { ReactElement } from 'react' + +import { BinPackedGroup } from '../../lib/types' +import BinPackedMilestoneItem from './BinPackedMilestoneItem' +import RoadmapGroup from './RoadmapGroup' +import { ItemContainerSvg } from './svg/ItemContainerSvg' + +export default function RoadmapGroupRenderer ({ binPackedGroups }: {binPackedGroups: BinPackedGroup[]}): ReactElement { + if (binPackedGroups.length === 1) { + return ( + <> + {binPackedGroups[0].items.map((item, index) => ( + + ))} + + ) + } + return ( + <> + {binPackedGroups.filter((binPackedGroup) => binPackedGroup.items.length > 0).map((binPackedGroup, gIdx) => ())} + + ) +} diff --git a/components/roadmap/RoadmapHeader.tsx b/components/roadmap/RoadmapHeader.tsx deleted file mode 100644 index 807a72af..00000000 --- a/components/roadmap/RoadmapHeader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useRouter } from 'next/router'; -import React from 'react'; - -import { Link, Text } from '@chakra-ui/react'; - -import { IssueData } from '../../lib/types'; - -interface RoadmapHeaderProps { - issueData: IssueData; -} - -function RoadmapHeader({ issueData }: RoadmapHeaderProps) { - const router = useRouter(); - - return ( - <> - {/* TODO: */} - router.back()}> - {'<'} Back to parent roadmap - - {/* TODO: add github icon */} - - {/* */} - {/* */} - {issueData.title} - {/* */} - {/* */} - - {/* - - (github) - - */} - - ); -} - -export default RoadmapHeader; -export type { RoadmapHeaderProps }; diff --git a/components/roadmap/RoadmapItem.tsx b/components/roadmap/RoadmapItem.tsx deleted file mode 100644 index 49898a69..00000000 --- a/components/roadmap/RoadmapItem.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import NextLink from 'next/link'; - -import { ScaleTime } from 'd3'; -import dayjs from 'dayjs'; - -import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild'; -import { IssueData } from '../../lib/types'; -import { useMaxHeight, setMaxHeight } from '../../hooks/useMaxHeight'; -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import React from 'react'; - -function RoadmapItem({ - childIssue, - scale, - index, -}: { - childIssue: IssueData; - scale: ScaleTime; - index: number; -}) { - const maxSvgHeight = useMaxHeight(); - console.log('index:', index); - const y = 50; - const yPadding = 5; - const etaX = scale(dayjs(childIssue.due_date).toDate()); - const ySpacingBetweenItems = 20; - const rectConfig = { - width: 300, - height: 80, - strokeWidth: 2, - }; - const minimumY = 20; - // TODO: sgtpooki: increase distance of first item from top axis - const yLocation = Math.max(y + yPadding + ((rectConfig.height + ySpacingBetweenItems) * index + 1), minimumY); - const textPadding = 10; - const rxSize = 10; - - // const randomIntFromInterval = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min); - useEffect(() => { - setMaxHeight(Math.max(rectConfig.height + yLocation, maxSvgHeight)) - }, [maxSvgHeight, rectConfig.height, yLocation]) - - // TODO: Add on hover to show clickability - return ( - - - - - {/* {childIssue.completion_rate}% complete */} - {/* {childIssue.completion_rate}% */} - - {childIssue.title} - - {/* state: {childIssue.state} */} - - - {childIssue.due_date} - - - - ); -} - -export default RoadmapItem; diff --git a/components/roadmap/RoadmapTabbedView.tsx b/components/roadmap/RoadmapTabbedView.tsx new file mode 100644 index 00000000..3bbaf7da --- /dev/null +++ b/components/roadmap/RoadmapTabbedView.tsx @@ -0,0 +1,151 @@ +import { + Box, + Center, + Flex, + Link, + Skeleton, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Spacer +} from '@chakra-ui/react' +import { State, useHookstateMemo } from '@hookstate/core' +import { useRouter } from 'next/router' +import React, { useContext } from 'react' + +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import { setViewMode, useViewMode } from '../../hooks/useViewMode' +import { convertIssueDataStateToDetailedViewGroupOld } from '../../lib/client/convertIssueDataToDetailedViewGroup' +import { DEFAULT_INITIAL_VIEW_MODE } from '../../lib/defaults' +import { ViewMode } from '../../lib/enums' +import { IssueData } from '../../lib/types' +import SvgDetailViewIcon from '../icons/svgr/SvgDetailViewIcon' +import SvgListViewIcon from '../icons/svgr/SvgListViewIcon' +import SvgOverviewIcon from '../icons/svgr/SvgOverviewIcon' +import RoadmapList from '../RoadmapList' +import { IssueDataStateContext, IssuesGroupedContext } from './contexts' +import Header from './header' +import NewRoadmap from './NewRoadmap' +import styles from './Roadmap.module.css' +import { TodayMarkerToggle } from './today-marker-toggle' + +export function RoadmapTabbedView () { + const globalLoadingState = useGlobalLoadingState() + const viewMode = useViewMode() || DEFAULT_INITIAL_VIEW_MODE + const router = useRouter() + const issueDataState = useContext(IssueDataStateContext) + // Defining what tabs to show and in what order + const tabs = ['Detailed View', 'Overview', 'List'] as const + + // Mapping the views to the tabs + const tabViewMap: Record = { + 'Detailed View': ViewMode.Detail, + Overview: ViewMode.Simple, + List: ViewMode.List + } + + // Mapping the tabs to the views + const tabViewMapInverse: Record = tabs.reduce((acc, tab, index) => { + acc[tabViewMap[tab]] = index + return acc + }, {} as Record) + + const tabIndexFromViewMode = tabViewMapInverse[viewMode] + + const handleTabChange = async (index: number) => { + setViewMode(tabViewMap[tabs[index]]) + const currentHashString = router.asPath.split('#')[1] + const currentHashParams = new URLSearchParams(currentHashString) + currentHashParams.set('view', tabViewMap[tabs[index]]) + try { + await router.push({ + hash: currentHashParams.toString() + }, undefined, { shallow: true }) + } catch { + // catch, but ignore cancelled route errors. + } + } + + const renderTab = (title: typeof tabs[number], index: number) => { + let TabIcon = SvgDetailViewIcon + + if (title === 'Overview') { + TabIcon = SvgOverviewIcon + } else if (title === 'List') { + TabIcon = SvgListViewIcon + } + + return ( + + +
+ + {title} +
+
+
+ ) + } + // const ref = useRef(null); + + const renderTabPanel = (title: typeof tabs[number], index: number) => { + let component = + + if (title === 'List') { + component = + } + return ( + + {component} + + ) + } + + const query = router.query + + // const groupedIssuesIdPrev = usePrevious(''); + + // const groupedIssuesId = getUniqIdForGroupedIssues(issuesGrouped) + viewMode; + const issuesGrouped = useHookstateMemo(() => { + if (issueDataState.ornull === null) { + return [] + } + const newIssuesGrouped = convertIssueDataStateToDetailedViewGroupOld(issueDataState as State, viewMode, query) + // const newIssuesGrouped2 = convertIssueDataToDetailedViewGroup(issueDataState.ornull.get({ noproxy: true }) as IssueData) + // console.log(`newIssuesGrouped2: `, newIssuesGrouped2); + // console.log(`newIssuesGrouped: `, newIssuesGrouped); + // if (viewMode === ViewMode.Detail) { + // return newIssuesGrouped2 + // } + return newIssuesGrouped + }, [viewMode, query, issueDataState]) + + return ( + <> + +
+ + + + + {tabs.map(renderTab)} + + + + + + + {tabs.map(renderTabPanel)} + + + + + + + ) +} diff --git a/components/roadmap/TodayLine.tsx b/components/roadmap/TodayLine.tsx index c6919858..b74a27ce 100644 --- a/components/roadmap/TodayLine.tsx +++ b/components/roadmap/TodayLine.tsx @@ -1,27 +1,45 @@ -import { ScaleTime } from 'd3'; +import { ScaleTime } from 'd3' -import { dayjs } from '../../lib/client/dayjs'; +import { setShowTodayMarker, useShowTodayMarker } from '../../hooks/useShowTodayMarker' +import { dayjs } from '../../lib/client/dayjs' +import styles from './today-marker.module.css' -function TodayLine({ scale, height }: { scale: ScaleTime; height: number }) { - const todayX = scale(dayjs().toDate()); +function TodayLine ({ scale, height }: { scale: ScaleTime; height: number, transform?: string }) { + const todayX = scale(dayjs().toDate()) + const showTodayMarker = useShowTodayMarker() - return ( - - - Today - + if (!showTodayMarker) { + return null + } + + const size = 5 + const yMin = 0 + /** + * Draws a filled triangle + rectangle, centered at the top center of the + * todayLine, that kind of looks like: + * .-----. + * | | + * . . + * + * . + * + * The '.' in the above are the points of the polygon defined below. + */ + const polygonPointArray = [ + [todayX - size, yMin], // the top left point + [todayX - size, yMin + size], // the bottom left point (end of rectangle) + [todayX, yMin + size * 2], // the bottom tip of the triangle + [todayX + size, yMin + size], // the bottom right point (end of the rectangle) + [todayX + size, yMin] // the top right point + ] + const points = polygonPointArray.map(point => point.join(',')).join(' ') - + return ( + setShowTodayMarker(!showTodayMarker)}> + + - ); + ) } -export default TodayLine; +export default TodayLine diff --git a/components/roadmap/WeekTicksSelector.tsx b/components/roadmap/WeekTicksSelector.tsx deleted file mode 100644 index 4a13bda9..00000000 --- a/components/roadmap/WeekTicksSelector.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Slider from 'react-input-slider'; - -import { setWeekTicks, useWeekTicks } from '../../hooks/useWeekTicks'; - -function WeekTicksSelector() { - const weekTicks = useWeekTicks(); - - return ( - <> - {weekTicks} Weeks per tick: - setWeekTicks(x)} /> - - ); -} - -export default WeekTicksSelector; diff --git a/components/roadmap/contexts.ts b/components/roadmap/contexts.ts new file mode 100644 index 00000000..871cb158 --- /dev/null +++ b/components/roadmap/contexts.ts @@ -0,0 +1,8 @@ +import { hookstate, State } from '@hookstate/core' +import { createContext } from 'react' + +import { DetailedViewGroup, IssueData } from '../../lib/types' + +export const IssueDataStateContext = createContext>(hookstate(null) as State) + +export const IssuesGroupedContext = createContext([] as DetailedViewGroup[]) diff --git a/components/roadmap/group-header.tsx b/components/roadmap/group-header.tsx new file mode 100644 index 00000000..378672dd --- /dev/null +++ b/components/roadmap/group-header.tsx @@ -0,0 +1,46 @@ +import { Text } from '@chakra-ui/react' +import { isEmpty } from 'lodash' +import NextLink from 'next/link' +import { useRouter } from 'next/router' +import React, { ReactElement, useContext } from 'react' + +import { useViewMode } from '../../hooks/useViewMode' +import { getLinkForRoadmapChild } from '../../lib/client/getLinkForRoadmapChild' +import { ViewMode } from '../../lib/enums' +import { BinPackedGroup } from '../../lib/types' +import { IssueDataStateContext } from './contexts' +import styles from './Roadmap.module.css' + +export function BinPackedGroupHeader ({ group }: {group: BinPackedGroup }) { + const issueDataState = useContext(IssueDataStateContext) + const viewMode = useViewMode() + const router = useRouter() + let groupNameElement: ReactElement | null = null + const issueData = { + html_url: group.url.replace('/roadmap/', ''), + children: group.items + } + if (issueDataState.ornull === null) { + return null + } + if (viewMode === ViewMode.Detail) { + if (isEmpty(group.url)) { + groupNameElement = {group.groupName} + } else { + const groupHeaderLink = getLinkForRoadmapChild({ + issueData: issueData as unknown as Parameters[0]['issueData'], + currentRoadmapRoot: issueDataState.ornull.value, + viewMode, + query: router.query, + replaceOrigin: false + }) + groupNameElement = {group.groupName} + } + } + + return ( +
+
{groupNameElement}
+
+ ) +} diff --git a/components/roadmap/header.tsx b/components/roadmap/header.tsx new file mode 100644 index 00000000..1fc84ae3 --- /dev/null +++ b/components/roadmap/header.tsx @@ -0,0 +1,41 @@ +import { Center, Flex, Link, Skeleton, Spacer, Text } from '@chakra-ui/react' +import Image from 'next/image' +import NextLink from 'next/link' +import React, { useContext } from 'react' +import { ReactElement } from 'react-markdown/lib/react-markdown' + +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import GitHubSvgIcon from '../icons/GitHubLogo.svg' +import themes from '../theme/constants' +import { IssueDataStateContext } from './contexts' + +export default function Header (): ReactElement { + const globalLoadingState = useGlobalLoadingState() + const isGlobalLoading = globalLoadingState.get() + const issueDataState = useContext(IssueDataStateContext) + const rootIssueUrl = issueDataState.ornull?.html_url?.value + const rootIssueTitle = issueDataState.ornull?.title?.value + + const isReadyToRender = typeof rootIssueUrl === 'string' && typeof rootIssueTitle === 'string' && !isGlobalLoading + + return ( + + + + {rootIssueTitle} + + +
+ + +
+ View in GitHub + GitHub Logo +
+ +
+
+
+
+ ) +} diff --git a/components/roadmap/lib.ts b/components/roadmap/lib.ts new file mode 100644 index 00000000..e311e95f --- /dev/null +++ b/components/roadmap/lib.ts @@ -0,0 +1,139 @@ + +import { ImmutableArray } from '@hookstate/core' +import { ScaleTime, ZoomTransform } from 'd3' +import { MutableRefObject } from 'react' + +import { dayjs } from '../../lib/client/dayjs' +import { BinPackIssueData, BinPackItem, BoxItem } from '../../lib/types.js' + +interface BinPackOptions { + width: number + height: number + scale: ScaleTime + xSpacing?: number + ySpacing?: number + yMin?: number +} + +function getAllRectsWithCollisionsOnXRange (rects: BinPackItem[], x1: number, x2: number) { + // this can be calculated as item that are outside the range of x1 and x2. + return rects.filter(rect => !(rect.right < x1 || rect.left > x2)) +} + +/** + * A bin-packing algorithm that converts items to a position within an x,y coordinate system, given: + * 1. an ETA date (this is converted to the x2 position; i.e. x2 = scale(eta)) + * 2. an index (this is used to determine order; i.e. whether the item is placed above or below other items, and it is assumed the given items are in order) + * 3. a width (this is used to determine the x1 position; e.g. x1 = x2 - width) + * 4. a height (this is used to determine the y2 position; e.g. y2 = y1 + height) + * + * y1 is determined by finding the first empty space where the space between y1 and y2 are not occupied by other items within the same x1 and x2 range. + */ +export const binPack = (items: ImmutableArray, { height, width, scale, yMin, ...opts }: BinPackOptions): [BinPackItem[], BoxItem] => { + const sortedItems = items + const rects: BinPackItem[] = [] + const ySpacing = opts.ySpacing ?? 0 + const statsObj: BoxItem = { + top: Infinity, + bottom: -Infinity, + left: Infinity, + right: -Infinity + } + + for (const item of sortedItems) { + if (item.due_date == null || item.due_date === '') { + // if there's no due date, then we don't place it on the roadmap + continue + } + const dueDate = dayjs(item.due_date) + const x2 = scale(dueDate.endOf('day').toDate()) + const x1 = x2 - width + + const overlappingRects = getAllRectsWithCollisionsOnXRange(rects, x1, x2).sort((a, b) => a.bottom - b.bottom) + let y1 = yMin ?? 0 + // if the space between one rect and another is greater than the height, then we can place it there + // otherwise, we need to find the first empty space + if (overlappingRects.length > 0 && overlappingRects[0].top === (yMin ?? 0)) { + // ensure that we don't loop over items if there's not already an item in the first row. + // if (overlappingRects[0].top === (yMin ?? 0)) { + // y1 = overlappingRects.reduce((y1, rect) => Math.max(y1, rect.bottom + ySpacing), yMin ?? 0); + for (let i = 0; i <= overlappingRects.length - 1; i++) { + const currentRect = overlappingRects[i] + const nextRect = overlappingRects[i + 1] + if (nextRect != null) { + const spaceBetweenCurrentRectAndNext = nextRect.top - currentRect.bottom + if (spaceBetweenCurrentRectAndNext >= height) { + y1 = currentRect.bottom + ySpacing + break + } + } else { + y1 = Math.max(y1, currentRect.bottom + ySpacing) + } + // } + } + } + const y2 = y1 + height + + statsObj.top = Math.min(statsObj.top, y1) + statsObj.bottom = Math.max(statsObj.bottom, y2) + statsObj.left = Math.min(statsObj.left, x1) + statsObj.right = Math.max(statsObj.right, x2) + + rects.push({ + left: x1, + right: x2, + top: y1, + bottom: y2, + data: item + }) + } + + return [rects, statsObj] +} + +/** + * Returns the hashString by merging the current hashString with the given zoomTransform + * + * @param zoomTransform + * @returns {string} the hashString + */ +export const getHashFromZoomTransform = (zoomTransform: ZoomTransform) => { + const d3x = zoomTransform.x.toFixed(2) + const d3y = zoomTransform.y.toFixed(2) + const d3k = zoomTransform.k.toFixed(2) + + const hashString = window.location.hash.substring(1) ?? '' + // const hashString = window.location.search ?? '' + const hashParams = new URLSearchParams(hashString) + hashParams.set('d3x', String(d3x)) + hashParams.set('d3y', String(d3y)) + hashParams.set('d3k', String(d3k)) + + // const newUrl = new URL(window.location.toString()) + // newUrl.hash = hashParams.toString() + + // setShareLink(newUrl.toString()) + return hashParams.toString() +} + +export const getDefaultZoomTransform = (defaultZoomSet: MutableRefObject) => { + if (typeof window === 'undefined') { + return new ZoomTransform(1, 0, 0) + } + // load zoomTransform from URL, only once. + const hashString = window.location.hash.substring(1) || '' + const hashParams = new URLSearchParams(hashString) + const d3xParam = hashParams.get('d3x') + const d3yParam = hashParams.get('d3y') + const d3kParam = hashParams.get('d3k') + if (d3xParam || d3yParam || d3kParam) { + // we received some urlParameters, prevent finding the default zoom. + defaultZoomSet.current = true + } + + const d3x = Number(d3xParam) || 0 + const d3y = Number(d3yParam) || 0 + const d3k = Number(d3kParam) || 1 + + return new ZoomTransform(d3k, d3x, d3y) +} diff --git a/components/roadmap/svg/ItemContainerSvg.ts b/components/roadmap/svg/ItemContainerSvg.ts new file mode 100644 index 00000000..8a5c5779 --- /dev/null +++ b/components/roadmap/svg/ItemContainerSvg.ts @@ -0,0 +1,57 @@ +import { BinPackItem, BoxItem } from '../../../lib/types' + +interface ItemContainerSvgConstructorOptions { + item: BinPackItem; + padding?: { x: number, y: number } + strokeWidth?: number; +} + +class BoxModel { + top: number + bottom: number + left: number + right: number + width: number + height: number + + constructor ({ item }: { item: BoxItem }) { + this.top = item.top + this.bottom = item.bottom + this.left = item.left + this.right = item.right + this.width = Math.abs(item.right - item.left) + this.height = Math.abs(item.top - item.bottom) + } +} + +export class ItemContainerSvg extends BoxModel implements BinPackItem { + static defaultXPadding = 10 + static defaultYPadding = 5 + static defaultStrokeWidth = 2 + data: BinPackItem['data'] + boundary: BoxModel + + constructor ({ + item, padding, strokeWidth = ItemContainerSvg.defaultStrokeWidth + }: ItemContainerSvgConstructorOptions) { + super({ item }) + this.data = item.data + + const computedPadding = { + x: padding?.x ?? ItemContainerSvg.defaultXPadding, + y: padding?.y ?? ItemContainerSvg.defaultYPadding + } + + const horizontalPadding = strokeWidth + computedPadding.x + const verticalPadding = strokeWidth + computedPadding.y + + this.boundary = new BoxModel({ + item: { + top: item.top + verticalPadding, + bottom: item.bottom - verticalPadding, + left: item.left + horizontalPadding, + right: item.right - horizontalPadding + } + }) + } +} diff --git a/components/roadmap/today-marker-toggle.tsx b/components/roadmap/today-marker-toggle.tsx new file mode 100644 index 00000000..31ab8294 --- /dev/null +++ b/components/roadmap/today-marker-toggle.tsx @@ -0,0 +1,25 @@ +import { Button, Skeleton, Text } from '@chakra-ui/react' +import React from 'react' + +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import { setShowTodayMarker, useShowTodayMarker } from '../../hooks/useShowTodayMarker' +import SvgEyeClosedIcon from '../icons/svgr/SvgEyeClosedIcon' +import SvgEyeOpenIcon from '../icons/svgr/SvgEyeOpenIcon' +import styles from './today-marker.module.css' + +export function TodayMarkerToggle () { + const showTodayMarker = useShowTodayMarker() + const globalLoadingState = useGlobalLoadingState() + + return ( + + + + ) +} diff --git a/components/roadmap-grid/today-marker.module.css b/components/roadmap/today-marker.module.css similarity index 100% rename from components/roadmap-grid/today-marker.module.css rename to components/roadmap/today-marker.module.css diff --git a/components/theme/constants.ts b/components/theme/constants.ts index 618c1510..f1285f9e 100644 --- a/components/theme/constants.ts +++ b/components/theme/constants.ts @@ -1,4 +1,4 @@ -export const SMALL_SCREEN_THRESHOLD = 720; // average mobile screen height in pixels. +export const SMALL_SCREEN_THRESHOLD = 720 // average mobile screen height in pixels. const lightMode = { header: { @@ -52,6 +52,8 @@ const darkMode = { } } +// TODO: Migrate to chakra theme defined in /pages/_app.tsx +// see https://github.com/pln-planning-tools/Starmap/issues/370 export default { light: lightMode, dark: darkMode diff --git a/e2e/breadcrumbs.spec.ts b/e2e/breadcrumbs.spec.ts index 0e0361d4..55704d79 100644 --- a/e2e/breadcrumbs.spec.ts +++ b/e2e/breadcrumbs.spec.ts @@ -57,7 +57,7 @@ test('clicking a milestone item should show more breadcrumbs', async ({ page, co const expectedCrumbs = convertCrumbDataArraysToCrumbDataString([currentUrlCrumbs]); const newUrl = new URL(`/roadmap/github.com/ipfs/roadmap/issues/98`, page.url()); newUrl.searchParams.set('crumbs', expectedCrumbs); - newUrl.hash = 'simple'; + newUrl.hash = 'view=simple'; await expect(page).toHaveURL(newUrl.href); await getSpinnerAndBreadcrumbPromise(0, 2); diff --git a/hooks/useAsync.ts b/hooks/useAsync.ts deleted file mode 100644 index d2bd9a42..00000000 --- a/hooks/useAsync.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; - -/** - * @see https://usehooks.com/useAsync/ - */ -export const useAsync = ( - asyncFunction: () => Promise, - immediate = true -) => { - const [status, setStatus] = useState< - "idle" | "pending" | "success" | "error" - >("idle"); - const [value, setValue] = useState(null); - const [error, setError] = useState(null); - // The execute function wraps asyncFunction and - // handles setting state for pending, value, and error. - // useCallback ensures the below useEffect is not called - // on every render, but only if asyncFunction changes. - const execute = useCallback(async () => { - setStatus("pending"); - setValue(null); - setError(null); - try { - const response = await asyncFunction(); - setValue(response); - setStatus("success"); - } catch(error: any) { - setError(error); - setStatus("error"); - } - }, [asyncFunction]); - // Call execute if we want to fire it right away. - // Otherwise execute can be called later, such as - // in an onClick handler. - useEffect(() => { - if (immediate) { - execute(); - } - }, [execute, immediate]); - return { execute, status, value, error }; -}; diff --git a/hooks/useGlobalLoadingState.ts b/hooks/useGlobalLoadingState.ts index 186eb901..8c3d060c 100644 --- a/hooks/useGlobalLoadingState.ts +++ b/hooks/useGlobalLoadingState.ts @@ -1,13 +1,13 @@ -import Router from 'next/router'; -import { hookstate, State, useHookstate } from '@hookstate/core'; +import { hookstate, State, useHookstate } from '@hookstate/core' +import Router from 'next/router' -const globalLoadingState = hookstate(false); +const globalLoadingState = hookstate(false) const wrapState = (s: State) => ({ get: () => s.value, toggle: () => s.set(p => !p), start: () => s.set(true), stop: () => s.set(false), - set: (v: boolean) => s.set(v), + set: (v: boolean) => s.set(v) }) // The following 2 functions can be exported now: @@ -15,11 +15,12 @@ const wrapState = (s: State) => ({ export const accessGlobalLoadingState = () => wrapState(globalLoadingState) export const useGlobalLoadingState = () => wrapState(useHookstate(globalLoadingState)) -Router.events.on("routeChangeError", (err, url) => { - console.error("Navigating to: " + "url: " + url, { cancelled: err.cancelled } ) -}); +Router.events.on('routeChangeError', (err, url) => { + if (!err.cancelled) { + console.error('Navigating to: ' + 'url: ' + url, { cancelled: err.cancelled }) + } +}) -Router.events.on('hashChangeStart', accessGlobalLoadingState().start); -Router.events.on('hashChangeComplete', accessGlobalLoadingState().stop); -Router.events.on('routeChangeStart', accessGlobalLoadingState().start); -Router.events.on('routeChangeComplete', accessGlobalLoadingState().stop); +Router.events.on('routeChangeStart', accessGlobalLoadingState().start) +Router.events.on('routeChangeComplete', accessGlobalLoadingState().stop) +Router.events.on('hashChangeComplete', accessGlobalLoadingState().stop) diff --git a/hooks/useGlobalTimeScale.ts b/hooks/useGlobalTimeScale.ts deleted file mode 100644 index c0d2f258..00000000 --- a/hooks/useGlobalTimeScale.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ScaleTime } from 'd3'; -import { useState } from 'react'; - -import useSharedHook from '../lib/client/createSharedHook'; - -const [useGlobalTimeScale, setGlobalTimeScale] = useSharedHook | null>(useState, null); - -export { useGlobalTimeScale, setGlobalTimeScale }; diff --git a/hooks/usePrevious.ts b/hooks/usePrevious.ts deleted file mode 100644 index f6ab842a..00000000 --- a/hooks/usePrevious.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect, useRef } from 'react'; - -// Hook -export function usePrevious(value: T): T { - // The ref object is a generic container whose current property is mutable ... - // ... and can hold any value, similar to an instance property on a class - const ref: any = useRef(); - // Store current value in ref - useEffect(() => { - ref.current = value; - }, [value]); // Only re-run if value changes - // Return previous value (happens before update in useEffect above) - return ref.current; -} diff --git a/hooks/useWeekTicks.ts b/hooks/useWeekTicks.ts deleted file mode 100644 index 00dd506f..00000000 --- a/hooks/useWeekTicks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useState } from 'react'; - -import useSharedHook from '../lib/client/createSharedHook'; - -const [useWeekTicks, setWeekTicks] = useSharedHook(useState, 4); - -export { useWeekTicks, setWeekTicks }; diff --git a/lib/addStarMapsErrorsToStarMapsErrorGroups.ts b/lib/addStarMapsErrorsToStarMapsErrorGroups.ts index 84f4ec5b..adcec254 100644 --- a/lib/addStarMapsErrorsToStarMapsErrorGroups.ts +++ b/lib/addStarMapsErrorsToStarMapsErrorGroups.ts @@ -1,7 +1,7 @@ -import { flattenStarMapsErrorGroups } from './flattenStarMapsErrorGroups'; -import { groupStarMapsErrors } from './groupStarMapsErrors'; -import { StarMapsError, StarMapsIssueErrorsGrouped } from './types'; +import { flattenStarMapsErrorGroups } from './flattenStarMapsErrorGroups' +import { groupStarMapsErrors } from './groupStarMapsErrors' +import { StarMapsError, StarMapsIssueErrorsGrouped } from './types' -export function addStarMapsErrorsToStarMapsErrorGroups(errors: StarMapsError[], errorGroups: StarMapsIssueErrorsGrouped[]) { +export function addStarMapsErrorsToStarMapsErrorGroups (errors: StarMapsError[], errorGroups: StarMapsIssueErrorsGrouped[]) { return groupStarMapsErrors([...errors, ...flattenStarMapsErrorGroups(errorGroups)]) } diff --git a/lib/backend/addToChildren.ts b/lib/backend/addToChildren.ts index 97e0ddd9..f98bf6bc 100644 --- a/lib/backend/addToChildren.ts +++ b/lib/backend/addToChildren.ts @@ -1,16 +1,15 @@ -import { getDescription, getDueDate } from '../parser'; -import { GithubIssueDataWithGroupAndChildren, IssueData } from '../types'; -import { ErrorManager } from './errorManager'; +import { getDescription, getDueDate } from '../parser' +import { GithubIssueDataWithGroupAndChildren, IssueData } from '../types' +import { ErrorManager } from './errorManager' -export function addToChildren( +export function addToChildren ( data: GithubIssueDataWithGroupAndChildren[], parent: IssueData | GithubIssueDataWithGroupAndChildren = {} as IssueData | GithubIssueDataWithGroupAndChildren, errorManager: ErrorManager ): IssueData[] { - if (Array.isArray(data)) { - const parentAsGhIssueData = parent as GithubIssueDataWithGroupAndChildren; - let parentDueDate = ''; + const parentAsGhIssueData = parent as GithubIssueDataWithGroupAndChildren + let parentDueDate = '' let parentDescription = '' if (parentAsGhIssueData.body_html != null && parentAsGhIssueData.html_url != null) { parentDueDate = getDueDate(parentAsGhIssueData, errorManager).eta @@ -26,7 +25,7 @@ export function addToChildren( completion_rate: 0, // calculated on the client-side once all issues are loaded due_date: parentDueDate, description: parentDescription - }; + } return data.map((item: GithubIssueDataWithGroupAndChildren): IssueData => ({ labels: item.labels ?? [], completion_rate: 0, // calculated on the client-side once all issues are loaded @@ -38,9 +37,9 @@ export function addToChildren( node_id: item.node_id, parent: parentParsed, children: addToChildren(item.children, item, errorManager), - description: item.description.length === 0 ? getDescription(item.body) : item.description, - })); + description: item.description.length === 0 ? getDescription(item.body) : item.description + })) } - return []; -}; + return [] +} diff --git a/lib/backend/convertParsedChildToGroupedIssueData.ts b/lib/backend/convertParsedChildToGroupedIssueData.ts index 04eaba5a..04a8ebd7 100644 --- a/lib/backend/convertParsedChildToGroupedIssueData.ts +++ b/lib/backend/convertParsedChildToGroupedIssueData.ts @@ -1,14 +1,14 @@ -import { paramsFromUrl } from '../paramsFromUrl'; -import { GithubIssueDataWithGroup, ParserGetChildrenResponse } from '../types'; -import { getIssue } from './issue'; +import { paramsFromUrl } from '../paramsFromUrl' +import { GithubIssueDataWithGroup, ParserGetChildrenResponse } from '../types' +import { getIssue } from './issue' -export async function convertParsedChildToGroupedIssueData(child: ParserGetChildrenResponse): Promise { - const urlParams = paramsFromUrl(child.html_url); - const issueData = await getIssue(urlParams); +export async function convertParsedChildToGroupedIssueData (child: ParserGetChildrenResponse): Promise { + const urlParams = paramsFromUrl(child.html_url) + const issueData = await getIssue(urlParams) return { ...issueData, labels: issueData?.labels ?? [], group: child.group - }; + } } diff --git a/lib/backend/errorManager.ts b/lib/backend/errorManager.ts index fab865eb..ffbb7eaa 100644 --- a/lib/backend/errorManager.ts +++ b/lib/backend/errorManager.ts @@ -1,16 +1,16 @@ +import { groupStarMapsErrors } from '../groupStarMapsErrors' import { GithubIssueData, StarMapsError -} from '../types'; -import { groupStarMapsErrors } from '../groupStarMapsErrors'; +} from '../types' export class ErrorManager { - errors: StarMapsError[]; - constructor() { - this.errors = []; + errors: StarMapsError[] + constructor () { + this.errors = [] } - addError({ + addError ({ issue, errorTitle, errorMessage, @@ -21,23 +21,23 @@ export class ErrorManager { errorMessage: string; userGuideSection: string; }): void { - const { html_url, title } = issue; + const { html_url, title } = issue this.errors.push({ issueUrl: html_url, issueTitle: title, userGuideUrl: `https://github.com/pln-planning-tools/Starmap/blob/main/User%20Guide.md${userGuideSection}`, title: errorTitle, message: errorMessage - }); + }) } - flushErrors() { - const errors = groupStarMapsErrors(this.errors); - this.clearErrors(); - return errors; + flushErrors () { + const errors = groupStarMapsErrors(this.errors) + this.clearErrors() + return errors } - clearErrors() { - this.errors = []; + clearErrors () { + this.errors = [] } } diff --git a/lib/backend/getGithubIssueDataWithGroupAndChildren.ts b/lib/backend/getGithubIssueDataWithGroupAndChildren.ts index b0a76788..ab89c810 100644 --- a/lib/backend/getGithubIssueDataWithGroupAndChildren.ts +++ b/lib/backend/getGithubIssueDataWithGroupAndChildren.ts @@ -1,13 +1,13 @@ -import { getChildren } from '../parser'; -import { GithubIssueDataWithGroup, GithubIssueDataWithGroupAndChildren, ParserGetChildrenResponse, PendingChildren } from '../types'; -import { ErrorManager } from './errorManager'; -import { resolveChildren } from './resolveChildren'; -import { resolveChildrenWithDepth } from './resolveChildrenWithDepth'; +import { getChildren } from '../parser' +import { GithubIssueDataWithGroup, GithubIssueDataWithGroupAndChildren, ParserGetChildrenResponse, PendingChildren } from '../types' +import { ErrorManager } from './errorManager' +import { resolveChildren } from './resolveChildren' +import { resolveChildrenWithDepth } from './resolveChildrenWithDepth' export async function getGithubIssueDataWithGroupAndChildren (issueData: GithubIssueDataWithGroup, errorManager: ErrorManager, usePendingChildren = false): Promise { - const childrenParsed: ParserGetChildrenResponse[] = getChildren(issueData); - let pendingChildren: PendingChildren[] | undefined = undefined; - let children: GithubIssueDataWithGroupAndChildren[] = []; + const childrenParsed: ParserGetChildrenResponse[] = getChildren(issueData) + let pendingChildren: PendingChildren[] | undefined + let children: GithubIssueDataWithGroupAndChildren[] = [] if (usePendingChildren) { pendingChildren = childrenParsed.map(({ html_url }) => ({ html_url, group: issueData.title, parentHtmlUrl: issueData.html_url })) @@ -19,8 +19,8 @@ export async function getGithubIssueDataWithGroupAndChildren (issueData: GithubI ...issueData, labels: issueData.labels ?? [], children, - pendingChildren, + pendingChildren } - return ghIssueDataWithGroupAndChildren; + return ghIssueDataWithGroupAndChildren } diff --git a/lib/backend/issue.ts b/lib/backend/issue.ts index db65d4ef..efa26f11 100644 --- a/lib/backend/issue.ts +++ b/lib/backend/issue.ts @@ -1,26 +1,26 @@ -import { IssueStates } from '../enums'; -import { getDescription } from '../parser'; -import { GithubIssueData } from '../types'; -import { getOctokit } from './octokit'; +import { IssueStates } from '../enums' +import { getDescription } from '../parser' +import { GithubIssueData } from '../types' +import { getOctokit } from './octokit' -const cache = new Map(); +const cache = new Map() export async function getIssue ({ owner, repo, issue_number }): Promise { - const cacheKey = `${owner}${repo}${issue_number}`; + const cacheKey = `${owner}${repo}${issue_number}` if (process.env.IS_LOCAL === 'true') { if (cache.has(cacheKey)) { - return cache.get(cacheKey) as GithubIssueData; + return cache.get(cacheKey) as GithubIssueData } } try { const { data } = await getOctokit().rest.issues.get({ mediaType: { - format: 'full', + format: 'full' }, owner, repo, - issue_number, - }); + issue_number + }) const description = getDescription(data.body ?? '') @@ -34,14 +34,14 @@ export async function getIssue ({ owner, repo, issue_number }): Promise (typeof label !== 'string' ? label.name : label)) as string[], description - }; + } if (process.env.IS_LOCAL === 'true') { cache.set(cacheKey, result) } - return result; + return result } catch (err) { - console.error('error:', err); - throw new Error(`Error getting issue: ${err}`); + console.error('error:', err) + throw new Error(`Error getting issue: ${err}`) } -}; +} diff --git a/lib/backend/octokit.ts b/lib/backend/octokit.ts index 207d5a5f..336f3f93 100644 --- a/lib/backend/octokit.ts +++ b/lib/backend/octokit.ts @@ -1,55 +1,53 @@ -import { retry } from '@octokit/plugin-retry'; -import { throttling } from '@octokit/plugin-throttling'; -import { Octokit } from '@octokit/rest'; -import { knuthShuffle } from 'knuth-shuffle'; +import { retry } from '@octokit/plugin-retry' +import { throttling } from '@octokit/plugin-throttling' +import { Octokit } from '@octokit/rest' +import { knuthShuffle } from 'knuth-shuffle' - -const authTokens = new Map(); +const authTokens = new Map() const potentialTokens = [ process.env.PLN_ADMIN_GITHUB_TOKEN, process.env.PLN_ADMIN_GITHUB_TOKEN2, - process.env.PLN_ADMIN_GITHUB_TOKEN3, -]; + process.env.PLN_ADMIN_GITHUB_TOKEN3 +] if (potentialTokens.filter((t: string | undefined) => t != null).length === 0) { console.error('You need to set `PLN_ADMIN_GITHUB_TOKEN`, `PLN_ADMIN_GITHUB_TOKEN1`, and/or `PLN_ADMIN_GITHUB_TOKEN3`') - throw new Error('PLN_ADMIN_GITHUB_TOKEN environmental variable not set. It is required.'); + throw new Error('PLN_ADMIN_GITHUB_TOKEN environmental variable not set. It is required.') } potentialTokens.forEach((token: string | undefined) => { if (token != null) { - authTokens.set(token, true); + authTokens.set(token, true) } }) -const maxRetryAfterMinutes = 1; -const RetryableOctokit = Octokit.plugin(retry, throttling); - +const maxRetryAfterMinutes = 1 +const RetryableOctokit = Octokit.plugin(retry, throttling) -function getValidAuthToken(): string { +function getValidAuthToken (): string { const validAuthTokens: string[] = [] // find all authTokens with value of true for (const [token, isValid] of authTokens.entries()) { if (isValid) { - validAuthTokens.push(token); + validAuthTokens.push(token) } } - return knuthShuffle(validAuthTokens)[0]; + return knuthShuffle(validAuthTokens)[0] } -function getOctokit() { - const currentAuthToken = getValidAuthToken(); +function getOctokit () { + const currentAuthToken = getValidAuthToken() const getRateLimitHandler = (msg) => (retryAfter, options, octokit, retryCount) => { - octokit.log.warn(`${msg} ${options.method} ${options.url}`); + octokit.log.warn(`${msg} ${options.method} ${options.url}`) if (retryAfter / 60 > maxRetryAfterMinutes) { - authTokens.set(currentAuthToken, false); - throw new Error(`RetryAfter is over ${maxRetryAfterMinutes} minutes. Aborting.`); + authTokens.set(currentAuthToken, false) + throw new Error(`RetryAfter is over ${maxRetryAfterMinutes} minutes. Aborting.`) } if (retryCount < 2) { // retry 2 times - octokit.log.warn(`Retrying after ${retryAfter} seconds!`); - return true; + octokit.log.warn(`Retrying after ${retryAfter} seconds!`) + return true } else { throw new Error('Request quota exceeded after two retries.') } @@ -58,9 +56,9 @@ function getOctokit() { auth: currentAuthToken, throttle: { onRateLimit: getRateLimitHandler('Request quota exhausted for request'), - onSecondaryRateLimit: getRateLimitHandler('SecondaryRateLimit detected for request'), - }, - }); + onSecondaryRateLimit: getRateLimitHandler('SecondaryRateLimit detected for request') + } + }) } -export { getOctokit }; +export { getOctokit } diff --git a/lib/backend/resolveChildren.ts b/lib/backend/resolveChildren.ts index 48f13de3..73da4b51 100644 --- a/lib/backend/resolveChildren.ts +++ b/lib/backend/resolveChildren.ts @@ -1,31 +1,31 @@ -import { GithubIssueDataWithGroup, ParserGetChildrenResponse } from '../types'; -import { convertParsedChildToGroupedIssueData } from './convertParsedChildToGroupedIssueData'; -import { ErrorManager } from './errorManager'; +import { GithubIssueDataWithGroup, ParserGetChildrenResponse } from '../types' +import { convertParsedChildToGroupedIssueData } from './convertParsedChildToGroupedIssueData' +import { ErrorManager } from './errorManager' export async function resolveChildren (children: ParserGetChildrenResponse[], errorManager: ErrorManager): Promise { if (!Array.isArray(children)) { - throw new Error('Children is not an array. Is this a root issue?'); + throw new Error('Children is not an array. Is this a root issue?') } try { return await Promise.all(children.map( async (child: ParserGetChildrenResponse): Promise => { try { - return await convertParsedChildToGroupedIssueData(child); + return await convertParsedChildToGroupedIssueData(child) } catch (err) { errorManager.addError({ issue: { html_url: child.html_url, - title: child.html_url, + title: child.html_url }, errorTitle: 'Error parsing issue', errorMessage: (err as Error).message, userGuideSection: '#children' }) - throw err; + throw err } } - )); + )) } catch (reason) { - throw new Error(`Error resolving children: ${reason}`); + throw new Error(`Error resolving children: ${reason}`) } -}; +} diff --git a/lib/backend/resolveChildrenWithDepth.ts b/lib/backend/resolveChildrenWithDepth.ts index e2c67b52..74ec2840 100644 --- a/lib/backend/resolveChildrenWithDepth.ts +++ b/lib/backend/resolveChildrenWithDepth.ts @@ -1,14 +1,14 @@ -import { GithubIssueDataWithGroupAndChildren, ParserGetChildrenResponse } from '../types'; -import { ErrorManager } from './errorManager'; -import { getGithubIssueDataWithGroupAndChildren } from './getGithubIssueDataWithGroupAndChildren'; -import { resolveChildren } from './resolveChildren'; +import { GithubIssueDataWithGroupAndChildren, ParserGetChildrenResponse } from '../types' +import { ErrorManager } from './errorManager' +import { getGithubIssueDataWithGroupAndChildren } from './getGithubIssueDataWithGroupAndChildren' +import { resolveChildren } from './resolveChildren' -export async function resolveChildrenWithDepth(children: ParserGetChildrenResponse[], errorManager: ErrorManager): Promise { +export async function resolveChildrenWithDepth (children: ParserGetChildrenResponse[], errorManager: ErrorManager): Promise { try { - const issues = await resolveChildren(children, errorManager); - return await Promise.all(issues.map((issue) => getGithubIssueDataWithGroupAndChildren(issue, errorManager, true))); + const issues = await resolveChildren(children, errorManager) + return await Promise.all(issues.map((issue) => getGithubIssueDataWithGroupAndChildren(issue, errorManager, true))) } catch (err) { - console.error('error:', err); - return []; + console.error('error:', err) + return [] } -}; +} diff --git a/lib/backend/saveIssueDataToFile.ts b/lib/backend/saveIssueDataToFile.ts index 5b7aed93..62ddfa59 100644 --- a/lib/backend/saveIssueDataToFile.ts +++ b/lib/backend/saveIssueDataToFile.ts @@ -1,48 +1,48 @@ -import { writeFile, readFile } from 'fs/promises'; -import { join } from 'path'; -import { IssueData } from '../types'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { paramsFromUrl } from '../paramsFromUrl'; +import { writeFile, readFile } from 'fs/promises' +import path, { join } from 'path' +import { fileURLToPath } from 'url' -const __filename = fileURLToPath(import.meta.url); +import { paramsFromUrl } from '../paramsFromUrl' +import { IssueData } from '../types' -const __dirname = path.dirname(__filename); -const pathToSaveFiles = join(__dirname, '..', '..', 'cache'); +const __filename = fileURLToPath(import.meta.url) - /** +const __dirname = path.dirname(__filename) +const pathToSaveFiles = join(__dirname, '..', '..', 'cache') + +/** * Save the finalIssueData to a file with the owner, repo, and issue number as name */ -export function saveIssueDataToFile(finalIssueData: IssueData) { +export function saveIssueDataToFile (finalIssueData: IssueData) { if (process.env.IS_LOCAL !== 'true' || process.env.ENABLE_ISSUEDATA_SAVING !== 'true') { - return; + return } /** * Get the owner, repo, and issue_number from the issue's html_url using paramsFromUrl */ - const { owner, repo, issue_number } = paramsFromUrl(finalIssueData.html_url); + const { owner, repo, issue_number } = paramsFromUrl(finalIssueData.html_url) - const fileName = `${owner}-${repo}-${issue_number}.json`; - const filePath = join(pathToSaveFiles, fileName); - console.log(`Saving ${owner}/${repo}#${issue_number} issueData to ${filePath}`); + const fileName = `${owner}-${repo}-${issue_number}.json` + const filePath = join(pathToSaveFiles, fileName) + console.log(`Saving ${owner}/${repo}#${issue_number} issueData to ${filePath}`) writeFile(filePath, JSON.stringify(finalIssueData, null, 2)).catch((err) => { - console.error(`Error writing file ${filePath}`, err); - }); + console.error(`Error writing file ${filePath}`, err) + }) // throw new Error('Function not implemented.'); } -export async function checkForSavedIssueData({ owner, repo, issue_number }): Promise { +export async function checkForSavedIssueData ({ owner, repo, issue_number }): Promise { if (process.env.IS_LOCAL !== 'true' || process.env.ENABLE_ISSUEDATA_READING !== 'true') { - throw new Error('checkForSavedIssueData not enabled. Check that IS_LOCAL and ENABLE_ISSUEDATA_READING are set to true'); + throw new Error('checkForSavedIssueData not enabled. Check that IS_LOCAL and ENABLE_ISSUEDATA_READING are set to true') } - const fileName = `${owner}-${repo}-${issue_number}.json`; - const filePath = join(pathToSaveFiles, fileName); + const fileName = `${owner}-${repo}-${issue_number}.json` + const filePath = join(pathToSaveFiles, fileName) try { - const issueData = await readFile(filePath, 'utf8'); - return JSON.parse(issueData); + const issueData = await readFile(filePath, 'utf8') + return JSON.parse(issueData) } catch (err) { - throw new Error(`Error reading file ${filePath}: ${(err as Error).toString()}`); + throw new Error(`Error reading file ${filePath}: ${(err as Error).toString()}`) } // throw new Error('Function not implemented.'); diff --git a/lib/breadcrumbs.ts b/lib/breadcrumbs.ts index eb0000d7..58dc3df5 100644 --- a/lib/breadcrumbs.ts +++ b/lib/breadcrumbs.ts @@ -1,8 +1,8 @@ -import { convertGithubUrlToShorthand } from './convertGithubUrlToShorthand'; -import { ViewMode } from './enums'; -import { getValidUrlFromInput } from './getValidUrlFromInput'; -import { paramsFromUrl } from './paramsFromUrl'; -import { IssueData, QueryParameters } from './types'; +import { convertGithubUrlToShorthand } from './convertGithubUrlToShorthand' +import { ViewMode } from './enums' +import { getValidUrlFromInput } from './getValidUrlFromInput' +import { paramsFromUrl } from './paramsFromUrl' +import { IssueData, QueryParameters } from './types' interface CrumbData { url: string; @@ -10,74 +10,74 @@ interface CrumbData { } type CrumbArrayData = [string, string] -export function getCrumbStringFromIssueData({ html_url, title }: Pick): string { - return JSON.stringify(getCrumbDataArrayFromIssueData({ html_url, title })); +export function getCrumbStringFromIssueData ({ html_url, title }: Pick): string { + return JSON.stringify(getCrumbDataArrayFromIssueData({ html_url, title })) } -export function getCrumbDataArrayFromIssueData({ html_url, title }: Pick): CrumbArrayData { - return [convertGithubUrlToShorthand(new URL(html_url)), title]; +export function getCrumbDataArrayFromIssueData ({ html_url, title }: Pick): CrumbArrayData { + return [convertGithubUrlToShorthand(new URL(html_url)), title] } -export function convertCrumbDataArraysToCrumbDataString(crumbDataArrays: CrumbArrayData[]): string { - return JSON.stringify([...crumbDataArrays]); +export function convertCrumbDataArraysToCrumbDataString (crumbDataArrays: CrumbArrayData[]): string { + return JSON.stringify([...crumbDataArrays]) } -function mapCrumbArrayToCrumbData(crumbItem, index, crumbArray) { - const [shortGithubId, title] = crumbItem; - const { owner, repo, issue_number } = paramsFromUrl(getValidUrlFromInput(shortGithubId).toString()); - const url = new URL(`/roadmap/github.com/${owner}/${repo}/issues/${issue_number}`, window.location.origin); - const crumbParents = crumbArray.slice(0, index); +function mapCrumbArrayToCrumbData (crumbItem, index, crumbArray) { + const [shortGithubId, title] = crumbItem + const { owner, repo, issue_number } = paramsFromUrl(getValidUrlFromInput(shortGithubId).toString()) + const url = new URL(`/roadmap/github.com/${owner}/${repo}/issues/${issue_number}`, window.location.origin) + const crumbParents = crumbArray.slice(0, index) if (crumbParents.length > 0) { - const crumbsForUrl = convertCrumbDataArraysToCrumbDataString([ ...crumbParents]); + const crumbsForUrl = convertCrumbDataArraysToCrumbDataString([...crumbParents]) url.searchParams.append('crumbs', crumbsForUrl) } return { url, - title, - }; + title + } } -export function getCrumbDataArrayFromCrumbString(crumbString: string): CrumbArrayData[] { +export function getCrumbDataArrayFromCrumbString (crumbString: string): CrumbArrayData[] { try { - return JSON.parse(crumbString); + return JSON.parse(crumbString) } catch (err) { - console.error(`Error parsing crumb string "${crumbString}"`, err); - return []; + console.error(`Error parsing crumb string "${crumbString}"`, err) + return [] } } -export function routerQueryToCrumbArrayData({ crumbs }: QueryParameters): CrumbArrayData[] { +export function routerQueryToCrumbArrayData ({ crumbs }: QueryParameters): CrumbArrayData[] { if (crumbs == null) { - return []; + return [] } - return getCrumbDataArrayFromCrumbString(decodeURIComponent(crumbs));//.map(mapCrumbArrayToCrumbData); + return getCrumbDataArrayFromCrumbString(decodeURIComponent(crumbs))// .map(mapCrumbArrayToCrumbData); } -export function appendCrumbArrayData(urlCrumbDataArray: CrumbArrayData[], parentCrumbDataArray: CrumbArrayData): CrumbArrayData[] { - const crumbShortIdSet = new Set(); - urlCrumbDataArray.forEach((crumbDataArray) => crumbShortIdSet.add(crumbDataArray[0])); - let crumbDataArrays: CrumbArrayData[] = urlCrumbDataArray; +export function appendCrumbArrayData (urlCrumbDataArray: CrumbArrayData[], parentCrumbDataArray: CrumbArrayData): CrumbArrayData[] { + const crumbShortIdSet = new Set() + urlCrumbDataArray.forEach((crumbDataArray) => crumbShortIdSet.add(crumbDataArray[0])) + let crumbDataArrays: CrumbArrayData[] = urlCrumbDataArray // prevent duplicates if (crumbShortIdSet.has(parentCrumbDataArray[0])) { - crumbDataArrays = urlCrumbDataArray; + crumbDataArrays = urlCrumbDataArray } else { // parent hasn't been added, so we're going to add it to the existing array. - crumbDataArrays = urlCrumbDataArray.concat([parentCrumbDataArray]); + crumbDataArrays = urlCrumbDataArray.concat([parentCrumbDataArray]) } - return crumbDataArrays; + return crumbDataArrays } -export function getCrumbDataFromCrumbDataArray(crumbItems: CrumbArrayData[], viewMode: ViewMode): CrumbData[] { +export function getCrumbDataFromCrumbDataArray (crumbItems: CrumbArrayData[], viewMode: ViewMode): CrumbData[] { return crumbItems.map(mapCrumbArrayToCrumbData).map((crumb) => { - crumb.url.hash = viewMode; + crumb.url.hash = `view=${viewMode}` return { ...crumb, url: crumb.url.toString() - }; - }); + } + }) } /** @@ -86,8 +86,8 @@ export function getCrumbDataFromCrumbDataArray(crumbItems: CrumbArrayData[], vie * @param viewMode the current view mode, to ensure links stay in the current view * @returns */ -export function getCrumbDataFromCrumbString(crumbs: string, viewMode: ViewMode): CrumbData[] { - const crumbItems = getCrumbDataArrayFromCrumbString(crumbs); +export function getCrumbDataFromCrumbString (crumbs: string, viewMode: ViewMode): CrumbData[] { + const crumbItems = getCrumbDataArrayFromCrumbString(crumbs) - return getCrumbDataFromCrumbDataArray(crumbItems, viewMode); + return getCrumbDataFromCrumbDataArray(crumbItems, viewMode) } diff --git a/lib/calculateCompletionRate.ts b/lib/calculateCompletionRate.ts index 326734b3..12c18cd4 100644 --- a/lib/calculateCompletionRate.ts +++ b/lib/calculateCompletionRate.ts @@ -1,6 +1,7 @@ -import { ImmutableArray, State } from '@hookstate/core'; -import { IssueStates } from './enums'; -import { IssueData } from './types'; +import { ImmutableArray, State } from '@hookstate/core' + +import { IssueStates } from './enums' +import { IssueData } from './types' export type CalculateCompletionRateOptions = Pick & { children: ImmutableArray; @@ -13,44 +14,44 @@ interface GetIssueCountsResponse { percentClosed: number; } -const issueKey = ({ html_url }: Pick) => html_url; +const issueKey = ({ html_url }: Pick) => html_url -function getIssueStatesMap(issue: CalculateCompletionRateOptions, issueStatesMap = new Map()): Map { - const key = issueKey(issue); +function getIssueStatesMap (issue: CalculateCompletionRateOptions, issueStatesMap = new Map()): Map { + const key = issueKey(issue) if (issueStatesMap.has(key)) { - return issueStatesMap; + return issueStatesMap } - issueStatesMap.set(key, issue.state); - issue.children?.map((child) => getIssueStatesMap(child, issueStatesMap)); + issueStatesMap.set(key, issue.state) + issue.children?.map((child) => getIssueStatesMap(child, issueStatesMap)) - return issueStatesMap; + return issueStatesMap } -function getIssueCounts(issueStatesMap: Map): GetIssueCountsResponse { - const total = issueStatesMap.size; - let open = 0; - let closed = 0; +function getIssueCounts (issueStatesMap: Map): GetIssueCountsResponse { + const total = issueStatesMap.size + let open = 0 + let closed = 0 issueStatesMap.forEach((value) => { if (value === IssueStates.OPEN) { - open++; + open++ } else { - closed++; + closed++ } - }); + }) return { total, open, closed, - percentClosed: Number(Number((closed / total) * 100 || 0).toFixed(2)), - }; + percentClosed: Number(Number((closed / total) * 100 || 0).toFixed(2)) + } } export function calculateCompletionRate (issue: CalculateCompletionRateOptions): number { - const issueStatesMap = getIssueStatesMap(issue); - const { percentClosed } = getIssueCounts(issueStatesMap); - return percentClosed; -}; + const issueStatesMap = getIssueStatesMap(issue) + const { percentClosed } = getIssueCounts(issueStatesMap) + return percentClosed +} export function assignCompletionRateToIssues (issue: State | State): void { if (issue.ornull === null) { @@ -60,7 +61,7 @@ export function assignCompletionRateToIssues (issue: State | State ({ ...issue, completion_rate })) // = completionRate issue.ornull.children.forEach(assignCompletionRateToIssues) } diff --git a/lib/client/TimeScaler.ts b/lib/client/TimeScaler.ts deleted file mode 100644 index 74412a96..00000000 --- a/lib/client/TimeScaler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ScaleTime, scaleTime } from 'd3'; - -import { dayjs } from './dayjs'; - -/** - * TODO: Implement as global hookstate - */ -class TimeScaler { - gridColScale: ScaleTime; - percentageScale: ScaleTime; - constructor() { - this.percentageScale = scaleTime(); - this.gridColScale = scaleTime(); - } - - setScale(dates: Date[], numCols: number) { - const validDates = dates.map(dayjs).filter((d) => d.isValid()) - const minDate = dayjs.min(validDates); - const maxDate = dayjs.max(validDates); - const domain = [minDate.toDate(), maxDate.toDate()]; - this.percentageScale = scaleTime().domain(domain).range([0, 1]); - this.gridColScale = scaleTime().domain(domain).range([0, numCols]); - } - - getDomain() { - return this.percentageScale.domain(); - } - - getPercentileInverse(num: number) { - return this.percentageScale.invert(num) - } - getPercentile(date: Date) { - return this.percentageScale(date) - } - - getColumn(date: Date) { - return this.gridColScale(date) - } -} - - -export const globalTimeScaler = new TimeScaler(); diff --git a/lib/client/convertIssueDataToDetailedViewGroup.ts b/lib/client/convertIssueDataToDetailedViewGroup.ts index 41c37e93..f966ec08 100644 --- a/lib/client/convertIssueDataToDetailedViewGroup.ts +++ b/lib/client/convertIssueDataToDetailedViewGroup.ts @@ -1,13 +1,14 @@ -import { State, ImmutableArray } from '@hookstate/core'; -import { group } from 'd3'; -import { reverse, sortBy, uniqBy } from 'lodash'; +import { ParsedUrlQuery } from 'querystring' -import { ViewMode } from '../enums'; -import { getLinkForRoadmapChild } from './getLinkForRoadmapChild'; -import { DetailedViewGroup, IssueData } from '../types'; -import { ParsedUrlQuery } from 'querystring'; +import { State, ImmutableArray } from '@hookstate/core' +import { group } from 'd3' +import { reverse, sortBy, uniqBy } from 'lodash' -function flattenIssueData(issueData: IssueData, isChildIssue = false): IssueData[] { +import { ViewMode } from '../enums' +import { DetailedViewGroup, IssueData } from '../types' +import { getLinkForRoadmapChild } from './getLinkForRoadmapChild' + +function flattenIssueData (issueData: IssueData, isChildIssue = false): IssueData[] { const parentArray: IssueData[] = [] if (isChildIssue) { parentArray.push(issueData) @@ -16,29 +17,29 @@ function flattenIssueData(issueData: IssueData, isChildIssue = false): IssueData return uniqBy(parentArray.concat(childrenArray), 'html_url') } -export function convertIssueDataStateToDetailedViewGroupOld(issueDataState: State, viewMode: ViewMode, parsedQuery: ParsedUrlQuery): DetailedViewGroup[] { +export function convertIssueDataStateToDetailedViewGroupOld (issueDataState: State, viewMode: ViewMode, parsedQuery: ParsedUrlQuery): DetailedViewGroup[] { const newIssueData = issueDataState.children.value.map((v) => ({ ...v, group: v.parent?.title ?? '', - children: v.children.map((x) => ({ ...x, group: x.parent?.title ?? '' })), - })); + children: v.children.map((x) => ({ ...x, group: x.parent?.title ?? '' })) + })) const getGroupedIssues = (issueData: ImmutableArray): DetailedViewGroup[] => Array.from( group(issueData, (d) => d.group), ([key, value]) => ({ groupName: key, items: value, - url: getLinkForRoadmapChild({ issueData: newIssueData.find((i) => i.title === key), query: parsedQuery }), - }), - ); + url: getLinkForRoadmapChild({ issueData: newIssueData.find((i) => i.title === key), query: parsedQuery }) + }) + ) - const issueDataLevelOneIfNoChildren: ImmutableArray = newIssueData.map((v) => ({ ...v, children: [v], group: v.title })); + const issueDataLevelOneIfNoChildren: ImmutableArray = newIssueData.map((v) => ({ ...v, children: [v], group: v.title })) - const issueDataLevelOne: ImmutableArray = newIssueData.map((v) => v.children.flat()).flat(); - const issueDataLevelOneGrouped: DetailedViewGroup[] = getGroupedIssues(issueDataLevelOne); - const issueDataLevelOneIfNoChildrenGrouped: DetailedViewGroup[] = getGroupedIssues(issueDataLevelOneIfNoChildren); + const issueDataLevelOne: ImmutableArray = newIssueData.map((v) => v.children.flat()).flat() + const issueDataLevelOneGrouped: DetailedViewGroup[] = getGroupedIssues(issueDataLevelOne) + const issueDataLevelOneIfNoChildrenGrouped: DetailedViewGroup[] = getGroupedIssues(issueDataLevelOneIfNoChildren) - let issuesGrouped: DetailedViewGroup[]; + let issuesGrouped: DetailedViewGroup[] if (viewMode === ViewMode.Detail) { if (issueDataLevelOneGrouped.length > 0) { issuesGrouped = issueDataLevelOneGrouped @@ -51,31 +52,32 @@ export function convertIssueDataStateToDetailedViewGroupOld(issueDataState: Stat ([key, value]) => ({ groupName: key, items: value, - url: getLinkForRoadmapChild({ issueData: newIssueData.find((i) => i.title === key), query: parsedQuery }), - }), - ); + url: getLinkForRoadmapChild({ issueData: newIssueData.find((i) => i.title === key), query: parsedQuery }) + }) + ) } - return reverse(Array.from(sortBy(issuesGrouped, ['groupName']))); + return reverse(Array.from(sortBy(issuesGrouped, ['groupName']))) } -export function convertIssueDataToDetailedViewGroup(issueData: IssueData): DetailedViewGroup[] { - const allIssues = flattenIssueData(issueData); +export function convertIssueDataToDetailedViewGroup (issueData: IssueData): DetailedViewGroup[] { + const allIssues = flattenIssueData(issueData) interface MutableDetailedViewGroup extends DetailedViewGroup { items: IssueData[] } const group = allIssues.reduce((viewGroup: MutableDetailedViewGroup[], issueItem: IssueData) => { const currentItemsGroupIndex = viewGroup.findIndex((item) => item.groupName === issueItem.parent?.title) if (viewGroup[currentItemsGroupIndex] != null) { - viewGroup[currentItemsGroupIndex].items.push(issueItem); + viewGroup[currentItemsGroupIndex].items.push(issueItem) } else { if (issueItem.parent != null && issueItem.parent.title !== issueData.title) { if (issueItem.parent.title == null) { + console.error('issueItem.parent.title is null') } else { viewGroup.push({ groupName: issueItem.parent.title ?? 'no title for issue with parent', items: [issueItem], - url: getLinkForRoadmapChild({ issueData: issueItem, currentRoadmapRoot: issueData }), + url: getLinkForRoadmapChild({ issueData: issueItem, currentRoadmapRoot: issueData }) }) } } else if (issueItem.children?.length > 0) { @@ -83,12 +85,12 @@ export function convertIssueDataToDetailedViewGroup(issueData: IssueData): Detai viewGroup.push({ groupName: issueItem.title ?? 'no title for issue with no parent', items: [], - url: getLinkForRoadmapChild({ issueData: issueItem, currentRoadmapRoot: issueData }), + url: getLinkForRoadmapChild({ issueData: issueItem, currentRoadmapRoot: issueData }) }) } } - return viewGroup; + return viewGroup }, [] as MutableDetailedViewGroup[]) - return reverse(Array.from(sortBy(group, ['groupName']))); + return reverse(Array.from(sortBy(group, ['groupName']))) } diff --git a/lib/client/createSharedHook.ts b/lib/client/createSharedHook.ts index ad58d58b..efbaef79 100644 --- a/lib/client/createSharedHook.ts +++ b/lib/client/createSharedHook.ts @@ -2,36 +2,36 @@ * Typescript version of https://github.com/bebbi/react-shared-hook/blob/d6cabcb49a7978267c36057ba7efd40ca56aec03/src/index.js * @fileoverview */ -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react' type UseSharedHookReturnType = [() => T, (newValue: T) => void]; const useSharedHook = (hook: typeof useState, state: T): UseSharedHookReturnType => { - const listeners: Set>> = new Set(); + const listeners: Set>> = new Set() const client = () => { - const [clientState, setter] = hook(state); + const [clientState, setter] = hook(state) // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - listeners.add(setter); + listeners.add(setter) return () => { - listeners.delete(setter); - }; - }, [setter]); + listeners.delete(setter) + } + }, [setter]) - return clientState; - }; + return clientState + } const setter = (newValue: T) => { if (typeof newValue === 'function') { - newValue = newValue(state); + newValue = newValue(state) } - state = newValue; - listeners.forEach((listener) => listener(newValue)); - }; + state = newValue + listeners.forEach((listener) => listener(newValue)) + } - return [client, setter]; -}; + return [client, setter] +} -export default useSharedHook; +export default useSharedHook diff --git a/lib/client/dayjs.ts b/lib/client/dayjs.ts index ba0f02c9..5d760d4f 100644 --- a/lib/client/dayjs.ts +++ b/lib/client/dayjs.ts @@ -1,16 +1,18 @@ -import dayjs from 'dayjs'; -import advancedFormat from 'dayjs/plugin/advancedFormat'; -import customParseFormat from 'dayjs/plugin/customParseFormat'; -import localizedFormat from 'dayjs/plugin/localizedFormat'; -import minMax from 'dayjs/plugin/minMax'; -import utc from 'dayjs/plugin/utc'; +import dayjs from 'dayjs' +import advancedFormat from 'dayjs/plugin/advancedFormat' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import duration from 'dayjs/plugin/duration' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import minMax from 'dayjs/plugin/minMax' import quarterOfYear from 'dayjs/plugin/quarterOfYear' +import utc from 'dayjs/plugin/utc' -dayjs.extend(customParseFormat); -dayjs.extend(utc); -dayjs.extend(minMax); -dayjs.extend(localizedFormat); -dayjs.extend(advancedFormat); -dayjs.extend(quarterOfYear); +dayjs.extend(customParseFormat) +dayjs.extend(utc) +dayjs.extend(minMax) +dayjs.extend(localizedFormat) +dayjs.extend(advancedFormat) +dayjs.extend(quarterOfYear) +dayjs.extend(duration) -export { dayjs }; +export { dayjs } diff --git a/lib/client/errorFilters.ts b/lib/client/errorFilters.ts index 09278495..9585ce5f 100644 --- a/lib/client/errorFilters.ts +++ b/lib/client/errorFilters.ts @@ -1,7 +1,8 @@ -import { ImmutableArray, ImmutableObject } from '@hookstate/core'; -import { ViewMode } from '../enums'; -import { findIssueDepthByUrl } from '../findIssueDepthByUrl'; -import { StarMapsIssueErrorsGrouped, IssueData } from '../types'; +import { ImmutableArray, ImmutableObject } from '@hookstate/core' + +import { ViewMode } from '../enums' +import { findIssueDepthByUrl } from '../findIssueDepthByUrl' +import { StarMapsIssueErrorsGrouped, IssueData } from '../types' /** * Returns an issueError filter that will filter out any errors that are not maxDepth or shallower. @@ -11,21 +12,19 @@ import { StarMapsIssueErrorsGrouped, IssueData } from '../types'; */ export const getIssueErrorFilter = (maxDepth: number) => (errors: ImmutableArray, issueDataState: ImmutableObject) => errors.filter(({ issueUrl: errorIssueUrl }) => { - if (issueDataState != null) { - const foundIssueDepth = findIssueDepthByUrl(issueDataState, errorIssueUrl); + if (issueDataState != null) { + const foundIssueDepth = findIssueDepthByUrl(issueDataState, errorIssueUrl) - if (foundIssueDepth <= maxDepth && foundIssueDepth > -1) { - return true; - } + if (foundIssueDepth <= maxDepth && foundIssueDepth > -1) { + return true } + } - return false; - }) - - + return false + }) export const errorFilters = { [ViewMode.List]: getIssueErrorFilter(1), [ViewMode.Simple]: getIssueErrorFilter(1), - [ViewMode.Detail]: getIssueErrorFilter(3), + [ViewMode.Detail]: getIssueErrorFilter(3) } diff --git a/lib/client/getClosest.ts b/lib/client/getClosest.ts deleted file mode 100644 index 40491b25..00000000 --- a/lib/client/getClosest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { scaleTime } from 'd3'; -import dayjs from 'dayjs'; - -export function getClosest({ - currentDate, - dates, - totalTimelineTicks, -}: { - currentDate: Date; - dates: Date[]; - totalTimelineTicks: number; -}) { - const min = dayjs.min(dates.map((v) => dayjs.utc(v))).toDate(); - const max = dayjs.max(dates.map((v) => dayjs.utc(v))).toDate(); - const closest = scaleTime().domain([min, max]).range([0, totalTimelineTicks]); - - return Math.round(closest(currentDate)); -} diff --git a/lib/client/getDateAsQuarter.ts b/lib/client/getDateAsQuarter.ts index 0515931a..5fbaff13 100644 --- a/lib/client/getDateAsQuarter.ts +++ b/lib/client/getDateAsQuarter.ts @@ -1,10 +1,11 @@ import type { Dayjs } from 'dayjs' -import { dayjs } from './dayjs'; -function getDateAsQuarter(inputDate: Dayjs | Date | string) { +import { dayjs } from './dayjs' + +function getDateAsQuarter (inputDate: Dayjs | Date | string) { const date = dayjs(inputDate) - const quarterNum = date.quarter(); - const year = date.format('YY'); + const quarterNum = date.quarter() + const year = date.format('YY') return `Q${quarterNum}'${year}` } diff --git a/lib/client/getDates.ts b/lib/client/getDates.ts new file mode 100644 index 00000000..04c31ab5 --- /dev/null +++ b/lib/client/getDates.ts @@ -0,0 +1,25 @@ +import type { Dayjs } from 'dayjs' + +import { DetailedViewGroup } from '../types' +import { dayjs } from './dayjs' + +interface GetDatesOptions { + issuesGroupedState: DetailedViewGroup[]; +} +/** + * Returns the dates (as dayjs dates) for all issues in all groups of an + * issuesGroupedState object + * + * @returns + */ +export function getDates ({ issuesGroupedState }: GetDatesOptions): Dayjs[] { + let innerDayjsDates: Dayjs[] = [] + try { + innerDayjsDates = issuesGroupedState + .flatMap((group) => group.items.map((item) => dayjs(item.due_date).utc())) + .filter((d) => d.isValid()) + } catch { + innerDayjsDates = [] + } + return innerDayjsDates +} diff --git a/lib/client/getLinkForRoadmapChild.ts b/lib/client/getLinkForRoadmapChild.ts index 5330dab5..724ded86 100644 --- a/lib/client/getLinkForRoadmapChild.ts +++ b/lib/client/getLinkForRoadmapChild.ts @@ -1,8 +1,9 @@ -import { ImmutableObject } from '@hookstate/core'; -import { paramsFromUrl } from '../paramsFromUrl'; -import { IssueData, QueryParameters } from '../types'; -import { appendCrumbArrayData, convertCrumbDataArraysToCrumbDataString, getCrumbDataArrayFromIssueData, routerQueryToCrumbArrayData } from '../breadcrumbs'; -import { ViewMode } from '../enums'; +import { ImmutableObject } from '@hookstate/core' + +import { appendCrumbArrayData, convertCrumbDataArraysToCrumbDataString, getCrumbDataArrayFromIssueData, routerQueryToCrumbArrayData } from '../breadcrumbs' +import { ViewMode } from '../enums' +import { paramsFromUrl } from '../paramsFromUrl' +import { IssueData, QueryParameters } from '../types' type childrenAndUrl = Pick; type parentAndChildren = Pick; @@ -15,34 +16,33 @@ interface GetLinkForRoadmapChildOptions { replaceOrigin?: boolean; } -function addCrumbsParamToUrl({ url, currentRoadmapRoot, query }: Pick & {url: URL}) { +function addCrumbsParamToUrl ({ url, currentRoadmapRoot, query }: Pick & {url: URL}) { if (currentRoadmapRoot != null) { - const parentCrumbDataArray = getCrumbDataArrayFromIssueData(currentRoadmapRoot); - let crumbDataArrays: [string, string][] = [parentCrumbDataArray]; + const parentCrumbDataArray = getCrumbDataArrayFromIssueData(currentRoadmapRoot) + let crumbDataArrays: [string, string][] = [parentCrumbDataArray] if (query != null) { - const urlCrumbDataArray = routerQueryToCrumbArrayData(query); - crumbDataArrays = appendCrumbArrayData(urlCrumbDataArray, parentCrumbDataArray); + const urlCrumbDataArray = routerQueryToCrumbArrayData(query) + crumbDataArrays = appendCrumbArrayData(urlCrumbDataArray, parentCrumbDataArray) } - url.searchParams.set('crumbs', convertCrumbDataArraysToCrumbDataString(crumbDataArrays)); + url.searchParams.set('crumbs', convertCrumbDataArraysToCrumbDataString(crumbDataArrays)) } } -export function getLinkForRoadmapChild({ issueData, currentRoadmapRoot, query, viewMode, replaceOrigin = true }: GetLinkForRoadmapChildOptions): string { +export function getLinkForRoadmapChild ({ issueData, currentRoadmapRoot, query, viewMode, replaceOrigin = true }: GetLinkForRoadmapChildOptions): string { if (issueData == null || issueData?.children?.length === 0 || issueData.html_url === '#') { - return '#'; + return '#' } - currentRoadmapRoot = currentRoadmapRoot ?? issueData.parent; - const urlM = paramsFromUrl(issueData.html_url); - const url = new URL(`/roadmap/github.com/${urlM.owner}/${urlM.repo}/issues/${urlM.issue_number}`, window.location.origin); - addCrumbsParamToUrl({ currentRoadmapRoot, query, url }); + currentRoadmapRoot = currentRoadmapRoot ?? issueData.parent + const urlM = paramsFromUrl(issueData.html_url) + const url = new URL(`/roadmap/github.com/${urlM.owner}/${urlM.repo}/issues/${urlM.issue_number}`, window.location.origin) + addCrumbsParamToUrl({ currentRoadmapRoot, query, url }) if (viewMode != null) { - url.hash = viewMode; + url.hash = `view=${viewMode}` } - if (!replaceOrigin) { return url.toString() } - return url.toString().replace(window.location.origin, ''); + return url.toString().replace(window.location.origin, '') } diff --git a/lib/client/getQuantiles.ts b/lib/client/getQuantiles.ts deleted file mode 100644 index 5b40b305..00000000 --- a/lib/client/getQuantiles.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { quantile, scaleTime } from 'd3'; - -import { dayjs } from './dayjs'; - -/** - * Given an array of dates and totalNumber of ticks to display, return an array of dates - * - * @param ticks - * @param totalTicks - * @returns - */ -const getQuantilesNew = (ticks: Date[], totalTicks: number): Date[] => { - const newTicks = ticks.map((v) => dayjs.utc(v)); - const scaleDate = scaleTime() - .domain([dayjs.min(newTicks).toDate(), dayjs.max(newTicks).toDate()]) - .range([1, totalTicks]); - - const results: Date[] = []; - for (let i = 1; i <= totalTicks; i++) { - const quantileValue = scaleDate.invert(i); - if (quantileValue) { - results.push(dayjs.utc(quantileValue).toDate()); - } - } - return results; -} - -/** - * Given an array of dates and totalNumber of ticks to display, return an array of dates - * - * @param ticks - * @param totalTicks - * @returns - */ -const getQuantiles = (ticks: Date[], totalTicks: number): Date[] => { - const newTicks = ticks.map((v) => dayjs.utc(v).toDate()); - const tickIncrement = Number(1 / totalTicks); - - return Array(totalTicks) - .fill(Number(0)) - .reduce( - (a, _b, index) => { - const prev = parseFloat((Number(a[index]) || 0).toPrecision(2)); - - return [...a, parseFloat(Number(Number(prev) + Number(tickIncrement)).toPrecision(2))]; - }, - [Number(0)], - ) - .map((v: number) => quantile(newTicks, v) as number) - .map((x) => dayjs.utc(x).toDate()); -}; - -export { getQuantiles, getQuantilesNew }; diff --git a/lib/client/getTicks.ts b/lib/client/getTicks.ts deleted file mode 100644 index 4ebc33f9..00000000 --- a/lib/client/getTicks.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { utcTicks } from 'd3'; - -import { dayjs } from './dayjs'; -import { getQuantiles } from './getQuantiles'; - -const getTicks = (dates: Date[], totalTicks) => { - const count = totalTicks; - const utcDates = dates.map((date) => dayjs(date).utc()).filter((date) => date.isValid()); - const min = dayjs.min(utcDates); - const max = dayjs.max(utcDates); - - const ticks = utcTicks(min.toDate(), max.toDate(), count); - const quantiles = getQuantiles(ticks, totalTicks); - - return quantiles; -}; - -export { getTicks }; diff --git a/lib/client/getUniqIdForGroupedIssues.ts b/lib/client/getUniqIdForGroupedIssues.ts deleted file mode 100644 index 6cc0f2a6..00000000 --- a/lib/client/getUniqIdForGroupedIssues.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ImmutableArray } from '@hookstate/core'; -import { DetailedViewGroup, IssueData } from '../types'; - - -interface GetUniqIdForGroupedIssuesArgs extends Omit { - items: Pick[] -} - -export default function getUniqIdForGroupedIssues (groupedIssues: ImmutableArray): string { - return groupedIssues.map((group) => { - const groupChildrenId = group.items.map((item) => { - try { - return item.node_id; - } catch (e) { - console.error('Problem getting node_id for group item:', item) - console.error(e) - return Date.now().toString() + Math.random().toString() - } - }).join('-') - return `${group.groupName}${groupChildrenId}` - }).join('--') -} diff --git a/lib/convertGithubUrlToShorthand.ts b/lib/convertGithubUrlToShorthand.ts index 79524620..c2071955 100644 --- a/lib/convertGithubUrlToShorthand.ts +++ b/lib/convertGithubUrlToShorthand.ts @@ -1,6 +1,6 @@ -import { paramsFromUrl } from './paramsFromUrl'; +import { paramsFromUrl } from './paramsFromUrl' -export function convertGithubUrlToShorthand(url: URL): string { - const { owner, repo, issue_number } = paramsFromUrl(url.toString()); - return `${owner}/${repo}#${issue_number}`; +export function convertGithubUrlToShorthand (url: URL): string { + const { owner, repo, issue_number } = paramsFromUrl(url.toString()) + return `${owner}/${repo}#${issue_number}` } diff --git a/lib/defaults.ts b/lib/defaults.ts index 3f904fe6..20e64d69 100644 --- a/lib/defaults.ts +++ b/lib/defaults.ts @@ -1,3 +1,3 @@ -import { ViewMode } from "./enums"; +import { ViewMode } from './enums' -export const DEFAULT_INITIAL_VIEW_MODE = ViewMode.Simple; +export const DEFAULT_INITIAL_VIEW_MODE = ViewMode.Simple diff --git a/lib/enums.ts b/lib/enums.ts index e2e21bf4..400e3f94 100644 --- a/lib/enums.ts +++ b/lib/enums.ts @@ -1,14 +1,15 @@ -export enum RoadmapMode { - d3 = 'd3', - grid = 'grid', -} - export enum ViewMode { Detail = 'detail', Simple = 'simple', List = 'list', } +export enum TimeUnit { + Month = 'month', + Quarter = 'quarter', + Year = 'year', +} + export enum DateGranularityState { Months = 'months', Weeks = 'weeks', diff --git a/lib/findIssueDataByUrl.ts b/lib/findIssueDataByUrl.ts index 3ad3b884..e805e6f6 100644 --- a/lib/findIssueDataByUrl.ts +++ b/lib/findIssueDataByUrl.ts @@ -1,15 +1,15 @@ -import { IssueData } from './types'; +import { IssueData } from './types' -export function findIssueDataByUrl(rootIssueData: IssueData, parentHtmlUrl: string): IssueData | undefined { +export function findIssueDataByUrl (rootIssueData: IssueData, parentHtmlUrl: string): IssueData | undefined { if (rootIssueData.html_url === parentHtmlUrl) { - return rootIssueData; + return rootIssueData } else { let foundIssueData: IssueData | undefined = rootIssueData.children.find((issueData) => { - foundIssueData = findIssueDataByUrl(issueData, parentHtmlUrl); - return !!foundIssueData; - }); + foundIssueData = findIssueDataByUrl(issueData, parentHtmlUrl) + return !!foundIssueData + }) - return foundIssueData; + return foundIssueData } // throw new Error(`findIssueDataByUrl: Cannot find issue with url '${parentHtmlUrl}' in rootIssueData`); diff --git a/lib/findIssueDepthByUrl.ts b/lib/findIssueDepthByUrl.ts index ae9bc75f..0a281b06 100644 --- a/lib/findIssueDepthByUrl.ts +++ b/lib/findIssueDepthByUrl.ts @@ -1,18 +1,19 @@ -import { ImmutableObject } from '@hookstate/core'; -import { IssueData } from './types'; +import { ImmutableObject } from '@hookstate/core' -export function findIssueDepthByUrl(rootIssueData: ImmutableObject, parentHtmlUrl: string, depth = 0): number { +import { IssueData } from './types' + +export function findIssueDepthByUrl (rootIssueData: ImmutableObject, parentHtmlUrl: string, depth = 0): number { if (rootIssueData.html_url === parentHtmlUrl) { - return depth; + return depth } const foundIssueDepth = -1 for (const issueData of rootIssueData.children) { - const foundDepth = findIssueDepthByUrl(issueData, parentHtmlUrl, depth + 1); + const foundDepth = findIssueDepthByUrl(issueData, parentHtmlUrl, depth + 1) if (foundDepth !== -1) { return foundDepth } - }; + } - return foundIssueDepth; + return foundIssueDepth } diff --git a/lib/flattenStarMapsErrorGroups.ts b/lib/flattenStarMapsErrorGroups.ts index c1567305..047b84e9 100644 --- a/lib/flattenStarMapsErrorGroups.ts +++ b/lib/flattenStarMapsErrorGroups.ts @@ -1,5 +1,5 @@ -import { StarMapsError, StarMapsIssueErrorsGrouped } from './types'; +import { StarMapsError, StarMapsIssueErrorsGrouped } from './types' -export function flattenStarMapsErrorGroups(errors: StarMapsIssueErrorsGrouped[]): StarMapsError[] { +export function flattenStarMapsErrorGroups (errors: StarMapsIssueErrorsGrouped[]): StarMapsError[] { return errors.flatMap(({ issueUrl, issueTitle, errors }) => errors.map(({ userGuideUrl, title, message }) => ({ issueUrl, issueTitle, userGuideUrl, title, message }))) } diff --git a/lib/getValidUrlFromInput.ts b/lib/getValidUrlFromInput.ts index 64c61be9..79d7f6eb 100644 --- a/lib/getValidUrlFromInput.ts +++ b/lib/getValidUrlFromInput.ts @@ -1,4 +1,4 @@ -export function getValidUrlFromInput(urlString: string): URL { +export function getValidUrlFromInput (urlString: string): URL { if (/#\d+/.test(urlString)) { urlString = urlString.replace('#', '/issues/') } else if (!/\/issues\/\d+/.test(urlString)) { @@ -19,4 +19,4 @@ export function getValidUrlFromInput(urlString: string): URL { } return new URL(urlString, 'https://github.com') -}; +} diff --git a/lib/groupStarMapsErrors.ts b/lib/groupStarMapsErrors.ts index 1316b565..707e1bf6 100644 --- a/lib/groupStarMapsErrors.ts +++ b/lib/groupStarMapsErrors.ts @@ -1,27 +1,26 @@ -import { groupBy, uniqBy } from 'lodash'; +import { groupBy, uniqBy } from 'lodash' -import { StarMapsError, StarMapsIssueError, StarMapsIssueErrorsGrouped } from './types'; +import { StarMapsError, StarMapsIssueError, StarMapsIssueErrorsGrouped } from './types' -export function groupStarMapsErrors(errors: StarMapsError[]): StarMapsIssueErrorsGrouped[] { - const groupedErrors = groupBy(uniqBy(errors, (error) => `${error.issueUrl}${error.message}`), 'issueUrl'); - const processedErrors: StarMapsIssueErrorsGrouped[] = []; - for (const [url, errorsForUrl] of Object.entries(groupedErrors)) { - const urlErrors: StarMapsIssueError[] = [] - errorsForUrl.forEach((starMapError) => { - urlErrors.push({ - // errors: - userGuideUrl: starMapError.userGuideUrl, - title: starMapError.title, - message: starMapError.message, - }); - - }); - processedErrors.push({ - issueUrl: url, - issueTitle: errorsForUrl[0].issueTitle, - errors: urlErrors, - }); - } - - return processedErrors; +export function groupStarMapsErrors (errors: StarMapsError[]): StarMapsIssueErrorsGrouped[] { + const groupedErrors = groupBy(uniqBy(errors, (error) => `${error.issueUrl}${error.message}`), 'issueUrl') + const processedErrors: StarMapsIssueErrorsGrouped[] = [] + for (const [url, errorsForUrl] of Object.entries(groupedErrors)) { + const urlErrors: StarMapsIssueError[] = [] + errorsForUrl.forEach((starMapError) => { + urlErrors.push({ + // errors: + userGuideUrl: starMapError.userGuideUrl, + title: starMapError.title, + message: starMapError.message + }) + }) + processedErrors.push({ + issueUrl: url, + issueTitle: errorsForUrl[0].issueTitle, + errors: urlErrors + }) } + + return processedErrors +} diff --git a/lib/helpers.ts b/lib/helpers.ts index d8b567fe..fa7a1926 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -1,14 +1,14 @@ -import { dayjs } from './client/dayjs'; -import type { ErrorManager } from './backend/errorManager'; -import type { IssueData } from './types'; +import type { ErrorManager } from './backend/errorManager' +import { dayjs } from './client/dayjs' +import type { IssueData } from './types' /** * deprecating non-ISO 8601 date formats * @see https://github.com/pln-planning-tools/Starmap/issues/311 * @see https://github.com/pln-planning-tools/Starmap/issues/275 */ -const errorTitle = 'ETA format deprecated'; -const errorMessage = 'ETA must use ISO 8601 standard (YYYY-MM-DD). Please see https://github.com/pln-planning-tools/Starmap/issues/275'; +const errorTitle = 'ETA format deprecated' +const errorMessage = 'ETA must use ISO 8601 standard (YYYY-MM-DD). Please see https://github.com/pln-planning-tools/Starmap/issues/275' /** * Date parser based on: https://github.com/pln-planning-tools/Starmap/blob/main/User%20Guide.md#eta-requirement @@ -18,15 +18,15 @@ const errorMessage = 'ETA must use ISO 8601 standard (YYYY-MM-DD). Please see ht */ export const getEtaDate = (data: string, config: { addError: ErrorManager['addError'], issue: Pick }): string => { // how this works: https://www.debuggex.com/r/x-U2AnhTwWbSCXCD - const etaRegex = /^\s*eta\s*:\s*(?\d{4}(Q[1-4]|\-\d{2}(\-\d{2})?))/im; - const dateString = data.match(etaRegex)?.groups?.dateString; + const etaRegex = /^\s*eta\s*:\s*(?\d{4}(Q[1-4]|-\d{2}(-\d{2})?))/im + const dateString = data.match(etaRegex)?.groups?.dateString if (!dateString) { - throw new Error('No ETA date found'); + throw new Error('No ETA date found') } - const year = parseInt(dateString.slice(0, 4)); - let etaDate = dayjs().year(year); + const year = parseInt(dateString.slice(0, 4)) + let etaDate = dayjs().year(year) if (dateString[4] === 'Q') { if (config.addError) { @@ -34,38 +34,38 @@ export const getEtaDate = (data: string, config: { addError: ErrorManager['addEr issue: config.issue, userGuideSection: '#eta', errorTitle, - errorMessage, - }); + errorMessage + }) } etaDate = etaDate .quarter(parseInt(dateString[5])) - .endOf('quarter'); + .endOf('quarter') } else { - etaDate = etaDate.month(parseInt(dateString.slice(5, 7)) - 1); + etaDate = etaDate.month(parseInt(dateString.slice(5, 7)) - 1) if (dateString.length === 7) { if (config.addError) { config.addError({ issue: config.issue, userGuideSection: '#eta', errorTitle, - errorMessage, - }); + errorMessage + }) } - etaDate = etaDate.endOf('month'); + etaDate = etaDate.endOf('month') } else { - etaDate = etaDate.date(parseInt(dateString.slice(8, 10))); + etaDate = etaDate.date(parseInt(dateString.slice(8, 10))) } } - return etaDate.format('YYYY-MM-DD'); -}; + return etaDate.format('YYYY-MM-DD') +} -export const isValidChildren = (v) => /^children[:]?$/im.test(v); +export const isValidChildren = (v) => /^children[:]?$/im.test(v) export const getTimeFromDateString = (dateString: string, defaultValue: number): number => { try { - return new Date(dateString).getTime(); + return new Date(dateString).getTime() } catch { - return defaultValue; + return defaultValue } } diff --git a/lib/mergeStarMapsErrorGroups.ts b/lib/mergeStarMapsErrorGroups.ts index da46b6a6..334993ef 100644 --- a/lib/mergeStarMapsErrorGroups.ts +++ b/lib/mergeStarMapsErrorGroups.ts @@ -1,12 +1,12 @@ -import { flattenStarMapsErrorGroups } from './flattenStarMapsErrorGroups'; -import { groupStarMapsErrors } from './groupStarMapsErrors'; -import { StarMapsError, StarMapsIssueErrorsGrouped } from './types'; +import { flattenStarMapsErrorGroups } from './flattenStarMapsErrorGroups' +import { groupStarMapsErrors } from './groupStarMapsErrors' +import { StarMapsError, StarMapsIssueErrorsGrouped } from './types' -export function mergeStarMapsErrorGroups(errors1: StarMapsIssueErrorsGrouped[], errors2: StarMapsIssueErrorsGrouped[]) { +export function mergeStarMapsErrorGroups (errors1: StarMapsIssueErrorsGrouped[], errors2: StarMapsIssueErrorsGrouped[]) { const flattenedErrors: StarMapsError[] = [ ...flattenStarMapsErrorGroups(errors1), - ...flattenStarMapsErrorGroups(errors2), + ...flattenStarMapsErrorGroups(errors2) ] - return groupStarMapsErrors(flattenedErrors); + return groupStarMapsErrors(flattenedErrors) } diff --git a/lib/paramsFromUrl.ts b/lib/paramsFromUrl.ts index 9d12ee4e..7fbd6171 100644 --- a/lib/paramsFromUrl.ts +++ b/lib/paramsFromUrl.ts @@ -1,19 +1,19 @@ -import { getValidUrlFromInput } from './getValidUrlFromInput'; -import { slugsFromUrl } from './slugsFromUrl'; -import { UrlMatchSlugs } from './types'; +import { getValidUrlFromInput } from './getValidUrlFromInput' +import { slugsFromUrl } from './slugsFromUrl' +import { UrlMatchSlugs } from './types' export function paramsFromUrl (urlString: string): UrlMatchSlugs { if (urlString.includes('/issues/') && urlString.lastIndexOf('#') > 0) { - urlString = urlString.split('#')[0]; + urlString = urlString.split('#')[0] } try { const urlObj = getValidUrlFromInput(urlString) const matchResult = slugsFromUrl(urlObj.pathname) if (matchResult !== false && matchResult.params != null) { - return { ...matchResult.params }; + return { ...matchResult.params } } - throw new Error(`Could not parse URL: ${urlString}`); + throw new Error(`Could not parse URL: ${urlString}`) } catch { - throw new Error(`Unsupported URL: ${urlString}`); + throw new Error(`Unsupported URL: ${urlString}`) } -}; +} diff --git a/lib/parser.ts b/lib/parser.ts index 9e0edb12..a6e191fc 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -1,16 +1,18 @@ -import { parseHTML } from 'linkedom'; -import { ErrorManager } from './backend/errorManager'; -import { getValidUrlFromInput } from './getValidUrlFromInput'; -import { getEtaDate, isValidChildren } from './helpers'; -import { paramsFromUrl } from './paramsFromUrl'; -import { GithubIssueData, GithubIssueDataWithChildren, ParserGetChildrenResponse } from './types'; +import { parseHTML } from 'linkedom' + +import { ErrorManager } from './backend/errorManager' +import { getValidUrlFromInput } from './getValidUrlFromInput' +import { getEtaDate, isValidChildren } from './helpers' +import { paramsFromUrl } from './paramsFromUrl' +import { GithubIssueData, GithubIssueDataWithChildren, ParserGetChildrenResponse } from './types' +import { isNonEmptyString } from './typescriptGuards' export const getDueDate = (issue: Pick, errorManager: ErrorManager) => { - const { body_html: issueBodyHtml } = issue; + const { body_html: issueBodyHtml } = issue - const { document } = parseHTML(issueBodyHtml); - const issueText = [...document.querySelectorAll('*')].map((v) => v.textContent).join('\n'); - let eta: string | null = null; + const { document } = parseHTML(issueBodyHtml) + const issueText = [...document.querySelectorAll('*')].map((v) => v.textContent).join('\n') + let eta: string | null = null try { eta = getEtaDate(issueText, { addError: errorManager.addError.bind(errorManager), issue }) } catch (e) { @@ -19,35 +21,35 @@ export const getDueDate = (issue: Pick= 0) { return indexOf + startpos } - return indexOf; + return indexOf } -function indexOf(string: string, strOrRegex: string | RegExp, startpos = 0) { +function indexOf (string: string, strOrRegex: string | RegExp, startpos = 0) { if (typeof strOrRegex === 'string') { return string.indexOf(strOrRegex, startpos) } return regexIndexOf(string, strOrRegex, startpos) } -function getSectionLines(text: string, sectionHeader: string | RegExp): string { - const sectionStartIndex = indexOf(text, sectionHeader); +function getSectionLines (text: string, sectionHeader: string | RegExp): string { + const sectionStartIndex = indexOf(text, sectionHeader) if (sectionStartIndex === -1) { - return ''; + return '' } const startText = text.substring(sectionStartIndex) /** @@ -61,7 +63,7 @@ function getSectionLines(text: string, sectionHeader: string | RegExp): string { return startText.substring(0, sectionEndIndex) } -function getCleanedSectionLines(text: string, sectionHeader: string | RegExp) { +function getCleanedSectionLines (text: string, sectionHeader: string | RegExp) { const lines = getSectionLines(text, sectionHeader) if (typeof lines === 'string') { return lines.split(/[\r\n]+/).slice(1) @@ -100,9 +102,8 @@ const getGithubLinkFromLine = (line: string): string | null => { } const ensureTaskListChild = (line: string) => line.trim().indexOf('-') === 0 const getUrlFromMarkdownText = (line: string) => line.trim().split('](').slice(-1)[0].replace(')', '') -const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 -function getUrlStringForChildrenLine(line: string, issue: Pick) { +function getUrlStringForChildrenLine (line: string, issue: Pick) { if (/^#\d+$/.test(line)) { const { owner, repo } = paramsFromUrl(issue.html_url) line = `${owner}/${repo}${line}` @@ -127,12 +128,12 @@ function getUrlStringForChildrenLine(line: string, issue: Pick): ParserGetChildrenResponse[] { +function getChildrenFromTaskList (issue: Pick): ParserGetChildrenResponse[] { // tasklists require the checkbox style format to recognize children const lines = getCleanedSectionLines(issue.body, '```[tasklist]') .filter(ensureTaskListChild) .map(getGithubLinkFromLine) - .filter(isNonEmptyString); + .filter(isNonEmptyString) if (lines.length === 0) { throw new Error('Section missing or has no children') @@ -147,30 +148,29 @@ function getChildrenFromTaskList(issue: Pick): ParserGetChildrenResponse[] { - +function getChildrenNew (issue: Pick): ParserGetChildrenResponse[] { try { - return getChildrenFromTaskList(issue); + return getChildrenFromTaskList(issue) } catch (e) { // Could not find children using new tasklist format, // try to look for "children:" section } const lines = getCleanedSectionLines(issue.body, /children:/i) .map(getGithubLinkFromLine) - .filter(isNonEmptyString); + .filter(isNonEmptyString) if (lines.length === 0) { throw new Error('Section missing or has no children') } // guard against HTML tags (covers cases where this method is called with issue.body_html instead of issue.body_text) if (lines.some((line) => line.startsWith('<'))) { - throw new Error('HTML tags found in body_text'); + throw new Error('HTML tags found in body_text') } return convertLinesToChildren(lines, issue, 'children:') } -function getValidChildrenLinks(lines: string[], issue: Pick): string[] { +function getValidChildrenLinks (lines: string[], issue: Pick): string[] { const validChildrenLinks: string[] = [] for (const line of lines) { try { @@ -182,45 +182,45 @@ function getValidChildrenLinks(lines: string[], issue: Pick, group: string): ParserGetChildrenResponse[] { +function convertLinesToChildren (lines: string[], issue: Pick, group: string): ParserGetChildrenResponse[] { const validChildrenLinks = getValidChildrenLinks(lines, issue) return validChildrenLinks .map((html_url): ParserGetChildrenResponse => ({ group, - html_url, + html_url })) } export const getChildren = (issue: Pick): ParserGetChildrenResponse[] => { try { - return getChildrenNew(issue); + return getChildrenNew(issue) } catch (e) { // ignore failures for now, fallback to old method. } - const { document } = parseHTML(issue.body_html); - const ulLists = [...document.querySelectorAll('ul')]; + const { document } = parseHTML(issue.body_html) + const ulLists = [...document.querySelectorAll('ul')] const filterListByTitle = (ulLists) => ulLists.filter((list) => { - const title = list.previousElementSibling?.textContent?.trim(); + const title = list.previousElementSibling?.textContent?.trim() - return !!isValidChildren(title); - }); + return !!isValidChildren(title) + }) const children = filterListByTitle(ulLists) .reduce((a: any, b) => { - const listTitle = b.previousElementSibling?.textContent?.trim(); - const hrefSelector = [...b.querySelectorAll('a[href][data-hovercard-type*="issue"]')]; - const listHrefs = hrefSelector?.map((v: any) => v.href); + const listTitle = b.previousElementSibling?.textContent?.trim() + const hrefSelector = [...b.querySelectorAll('a[href][data-hovercard-type*="issue"]')] + const listHrefs = hrefSelector?.map((v: any) => v.href) - return [...a, { group: listTitle, hrefs: listHrefs }]; + return [...a, { group: listTitle, hrefs: listHrefs }] }, []) .flat() .map((item) => item.hrefs.map((href) => ({ html_url: href, group: item.group }))) - .flat(); + .flat() - return [...children]; -}; + return [...children] +} /** * Search for "description: " in the issue body, and return all text after that @@ -231,16 +231,16 @@ export const getChildren = (issue: Pick { - if (issueBodyText.length === 0) return ''; + if (issueBodyText.length === 0) return '' const [firstLine, ...linesToParse] = getSectionLines(issueBodyText, 'description:') .split(/\r\n|\r|\n/) // We do not want to replace multiple newlines, only one. // the first line may contain only "description:" or "description: This is the start of my description" const firstLineContent = firstLine - .replace(/^.{0,}description:/, '') - .replace(/-->/g, '') // may be part of an HTML comment so it's hidden from the user. Remove the HTML comment end tag - .trim(); + .replace(/^.{0,}description:/, '') + .replace(/-->/g, '') // may be part of an HTML comment so it's hidden from the user. Remove the HTML comment end tag + .trim() const descriptionLines: string[] = [] if (firstLineContent !== '') { @@ -254,5 +254,5 @@ export const getDescription = (issueBodyText: string): string => { descriptionLines.push(line.trim()) } - return descriptionLines.join('\n'); + return descriptionLines.join('\n') } diff --git a/lib/slugsFromUrl.ts b/lib/slugsFromUrl.ts index a7266e73..cd8819e7 100644 --- a/lib/slugsFromUrl.ts +++ b/lib/slugsFromUrl.ts @@ -1,8 +1,8 @@ -import { match, MatchResult } from 'path-to-regexp'; +import { match, MatchResult } from 'path-to-regexp' -import { UrlMatchSlugs } from './types'; +import { UrlMatchSlugs } from './types' export const slugsFromUrl = (url: string): MatchResult | false => match( '/:owner/:repo/issues/:issue_number(\\d+)', { - decode: decodeURIComponent, - })(url); + decode: decodeURIComponent + })(url) diff --git a/lib/types.d.ts b/lib/types.ts similarity index 76% rename from lib/types.d.ts rename to lib/types.ts index a12cb436..57c7bad2 100644 --- a/lib/types.d.ts +++ b/lib/types.ts @@ -1,6 +1,6 @@ -import type { ImmutableArray, State } from '@hookstate/core' +import type { ImmutableArray, ImmutableObject } from '@hookstate/core' -import type { RoadmapMode, IssueStates, DateGranularityState } from './enums' +import type { IssueStates, DateGranularityState } from './enums' export interface GithubIssueData { body_html: string; @@ -19,31 +19,36 @@ export interface GithubIssueDataWithGroup extends GithubIssueData { } export interface GithubIssueDataWithChildren extends GithubIssueData { + // eslint-disable-next-line no-use-before-define children: GithubIssueDataWithGroupAndChildren[]; } export interface GithubIssueDataWithGroupAndChildren extends GithubIssueDataWithGroup, GithubIssueDataWithChildren { + // eslint-disable-next-line no-use-before-define pendingChildren?: PendingChildren[] } interface ProcessedGithubIssueDataWithGroupAndChildren extends Omit { children: ProcessedGithubIssueDataWithGroupAndChildren[]; } -interface PreParsedIssueData extends ProcessedGithubIssueDataWithGroupAndChildren { +interface PostParsedIssueData extends ProcessedGithubIssueDataWithGroupAndChildren { + // eslint-disable-next-line no-use-before-define children: (PreParsedIssueData | IssueData)[]; + // eslint-disable-next-line no-use-before-define parent: PreParsedIssueData; } -type PostParsedIssueData = PreParsedIssueData; -type ProcessedParentIssueData = Omit; +// type PostParsedIssueData = PreParsedIssueData; interface PreParsedIssueData extends ProcessedGithubIssueDataWithGroupAndChildren { + // eslint-disable-next-line no-use-before-define children: (PreParsedIssueData | IssueData)[]; completion_rate: number; due_date: string; parent: PreParsedIssueData; } +// type ProcessedParentIssueData = Omit; -export interface IssueData extends Omit { +export interface IssueData extends Omit { children: IssueData[]; completion_rate: number; due_date: string; @@ -53,12 +58,15 @@ export interface IssueData extends Omit; -} - export interface UrlMatchSlugs { owner: string; repo: string; @@ -150,17 +152,10 @@ export interface UrlMatchSlugs { export interface QueryParameters { filter_group?: string; - mode?: RoadmapMode; timeUnit?: DateGranularityState; crumbs?: string; } -export interface IssueDataViewInput { - issueDataState: State; - // isRootIssueLoading: boolean; - // isPendingChildrenLoading: boolean; -} - export type BrowserMetricsProvider = typeof import('@ipfs-shipyard/ignite-metrics').BrowserMetricsProvider interface StarmapContentUpdatedEvent extends Event { @@ -170,6 +165,21 @@ interface StarmapContentUpdatedEvent extends Event { } } +export type BinPackIssueData = Pick + +export interface BoxItem { + top: number, // y1 + bottom: number, // y2 + left: number, // x1 + right: number, // x2 +} + +export interface BinPackItem extends BoxItem { + data: ImmutableObject +} + +export type BinPackedGroup = Omit & {items: BinPackItem[]} + declare global { interface DocumentEventMap { 'starmap:content:updated': StarmapContentUpdatedEvent; diff --git a/lib/typescriptGuards/index.ts b/lib/typescriptGuards/index.ts new file mode 100644 index 00000000..0ec4ed68 --- /dev/null +++ b/lib/typescriptGuards/index.ts @@ -0,0 +1 @@ +export const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 diff --git a/package.json b/package.json index eb6b7fb2..9d41f594 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@octokit/rest": "^19.0.5", "@types/d3": "^7.4.0", "@types/lodash": "^4.14.186", + "@visx/text": "^3.0.0", "awesome-toast-component": "^2.0.6", "chakra-ui-markdown-renderer": "^4.1.0", "cheerio": "^1.0.0-rc.12", @@ -78,10 +79,13 @@ "eslint-config-airbnb": "19.0.4", "eslint-config-next": "12.3.1", "eslint-config-prettier": "^8.5.0", + "eslint-config-standard": "^17.0.0", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-n": "^15.7.0", "eslint-plugin-primer-react": "^1.0.1", + "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.6.0", "jest": "^29.3.1", @@ -91,5 +95,18 @@ "react-icons": "^4.6.0", "ts-jest": "^29.0.3", "typescript": "^4.8.4" + }, + "next-unused": { + "alias": {}, + "include": [ + "components", + "config", + "hooks", + "lib", + "pages", + "styles" + ], + "exclude": [], + "entrypoints": [] } } diff --git a/pages/RoadmapNav.tsx b/pages/RoadmapNav.tsx deleted file mode 100644 index 4079fafd..00000000 --- a/pages/RoadmapNav.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useRouter } from 'next/router'; - -import { useEffect } from 'react'; - -// const router = useRouter(); -// const { pathname, query } = router; - -// console.dir(router, { maxArrayLength: Infinity, depth: Infinity }); - -// const handleBack = () => { -// router.push(new URL({ pathname, query })); -// }; - -// Current URL is '/' -function Page() { - const router = useRouter(); - - useEffect(() => { - // Always do navigations after the first render - router.push('/RoadmapNav/?rootIssue=3&parentIssue=2¤tIssue=1', '/RoadmapNav', { shallow: true }); - }, [router]); - - useEffect(() => { - console.log('router.query.currentIssue changed! | router:', router); - }, [router, router.query.currentIssue]); -} - -export default Page; diff --git a/pages/_app.tsx b/pages/_app.tsx index 0f0e4d14..7955725a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,28 +1,30 @@ -import Head from 'next/head'; -import { ChakraProvider, extendTheme } from '@chakra-ui/react'; -import { noSSR } from 'next/dynamic'; -import React, { useEffect } from 'react'; -import { onCLS, onFID, onLCP } from 'web-vitals'; +import { ChakraProvider, extendTheme } from '@chakra-ui/react' +import { noSSR } from 'next/dynamic' +import Head from 'next/head' +import React, { useEffect } from 'react' +import { onCLS, onFID, onLCP } from 'web-vitals' -import { setTelemetry, useTelemetry } from '../hooks/useTelemetry'; - -import './style.css'; -import type { BrowserMetricsProvider } from '../lib/types'; +import { setTelemetry, useTelemetry } from '../hooks/useTelemetry' +import type { BrowserMetricsProvider } from '../lib/types' +import './style.css' const theme = extendTheme({ semanticTokens: { colors: { + background: { + default: '#FFFFFF' + }, inactive: { // darkGray: '#D7D7D7', - default: '#D7D7D7', + default: '#D7D7D7' }, inactiveAccent: { // lightGray: '#EFEFEF', - default: '#EFEFEF', + default: '#EFEFEF' }, progressGreen: { // progressGreen: '#7DE087', - default: '#7DE087', + default: '#7DE087' }, progressGreenAccent: { // progressGreenAccent: 'rgba(125, 224, 135, 0.28)', @@ -30,16 +32,25 @@ const theme = extendTheme({ default: '#7de08747' }, spotLightBlue: { - default: '#1FA5FF', + default: '#1FA5FF' }, linkBlue: { - default: '#4987BD', + default: '#4987BD' }, text: { default: '#313239' + }, + textMuted: { + default: '#a3a3a3' + }, + textHeader: { + default: '#FFFFFF' + }, + orangeAccent: { + default: '#F39106' } - }, - }, + } + } }) /** @@ -51,23 +62,23 @@ const theme = extendTheme({ // @ts-expect-error const igniteMetricsModulePromise: Promise<{BrowserMetricsProvider: BrowserMetricsProvider}> = noSSR(() => import('@ipfs-shipyard/ignite-metrics/browser-vanilla'), {}) -function logDelta({ name, id, delta, value, rating }) { - console.log(`${name} (${rating}): ID ${id}: ${value} - changed by ${delta}`); +function logDelta ({ name, id, delta, value, rating }) { + console.log(`${name} (${rating}): ID ${id}: ${value} - changed by ${delta}`) } let webVitalsRegistered = false -function App({ Component, pageProps }) { +function App ({ Component, pageProps }) { const telemetry = useTelemetry() useEffect(() => { if (webVitalsRegistered) return webVitalsRegistered = true - onCLS(logDelta, { reportAllChanges: true }); - onFID(logDelta, { reportAllChanges: true }); - onLCP(logDelta, { reportAllChanges: true }); + onCLS(logDelta, { reportAllChanges: true }) + onFID(logDelta, { reportAllChanges: true }) + onLCP(logDelta, { reportAllChanges: true }) }, []) useEffect(() => { // read from the localStorage and send a message to the service worker to call debug.enable() - async function setupSWDebug() { + async function setupSWDebug () { const registration = await navigator.serviceWorker.ready const debugString = localStorage.getItem('debug') if (debugString) { @@ -77,11 +88,11 @@ function App({ Component, pageProps }) { }) } } - setupSWDebug(); + setupSWDebug() }, []) useEffect(() => { - (async() => { + (async () => { if (telemetry == null) { const { BrowserMetricsProvider } = await igniteMetricsModulePromise const newTelemetry = new BrowserMetricsProvider({ appKey: '294089175b8268e44bc4e4fab572fe250d57b968' }) @@ -105,7 +116,7 @@ function App({ Component, pageProps }) { - ); + ) } -export default App; +export default App diff --git a/pages/_document.tsx b/pages/_document.tsx index f6ff1a2c..6c9143af 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,7 +1,7 @@ import { Html, Head, Main, NextScript } from 'next/document' import Script from 'next/script' -export default function Document() { +export default function Document () { return ( diff --git a/pages/api/pendingChild.ts b/pages/api/pendingChild.ts index a073d989..11301e51 100644 --- a/pages/api/pendingChild.ts +++ b/pages/api/pendingChild.ts @@ -1,13 +1,13 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { addToChildren } from '../../lib/backend/addToChildren'; +import type { NextApiRequest, NextApiResponse } from 'next' -import { convertParsedChildToGroupedIssueData } from '../../lib/backend/convertParsedChildToGroupedIssueData'; -import { ErrorManager } from '../../lib/backend/errorManager'; -import { getGithubIssueDataWithGroupAndChildren } from '../../lib/backend/getGithubIssueDataWithGroupAndChildren'; -import { checkForSavedIssueData, saveIssueDataToFile } from '../../lib/backend/saveIssueDataToFile'; -import { GithubIssueDataWithGroup, PendingChildApiResponse } from '../../lib/types'; +import { addToChildren } from '../../lib/backend/addToChildren' +import { convertParsedChildToGroupedIssueData } from '../../lib/backend/convertParsedChildToGroupedIssueData' +import { ErrorManager } from '../../lib/backend/errorManager' +import { getGithubIssueDataWithGroupAndChildren } from '../../lib/backend/getGithubIssueDataWithGroupAndChildren' +import { checkForSavedIssueData, saveIssueDataToFile } from '../../lib/backend/saveIssueDataToFile' +import { GithubIssueDataWithGroup, PendingChildApiResponse } from '../../lib/types' -export default async function handler( +export default async function handler ( req: NextApiRequest, res: NextApiResponse ): Promise { @@ -15,48 +15,47 @@ export default async function handler( res.status(405).send({ error: { ...new Error('Only GET requests allowed'), code: '405' } }) return } - const { owner, repo, issue_number, parentJson } = req.query; - const parentJsonDecoded = decodeURIComponent(parentJson as string); - const parent = JSON.parse(parentJsonDecoded); - const errorManager = new ErrorManager(); + const { owner, repo, issue_number, parentJson } = req.query + const parentJsonDecoded = decodeURIComponent(parentJson as string) + const parent = JSON.parse(parentJsonDecoded) + const errorManager = new ErrorManager() if (process.env.IS_LOCAL === 'true') { try { - const issueData = await checkForSavedIssueData({ owner, repo, issue_number }); - console.log(`Returning saved issueData for ${owner}/${repo}#${issue_number}`); - res.status(200).json({ data: issueData, errors: [] }); - return; + const issueData = await checkForSavedIssueData({ owner, repo, issue_number }) + console.log(`Returning saved issueData for ${owner}/${repo}#${issue_number}`) + res.status(200).json({ data: issueData, errors: [] }) + return } catch { - console.log(`NOT_FOUND: saved issueData for ${owner}/${repo}#${issue_number}`); + console.log(`NOT_FOUND: saved issueData for ${owner}/${repo}#${issue_number}`) } } try { const issueDataWithGroup: GithubIssueDataWithGroup = await convertParsedChildToGroupedIssueData({ html_url: `https://github.com/${owner}/${repo}/issues/${issue_number}`, - group: '', + group: '' }) try { const issueDataWithGroupAndChildren = await getGithubIssueDataWithGroupAndChildren(issueDataWithGroup, errorManager, false) const issueData = addToChildren([issueDataWithGroupAndChildren], parent, errorManager)[0] if (process.env.IS_LOCAL === 'true') { - saveIssueDataToFile(issueData); + saveIssueDataToFile(issueData) } res.status(200).json({ data: issueData, - errors: errorManager.flushErrors(), - } as PendingChildApiResponse); + errors: errorManager.flushErrors() + } as PendingChildApiResponse) } catch (err) { res.status(501).json({ - error: { ...err as Error, code: '501' }, + error: { ...err as Error, code: '501' } } as PendingChildApiResponse) } } catch (err) { res.status(502).json({ - error: { ...err as Error, code: '501' }, + error: { ...err as Error, code: '501' } }) } - } diff --git a/pages/api/roadmap.ts b/pages/api/roadmap.ts index 920ab491..0fa545aa 100644 --- a/pages/api/roadmap.ts +++ b/pages/api/roadmap.ts @@ -1,16 +1,17 @@ -import { ErrorManager } from '../../lib/backend/errorManager'; -import { getChildren } from '../../lib/parser'; -import { getIssue } from '../../lib/backend/issue'; +import type { NextApiRequest, NextApiResponse } from 'next' + +import { addToChildren } from '../../lib/backend/addToChildren' +import { ErrorManager } from '../../lib/backend/errorManager' +import { getIssue } from '../../lib/backend/issue' +import { resolveChildrenWithDepth } from '../../lib/backend/resolveChildrenWithDepth' +import { getChildren } from '../../lib/parser' import { RoadmapApiResponse, RoadmapApiResponseFailure, RoadmapApiResponseSuccess - } from '../../lib/types'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { resolveChildrenWithDepth } from '../../lib/backend/resolveChildrenWithDepth'; -import { addToChildren } from '../../lib/backend/addToChildren'; +} from '../../lib/types' -export default async function handler( +export default async function handler ( req: NextApiRequest, res: NextApiResponse ): Promise { @@ -18,42 +19,42 @@ export default async function handler( res.status(405).send({ error: { ...new Error('Only GET requests allowed'), code: '405' } }) return } - const { platform = 'github', owner, repo, issue_number } = req.query; - const options = Object.create({}); - options.depth = Number(req.query.depth); + const { platform = 'github', owner, repo, issue_number } = req.query + const options = Object.create({}) + options.depth = Number(req.query.depth) // Set filter_group to a specific word to only return children under the specified word heading. - options.filter_group = req.query.filter_group || 'children'; + options.filter_group = req.query.filter_group || 'children' - const errorManager = new ErrorManager(); + const errorManager = new ErrorManager() if (!platform || !owner || !repo || !issue_number) { res.status(400).json({ errors: errorManager.flushErrors(), error: { code: '400', message: 'URL query is missing fields' } - } as RoadmapApiResponseFailure); - return; + } as RoadmapApiResponseFailure) + return } try { - const rootIssue = await getIssue({ owner, repo, issue_number }); + const rootIssue = await getIssue({ owner, repo, issue_number }) - const childrenFromBodyHtml = (!!rootIssue && rootIssue.body_html && getChildren(rootIssue)) || null; - let children: Awaited> = []; + const childrenFromBodyHtml = (!!rootIssue && rootIssue.body_html && getChildren(rootIssue)) || null + let children: Awaited> = [] try { if (childrenFromBodyHtml != null) { children = await resolveChildrenWithDepth(childrenFromBodyHtml, errorManager) if (children.length === 0) { - throw new Error('No children found, is this a root issue?'); + throw new Error('No children found, is this a root issue?') } } } catch (err: any) { - console.error(err); + console.error(err) if (rootIssue != null) { errorManager.addError({ issue: rootIssue, userGuideSection: '#children', errorTitle: 'Error resolving children', - errorMessage: err.message, - }); + errorMessage: err.message + }) } } @@ -62,18 +63,18 @@ export default async function handler( root_issue: true, group: 'root', children - }], undefined, errorManager)[0]; + }], undefined, errorManager)[0] res.status(200).json({ errors: errorManager.flushErrors(), data: issueData, - pendingChildren: children.flatMap((child) => child.pendingChildren), - } as RoadmapApiResponseSuccess); + pendingChildren: children.flatMap((child) => child.pendingChildren) + } as RoadmapApiResponseSuccess) } catch (err) { - const message = (err as Error)?.message ?? err; + const message = (err as Error)?.message ?? err res.status(404).json({ errors: errorManager.flushErrors(), error: { code: '404', message: `An Unknown error has occurred and was not captured by the errorManager: ${message}` } - } as RoadmapApiResponseFailure); + } as RoadmapApiResponseFailure) } } diff --git a/pages/index.tsx b/pages/index.tsx index ad220e54..6eea855e 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,48 +1,47 @@ -import { readFile } from 'fs/promises'; -import { join } from 'path'; +import { readFile } from 'fs/promises' +import { join } from 'path' -import type { NextPage } from 'next'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import ChakraUIRenderer from 'chakra-ui-markdown-renderer'; -import { Center, Link, Text, Flex } from '@chakra-ui/react'; -import Image from 'next/image'; -import NextLink from 'next/link'; -import GitHubSvgIcon from '../components/icons/GitHubLogo.svg'; -import themes from '../components/theme/constants'; +import { Center, Link, Text, Flex } from '@chakra-ui/react' +import ChakraUIRenderer from 'chakra-ui-markdown-renderer' +import type { NextPage } from 'next' +import Image from 'next/image' +import NextLink from 'next/link' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' -import styles from './LandingPage.module.css'; -import PageHeader from '../components/layout/PageHeader'; +import GitHubSvgIcon from '../components/icons/GitHubLogo.svg' +import PageHeader from '../components/layout/PageHeader' +import themes from '../components/theme/constants' +import styles from './LandingPage.module.css' interface SSProps { markdown: string } -const starmapsGithubUrl = 'https://github.com/pln-planning-tools/Starmap/blob/main/User%20Guide.md'; +const starmapsGithubUrl = 'https://github.com/pln-planning-tools/Starmap/blob/main/User%20Guide.md' -export async function getServerSideProps(): Promise<{ props: SSProps }> { - const filePath = join(process.cwd(), 'User Guide.md'); - const markdown = await readFile(filePath, 'utf8'); +export async function getServerSideProps (): Promise<{ props: SSProps }> { + const filePath = join(process.cwd(), 'User Guide.md') + const markdown = await readFile(filePath, 'utf8') return { props: { markdown - }, - }; + } + } } const chakraUiRendererTheme: Parameters[0] = { a: (props) => { - const { children, href } = props; + const { children, href } = props return ( {children} - ); - }, -}; - + ) + } +} const App: NextPage = ({ markdown }: SSProps) => ( <> @@ -59,10 +58,12 @@ const App: NextPage = ({ markdown }: SSProps) => ( - + + {markdown} +
- ); +) -export default App; +export default App diff --git a/pages/roadmap/[...slug].tsx b/pages/roadmap/[...slug].tsx index 62fe3c41..85cf13fb 100644 --- a/pages/roadmap/[...slug].tsx +++ b/pages/roadmap/[...slug].tsx @@ -1,23 +1,23 @@ -import { Box } from '@chakra-ui/react'; -import { none, State, useHookstate } from '@hookstate/core'; -import type { InferGetServerSidePropsType } from 'next'; -import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; -import { StarmapsBreadcrumb } from '../../components/breadcrumb'; +import { Box } from '@chakra-ui/react' +import { none, State, useHookstate } from '@hookstate/core' +import type { InferGetServerSidePropsType } from 'next' +import { useRouter } from 'next/router' +import React, { useEffect, useState } from 'react' -import { ErrorNotificationDisplay } from '../../components/errors/ErrorNotificationDisplay'; -import PageHeader from '../../components/layout/PageHeader'; -import { RoadmapTabbedView } from '../../components/roadmap-grid/RoadmapTabbedView'; -import NewRoadmap from '../../components/roadmap/NewRoadmap'; -import { BASE_PROTOCOL } from '../../config/constants'; -import { setDateGranularity } from '../../hooks/useDateGranularity'; -import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState'; -import { setViewMode } from '../../hooks/useViewMode'; -import { assignCompletionRateToIssues } from '../../lib/calculateCompletionRate'; -import { DateGranularityState, RoadmapMode, ViewMode } from '../../lib/enums'; -import { findIssueDataByUrl } from '../../lib/findIssueDataByUrl'; -import { mergeStarMapsErrorGroups } from '../../lib/mergeStarMapsErrorGroups'; -import { paramsFromUrl } from '../../lib/paramsFromUrl'; +import { StarmapsBreadcrumb } from '../../components/breadcrumb' +import { ErrorNotificationDisplay } from '../../components/errors/ErrorNotificationDisplay' +import PageHeader from '../../components/layout/PageHeader' +import { IssueDataStateContext } from '../../components/roadmap/contexts' +import { RoadmapTabbedView } from '../../components/roadmap/RoadmapTabbedView' +import { BASE_PROTOCOL } from '../../config/constants' +import { setDateGranularity } from '../../hooks/useDateGranularity' +import { useGlobalLoadingState } from '../../hooks/useGlobalLoadingState' +import { setViewMode } from '../../hooks/useViewMode' +import { assignCompletionRateToIssues } from '../../lib/calculateCompletionRate' +import { DateGranularityState, ViewMode } from '../../lib/enums' +import { findIssueDataByUrl } from '../../lib/findIssueDataByUrl' +import { mergeStarMapsErrorGroups } from '../../lib/mergeStarMapsErrorGroups' +import { paramsFromUrl } from '../../lib/paramsFromUrl' import { IssueData, PendingChildApiResponse, @@ -30,7 +30,7 @@ import { RoadmapApiResponseSuccess, RoadmapServerSidePropsResult, StarMapsIssueErrorsGrouped -} from '../../lib/types'; +} from '../../lib/types' /** * From https://vercel.com/docs/concepts/edge-network/caching#stale-while-revalidate @@ -44,9 +44,10 @@ const fetchHeaders = { 'Cache-Control': 's-maxage=30, stale-while-revalidate=86400' // 30 second cache, hold for 24 hours, revalidate after 30 seconds } -export async function getServerSideProps(context): Promise { - const [_hostname, owner, repo, _, issue_number] = context.query.slug; - const { filter_group, mode, timeUnit }: QueryParameters = context.query; +export async function getServerSideProps (context): Promise { + // eslint-disable-next-line no-unused-vars + const [_hostname, owner, repo, _, issue_number] = context.query.slug + const { filter_group, timeUnit }: QueryParameters = context.query return { props: { @@ -55,54 +56,52 @@ export async function getServerSideProps(context): Promise) { - const { error: serverError, isLocal, mode, dateGranularity, issue_number, repo, owner } = props; +export default function RoadmapPage (props: InferGetServerSidePropsType) { + const { error: serverError, dateGranularity, issue_number, repo, owner } = props - const starMapsErrorsState = useHookstate([]); + const starMapsErrorsState = useHookstate([]) const roadmapLoadErrorState = useHookstate<{ code: string; message: string } | null>(null) - const issueDataState = useHookstate(null); + const issueDataState = useHookstate(null) const pendingChildrenState = useHookstate([]) const asyncIssueDataState = useHookstate([]) - const globalLoadingState = useGlobalLoadingState(); - const [isRootIssueLoading, setIsRootIssueLoading] = useState(false); - const [isPendingChildrenLoading, setIsPendingChildrenLoading] = useState(false); + const globalLoadingState = useGlobalLoadingState() + const [isRootIssueLoading, setIsRootIssueLoading] = useState(false) + const [isPendingChildrenLoading, setIsPendingChildrenLoading] = useState(false) useEffect(() => { - let active = true; - const controller = new AbortController(); - if (isRootIssueLoading || issue_number == null || repo == null || owner == null) return; - setIsRootIssueLoading(true); + let active = true + const controller = new AbortController() + if (isRootIssueLoading || issue_number == null || repo == null || owner == null) return + setIsRootIssueLoading(true) const fetchRoadMap = async () => { if (!active) { - return; + return } const roadmapApiUrl = `${window.location.origin}/api/roadmap?owner=${owner}&repo=${repo}&issue_number=${issue_number}` try { const apiResult = await fetch(new URL(roadmapApiUrl), { method: 'GET', signal: controller.signal, headers: fetchHeaders }) // console.log(`roadmap: ${owner}/${repo}/${issue_number} - x-vercel-cache: `, apiResult.headers.get('x-vercel-cache')) - const roadmapResponse: RoadmapApiResponse = await apiResult.json(); + const roadmapResponse: RoadmapApiResponse = await apiResult.json() - const roadmapResponseSuccess = roadmapResponse as RoadmapApiResponseSuccess; - const roadmapResponseFailure = roadmapResponse as RoadmapApiResponseFailure; + const roadmapResponseSuccess = roadmapResponse as RoadmapApiResponseSuccess + const roadmapResponseFailure = roadmapResponse as RoadmapApiResponseFailure if (roadmapResponse.errors != null) { - starMapsErrorsState.set(roadmapResponse.errors); + starMapsErrorsState.set(roadmapResponse.errors) } pendingChildrenState.set(() => roadmapResponseSuccess.pendingChildren) if (roadmapResponseFailure.error != null) { - roadmapLoadErrorState.set(roadmapResponseFailure.error); + roadmapLoadErrorState.set(roadmapResponseFailure.error) } else { - issueDataState.set(roadmapResponseSuccess.data); + issueDataState.set(roadmapResponseSuccess.data) } - } catch (err) { if (!(err as Error).toString().includes('AbortError')) { roadmapLoadErrorState.set({ @@ -111,33 +110,33 @@ export default function RoadmapPage(props: InferGetServerSidePropsType { - controller.abort(); - active = false; - setIsRootIssueLoading(false); - }; + controller.abort() + active = false + setIsRootIssueLoading(false) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issue_number, repo, owner]); + }, [issue_number, repo, owner]) useEffect(() => { - let active = true; - const controller = new AbortController(); - if (isPendingChildrenLoading) return; - const typedPendingChild = pendingChildrenState[0]; + let active = true + const controller = new AbortController() + if (isPendingChildrenLoading) return + const typedPendingChild = pendingChildrenState[0] if (typedPendingChild == null || typedPendingChild.html_url?.value == null) { - pendingChildrenState[0].set(none); + pendingChildrenState[0].set(none) return } const fetchPendingChildren = async () => { if (!active) { - return; + return } - setIsPendingChildrenLoading(true); + setIsPendingChildrenLoading(true) const parent = findIssueDataByUrl(issueDataState.value as IssueData, typedPendingChild.parentHtmlUrl.value) // we can reduce the size of the parent, because we have the parent on the client and use the one we have when adding the success response @@ -145,7 +144,7 @@ export default function RoadmapPage(props: InferGetServerSidePropsType mergeStarMapsErrorGroups(currentErrors, pendingChildSuccess.errors)) } @@ -176,46 +175,45 @@ export default function RoadmapPage(props: InferGetServerSidePropsType { - controller.abort(); - active = false; - setIsPendingChildrenLoading(false); - }; + controller.abort() + active = false + setIsPendingChildrenLoading(false) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issue_number, repo, owner, isRootIssueLoading, pendingChildrenState.length]); + }, [issue_number, repo, owner, isRootIssueLoading, pendingChildrenState.length]) /** * Add asyncIssueData items to issueDataState */ useEffect(() => { - const issueData = issueDataState.get({ noproxy: true }) as IssueData; - const asyncIssues = asyncIssueDataState.get(); - const newIssueData = asyncIssueDataState[0]; + const issueData = issueDataState.get({ noproxy: true }) as IssueData + const asyncIssues = asyncIssueDataState.get() + const newIssueData = asyncIssueDataState[0] if (asyncIssues.length === 0 || newIssueData == null) { - asyncIssueDataState[0].set(none); + asyncIssueDataState[0].set(none) return } try { - const parentIndex = issueData.children.findIndex((potentialParent) => potentialParent.html_url === newIssueData.parent.html_url.value); + const parentIndex = issueData.children.findIndex((potentialParent) => potentialParent.html_url === newIssueData.parent.html_url.value) if (parentIndex > -1) { - (issueDataState as State).children[parentIndex].children.merge([newIssueData.get({ noproxy: true })]); + (issueDataState as State).children[parentIndex].children.merge([newIssueData.get({ noproxy: true })]) } else { - throw new Error('Could not find parentIndex'); + throw new Error('Could not find parentIndex') } - } catch (err) { - console.log('getting parent - error', err); - console.log('getting parent - error - issueData', issueData); + console.log('getting parent - error', err) + console.log('getting parent - error - issueData', issueData) } asyncIssueDataState[0].set(none) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [asyncIssueDataState.value]); + }, [asyncIssueDataState.value]) /** * Resolve global loading after root issue and pending issues are done. @@ -223,39 +221,42 @@ export default function RoadmapPage(props: InferGetServerSidePropsType { if (!isRootIssueLoading && pendingChildrenState.length === 0 && asyncIssueDataState.length === 0) { assignCompletionRateToIssues(issueDataState) - globalLoadingState.stop(); + globalLoadingState.stop() } else { - globalLoadingState.start(); + globalLoadingState.start() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRootIssueLoading, pendingChildrenState.length, asyncIssueDataState.length]) useEffect(() => { - setDateGranularity(dateGranularity); - }, [dateGranularity]); + setDateGranularity(dateGranularity) + }, [dateGranularity]) - const router = useRouter(); + const router = useRouter() const urlPath = router.asPath useEffect(() => { - const hashString = urlPath.split('#')[1] as ViewMode ?? ViewMode.Simple; - setViewMode(hashString); - }, [urlPath]); + const hashString = urlPath.split('#')[1] + const hashParams = new URLSearchParams(hashString) + const viewModeHash = hashParams.get('view') + const viewMode = viewModeHash as ViewMode ?? ViewMode.Simple + + setViewMode(viewMode) + }, [urlPath]) return ( - <> + -
+
{issueDataState.ornull != null && } - + {!!serverError && {serverError.message}} {roadmapLoadErrorState.ornull && {roadmapLoadErrorState.ornull.message.value}} - {!!issueDataState.ornull && mode === 'd3' && } - {!!issueDataState.ornull && mode === 'grid' && ( - } /> + {!!issueDataState.ornull && ( + )}
- - ); + + ) } diff --git a/pages/style.css b/pages/style.css index 11ec414c..5607396e 100644 --- a/pages/style.css +++ b/pages/style.css @@ -1 +1 @@ -html, body {margin: 0; height: 100%; overflow: hidden} +html, body {margin: 0; height: 100%; } diff --git a/tests/unit/client/getClosest.test.ts b/tests/unit/client/getClosest.test.ts deleted file mode 100644 index 07d686a2..00000000 --- a/tests/unit/client/getClosest.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { dayjs } from '../../../lib/client/dayjs'; -import { getClosest } from '../../../lib/client/getClosest'; - - -function getTest(currentDateString, startingDateString, endingDateString, totalTimelineTicks, expectedIndex) { - it(`returns index ${expectedIndex} for ${currentDateString} between ${startingDateString} & ${endingDateString}`, function() { - const dates = [dayjs(startingDateString), dayjs(endingDateString)].map((v) => v.startOf('day').toDate()); - expect(getClosest({ - currentDate: dayjs(currentDateString).toDate(), - dates, - totalTimelineTicks, - })).toEqual(expectedIndex); - }) -} - -describe('getClosest', function() { - describe('10 day spread', function() { - describe('10 total timeline ticks', function() { - getTest('2021-01-01', '2021-01-01', '2021-01-10', 10, 0); - getTest('2021-01-02', '2021-01-01', '2021-01-10', 10, 1); - getTest('2021-01-03', '2021-01-01', '2021-01-10', 10, 2); - getTest('2021-01-04', '2021-01-01', '2021-01-10', 10, 3); - getTest('2021-01-05', '2021-01-01', '2021-01-10', 10, 4); - // TODO: WHERE IS 5!?! - getTest('2021-01-06', '2021-01-01', '2021-01-10', 10, 6); - getTest('2021-01-07', '2021-01-01', '2021-01-10', 10, 7); - getTest('2021-01-08', '2021-01-01', '2021-01-10', 10, 8); - getTest('2021-01-09', '2021-01-01', '2021-01-10', 10, 9); - getTest('2021-01-10', '2021-01-01', '2021-01-10', 10, 10); - }) - describe('20 total timeline ticks', function() { - getTest('2021-01-01', '2021-01-01', '2021-01-10', 20, 0); - getTest('2021-01-02', '2021-01-01', '2021-01-10', 20, 2); - getTest('2021-01-03', '2021-01-01', '2021-01-10', 20, 4); - getTest('2021-01-04', '2021-01-01', '2021-01-10', 20, 7); - getTest('2021-01-05', '2021-01-01', '2021-01-10', 20, 9); - // TODO: WHERE IS 10!?! - getTest('2021-01-06', '2021-01-01', '2021-01-10', 20, 11); - getTest('2021-01-07', '2021-01-01', '2021-01-10', 20, 13); - getTest('2021-01-08', '2021-01-01', '2021-01-10', 20, 16); - getTest('2021-01-09', '2021-01-01', '2021-01-10', 20, 18); - getTest('2021-01-10', '2021-01-01', '2021-01-10', 20, 20); - }) - }); - - describe('10 month spread', function() { - describe('10 total timeline ticks', function() { - getTest('2021-01-01', '2021-01-01', '2021-10-01', 10, 0); - getTest('2021-02-01', '2021-01-01', '2021-10-01', 10, 1); - getTest('2021-03-01', '2021-01-01', '2021-10-01', 10, 2); - getTest('2021-04-01', '2021-01-01', '2021-10-01', 10, 3); - getTest('2021-05-01', '2021-01-01', '2021-10-01', 10, 4); - // TODO: WHERE IS 5!?! - getTest('2021-06-01', '2021-01-01', '2021-10-01', 10, 6); - getTest('2021-07-01', '2021-01-01', '2021-10-01', 10, 7); - getTest('2021-08-01', '2021-01-01', '2021-10-01', 10, 8); - getTest('2021-09-01', '2021-01-01', '2021-10-01', 10, 9); - getTest('2021-10-01', '2021-01-01', '2021-10-01', 10, 10); - }); - - describe('20 total timeline ticks', function() { - getTest('2021-01-01', '2021-01-01', '2021-10-01', 20, 0); - getTest('2021-02-01', '2021-01-01', '2021-10-01', 20, 2); - getTest('2021-03-01', '2021-01-01', '2021-10-01', 20, 4); - getTest('2021-04-01', '2021-01-01', '2021-10-01', 20, 7); - getTest('2021-05-01', '2021-01-01', '2021-10-01', 20, 9); - getTest('2021-06-01', '2021-01-01', '2021-10-01', 20, 11); - getTest('2021-07-01', '2021-01-01', '2021-10-01', 20, 13); - getTest('2021-08-01', '2021-01-01', '2021-10-01', 20, 16); - getTest('2021-09-01', '2021-01-01', '2021-10-01', 20, 18); - getTest('2021-10-01', '2021-01-01', '2021-10-01', 20, 20); - }); - }); - - describe('10 quarter spread', function() { - const startDate = dayjs('2021-01-01') - const endDateString = dayjs('2021-01-01').add(10, 'quarters').format('YYYY-MM-DD') - describe('10 total timeline ticks', function() { - - getTest(startDate.add(0, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 0); - getTest(startDate.add(1, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 1); - getTest(startDate.add(2, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 2); - getTest(startDate.add(3, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 3); - getTest(startDate.add(4, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 4); - getTest(startDate.add(5, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 5); - getTest(startDate.add(6, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 6); - getTest(startDate.add(7, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 7); - getTest(startDate.add(8, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 8); - getTest(startDate.add(9, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 9); - getTest(startDate.add(10, 'quarter').format('YYYY-MM-DD'), '2021-01-01', endDateString, 10, 10); - }); - }); - -}) diff --git a/tests/unit/client/getLinkForRoadmapChild.test.ts b/tests/unit/client/getLinkForRoadmapChild.test.ts index 108b6404..3b79cb5b 100644 --- a/tests/unit/client/getLinkForRoadmapChild.test.ts +++ b/tests/unit/client/getLinkForRoadmapChild.test.ts @@ -56,7 +56,7 @@ describe('getLinkForRoadmapChild', function() { it('issueData has children and parent, with viewMode', () => { const expectedCrumbs = convertCrumbDataArraysToCrumbDataString([getCrumbDataArrayFromIssueData(parent)]); - const expectedLink = `/roadmap/github.com${new URL(child.html_url).pathname}?crumbs=${encodeURIComponent(expectedCrumbs)}#detail` + const expectedLink = `/roadmap/github.com${new URL(child.html_url).pathname}?crumbs=${encodeURIComponent(expectedCrumbs)}#view=detail` expect(getLinkForRoadmapChild({ issueData: child, viewMode: ViewMode.Detail })).toEqual(expectedLink); }); diff --git a/tests/unit/client/getQuantiles.test.ts b/tests/unit/client/getQuantiles.test.ts deleted file mode 100644 index 4d05ef80..00000000 --- a/tests/unit/client/getQuantiles.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { dayjs } from '../../../lib/client/dayjs' -import { getQuantiles, getQuantilesNew } from '../../../lib/client/getQuantiles' - -function datesAsStrings(dates: Date[]) { - return dates.map((v) => dayjs(v).format('YYYY-MM-DD')) -} - -/** - * The original getQuantiles function... it does some unexpected stuff. - */ -describe('getQuantiles', function() { - it('returns the expected number of dates', function() { - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 1)).toHaveLength(2) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 2)).toHaveLength(3) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 3)).toHaveLength(4) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 4)).toHaveLength(5) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 5)).toHaveLength(6) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 15)).toHaveLength(16) - expect(getQuantiles([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 30)).toHaveLength(31) - }) - - it('returns expected split including start and end dates', function() { - const actual = getQuantiles([dayjs('2021-01-01').startOf('day').toDate(), dayjs('2021-01-10').startOf('day').toDate()], 3) - - const actualAsStrings = datesAsStrings(actual) - expect(actualAsStrings).toHaveLength(4) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-01'])) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-03'])) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-06'])) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-09'])) - }) -}) - -describe('getQuantilesNew', function() { - it('returns the expected number of dates', function() { - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 1)).toHaveLength(1) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 2)).toHaveLength(2) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 3)).toHaveLength(3) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 4)).toHaveLength(4) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 5)).toHaveLength(5) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 15)).toHaveLength(15) - expect(getQuantilesNew([dayjs().startOf('year').toDate(), dayjs().endOf('year').toDate()], 30)).toHaveLength(30) - }) - - it('returns expected split including start and end dates', function() { - const actual = getQuantilesNew([dayjs('2021-01-01').startOf('day').toDate(), dayjs('2021-01-10').startOf('day').toDate()], 3) - - const actualAsStrings = datesAsStrings(actual) - expect(actualAsStrings).toHaveLength(3) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-01'])) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-05'])) - expect(actualAsStrings).toEqual(expect.arrayContaining(['2021-01-10'])) - }) -}) - diff --git a/tests/unit/client/getUniqIdForGroupedIssues.test.ts b/tests/unit/client/getUniqIdForGroupedIssues.test.ts deleted file mode 100644 index 6fe4d577..00000000 --- a/tests/unit/client/getUniqIdForGroupedIssues.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import getUniqIdForGroupedIssues from '../../../lib/client/getUniqIdForGroupedIssues' - -describe('getUniqIdForGroupedIssues', function() { - it('returns empty string for empty array', () => { - expect(getUniqIdForGroupedIssues([])).toEqual(''); - }); - it('Returns only groupName for group without children', () => { - expect(getUniqIdForGroupedIssues([{ groupName: 'test123', items: [], url: 'nothing' }])).toEqual('test123') - }); - it('Returns both groupNames for two groups without children', () => { - expect(getUniqIdForGroupedIssues([ - { groupName: 'test123', items: [], url: 'nothing' }, - { groupName: 'abc987', items: [], url: 'nothing2' }, - ])).toEqual('test123--abc987'); - }); - it('Respects sort order', () => { - expect(getUniqIdForGroupedIssues([ - { groupName: 'item1', items: [], url: 'nothing' }, - { groupName: 'item2', items: [], url: 'nothing2' }, - ])).toEqual('item1--item2'); - expect(getUniqIdForGroupedIssues([ - { groupName: 'item2', items: [], url: 'nothing2' }, - { groupName: 'item1', items: [], url: 'nothing' }, - ])).toEqual('item2--item1'); - }); - it('Returns children and groupName', () => { - expect(getUniqIdForGroupedIssues([ - { groupName: 'item1', items: [{ node_id: 'childA' }], url: 'nothing' }, - { groupName: 'item2', items: [], url: 'nothing2' }, - ])).toEqual('item1childA--item2'); - expect(getUniqIdForGroupedIssues([ - { groupName: 'item1', items: [{ node_id: 'child1A' }, { node_id: 'child1B' }], url: 'nothing' }, - { groupName: 'item2', items: [{ node_id: 'child2A' }], url: 'nothing2' }, - ])).toEqual('item1child1A-child1B--item2child2A'); - }) -}) diff --git a/tests/unit/getCrumbDataFromCrumbDataArray.test.ts b/tests/unit/getCrumbDataFromCrumbDataArray.test.ts index 58d1cf9f..fea33aeb 100644 --- a/tests/unit/getCrumbDataFromCrumbDataArray.test.ts +++ b/tests/unit/getCrumbDataFromCrumbDataArray.test.ts @@ -21,7 +21,7 @@ describe('getCrumbDataFromCrumbDataArray', function() { const resultingCrumbData = getCrumbDataFromCrumbDataArray(crumbDataArray, ViewMode.Detail); expect(resultingCrumbData).toEqual([{ title: 'Issue 1', - url: 'http://localhost/roadmap/github.com/owner/repo/issues/1#detail', + url: 'http://localhost/roadmap/github.com/owner/repo/issues/1#view=detail', }]); }); diff --git a/tests/unit/getCrumbDataFromCrumbString.test.ts b/tests/unit/getCrumbDataFromCrumbString.test.ts index 899a6a59..0d65d972 100644 --- a/tests/unit/getCrumbDataFromCrumbString.test.ts +++ b/tests/unit/getCrumbDataFromCrumbString.test.ts @@ -29,7 +29,7 @@ const testData: (Pick)[] = [ ]; const getExpectedUrlForTestData = (testIssue: typeof testData[number], parents: typeof testData[number][] = [], viewMode = ViewMode.Detail) => { - const url = new URL(`http://localhost/roadmap/${testIssue.html_url.replace('https://', '')}#${viewMode}`) + const url = new URL(`http://localhost/roadmap/${testIssue.html_url.replace('https://', '')}#view=${viewMode}`) if (parents.length > 0) { const expectedCrumbs = convertCrumbDataArraysToCrumbDataString(parents.map(getCrumbDataArrayFromIssueData)); url.searchParams.append('crumbs', expectedCrumbs); diff --git a/yarn.lock b/yarn.lock index 7732e739..d31ea314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2474,12 +2474,12 @@ "@next/swc-android-arm-eabi@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.3.tgz#1173a8e9ddb92c9d2d1a4fc29c5397f3d815c1ef" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.3.tgz#1173a8e9ddb92c9d2d1a4fc29c5397f3d815c1ef" integrity sha512-5O/ZIX6hlIRGMy1R2f/8WiCZ4Hp4WTC0FcTuz8ycQ28j/mzDnmzjVoayVVr+ZmfEKQayFrRu+vxHjFyY0JGQlQ== "@next/swc-android-arm64@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.3.3.tgz#8e49a1486ff1c5e6f4760ad31a2fa3cfcc5a3329" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.3.3.tgz#8e49a1486ff1c5e6f4760ad31a2fa3cfcc5a3329" integrity sha512-2QWreRmlxYRDtnLYn+BI8oukHwcP7W0zGIY5R2mEXRjI4ARqCLdu8RmcT9Vemw7RfeAVKA/4cv/9PY0pCcQpNA== "@next/swc-darwin-arm64@12.3.3": @@ -2489,52 +2489,52 @@ "@next/swc-darwin-x64@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.3.tgz#76a5a496cc7ead3cc02aaca84d1ed02dff86e029" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.3.tgz#76a5a496cc7ead3cc02aaca84d1ed02dff86e029" integrity sha512-gRYvTKrRYynjFQUDJ+upHMcBiNz0ii0m7zGgmUTlTSmrBWqVSzx79EHYT7Nn4GWHM+a/W+2VXfu+lqHcJeQ9gQ== "@next/swc-freebsd-x64@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.3.tgz#d546fb7060adf0cd27c6a8c1abca5c58f62c8f06" + resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.3.tgz#d546fb7060adf0cd27c6a8c1abca5c58f62c8f06" integrity sha512-r+GLATzCjjQI82bgrIPXWEYBwZonSO64OThk5wU6HduZlDYTEDxZsFNoNoesCDWCgRrgg+OXj7WLNy1WlvfX7w== "@next/swc-linux-arm-gnueabihf@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.3.tgz#525f451e6e1d134e064707c5c761b6d5d6bb3c7e" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.3.tgz#525f451e6e1d134e064707c5c761b6d5d6bb3c7e" integrity sha512-juvRj1QX9jmQScL4nV0rROtYUFgWP76zfdn1fdfZ2BhvwUugIAq8x+jLVGlnXKUhDrP9+RrAufqXjjVkK+uBxA== "@next/swc-linux-arm64-gnu@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.3.tgz#13aa5dfeef0de52eac1220ab22cabdee6447bb3a" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.3.tgz#13aa5dfeef0de52eac1220ab22cabdee6447bb3a" integrity sha512-hzinybStPB+SzS68hR5rzOngOH7Yd/jFuWGeg9qS5WifYXHpqwGH2BQeKpjVV0iJuyO9r309JKrRWMrbfhnuBA== "@next/swc-linux-arm64-musl@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.3.tgz#4bdae82882c1e31a1008f20f544b1954a21d385f" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.3.tgz#4bdae82882c1e31a1008f20f544b1954a21d385f" integrity sha512-oyfQYljCwf+9zUu1YkTZbRbyxmcHzvJPMGOxC3kJOReh3kCUoGcmvAxUPMtFD6FSYjJ+eaog4+2IFHtYuAw/bQ== "@next/swc-linux-x64-gnu@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.3.tgz#0697b1fc60dc4a86a7260f60e983d9064a331b2c" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.3.tgz#0697b1fc60dc4a86a7260f60e983d9064a331b2c" integrity sha512-epv4FMazj/XG70KTTnrZ0H1VtL6DeWOvyHLHYy7f5PdgDpBXpDTFjVqhP8NFNH8HmaDDdeL1NvQD07AXhyvUKA== "@next/swc-linux-x64-musl@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.3.tgz#af012e65035fcd7cc3855ce90b4095d2c7b879a5" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.3.tgz#af012e65035fcd7cc3855ce90b4095d2c7b879a5" integrity sha512-bG5QODFy59XnSFTiPyIAt+rbPdphtvQMibtOVvyjwIwsBUw7swJ6k+6PSPVYEYpi6SHzi3qYBsro39ayGJKQJg== "@next/swc-win32-arm64-msvc@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.3.tgz#bf717c96ce17f63840e3cbb023725cb3872ed0b3" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.3.tgz#bf717c96ce17f63840e3cbb023725cb3872ed0b3" integrity sha512-FbnT3reJ3MbTJ5W0hvlCCGGVDSpburzT5XGC9ljBJ4kr+85iNTLjv7+vrPeDdwHEqtGmdZgnabkLVCI4yFyCag== "@next/swc-win32-ia32-msvc@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.3.tgz#f434bc4bd952af77070868b5fa32ced85b52a646" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.3.tgz#f434bc4bd952af77070868b5fa32ced85b52a646" integrity sha512-M/fKZC2tMGWA6eTsIniNEBpx2prdR8lIxvSO3gv5P6ymZOGVWCvEMksnTkPAjHnU6d8r8eCiuGKm3UNo7zCTpQ== "@next/swc-win32-x64-msvc@12.3.3": version "12.3.3" - resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.3.tgz#1b412e9e15550680e1fdba70daa8b6ddcc75a035" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.3.tgz#1b412e9e15550680e1fdba70daa8b6ddcc75a035" integrity sha512-Ku9mfGwmNtk44o4B/jEWUxBAT4tJ3S7QbBMLJdL1GmtRZ05LGL36OqWjLvBPr8dFiHOQQbYoAmYfQw7zeGypYA== "@nodelib/fs.scandir@2.1.5": @@ -3419,6 +3419,11 @@ resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.189.tgz" integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA== +"@types/lodash@^4.14.172": + version "4.14.194" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76" + integrity sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g== + "@types/lru-cache@^5.1.0": version "5.1.1" resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz" @@ -3708,6 +3713,18 @@ "@typescript-eslint/types" "5.45.0" eslint-visitor-keys "^3.3.0" +"@visx/text@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@visx/text/-/text-3.0.0.tgz#9099c3605027b9ab4c54bde97518a648136c3629" + integrity sha512-LW6v5T/gpd9RGw83/ScXncYc6IlcfzXTpaN8WbbxLRI65gdvSqrykwAMR0cbpQmzoVFuZXljqOf0QslHGnBg1w== + dependencies: + "@types/lodash" "^4.14.172" + "@types/react" "*" + classnames "^2.3.1" + lodash "^4.17.21" + prop-types "^15.7.2" + reduce-css-calc "^1.3.0" + "@vue/compiler-core@3.2.45": version "3.2.45" resolved "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.45.tgz" @@ -3799,7 +3816,7 @@ acorn-walk@^8.0.2: resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz" integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== -acorn@^8.1.0, acorn@^8.8.0: +acorn@^8.1.0, acorn@^8.8.0, acorn@^8.8.1: version "8.8.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz" integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== @@ -4147,6 +4164,11 @@ bail@^2.0.0: resolved "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz" integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + integrity sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" @@ -4238,6 +4260,13 @@ builtin-modules@^3.1.0: resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz" integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== +builtins@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" + integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ== + dependencies: + semver "^7.0.0" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz" @@ -4267,9 +4296,9 @@ camelize@^1.0.0: integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001406: - version "1.0.30001431" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz" - integrity sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ== + version "1.0.30001481" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001481.tgz" + integrity sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ== ccount@^2.0.0: version "2.0.1" @@ -4345,6 +4374,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== +classnames@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz" @@ -4893,7 +4927,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -decimal.js@^10.4.1: +decimal.js@^10.4.1, decimal.js@^10.4.2: version "10.4.2" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.2.tgz" integrity sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA== @@ -5256,6 +5290,11 @@ eslint-config-prettier@>=8.0.0, eslint-config-prettier@^8.5.0: resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz" integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== +eslint-config-standard@^17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz#fd5b6cf1dcf6ba8d29f200c461de2e19069888cf" + integrity sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg== + eslint-import-resolver-node@^0.3.6: version "0.3.6" resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz" @@ -5295,6 +5334,14 @@ eslint-module-utils@^2.7.3: dependencies: debug "^3.2.7" +eslint-plugin-es@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz#f0822f0c18a535a97c3e714e89f88586a7641ec9" + integrity sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ== + dependencies: + eslint-utils "^2.0.0" + regexpp "^3.0.0" + eslint-plugin-escompat@^3.3.3: version "3.3.4" resolved "https://registry.npmjs.org/eslint-plugin-escompat/-/eslint-plugin-escompat-3.3.4.tgz" @@ -5385,6 +5432,20 @@ eslint-plugin-jsx-a11y@^6.5.1, eslint-plugin-jsx-a11y@^6.6.0, eslint-plugin-jsx- minimatch "^3.1.2" semver "^6.3.0" +eslint-plugin-n@^15.7.0: + version "15.7.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz#e29221d8f5174f84d18f2eb94765f2eeea033b90" + integrity sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q== + dependencies: + builtins "^5.0.1" + eslint-plugin-es "^4.1.0" + eslint-utils "^3.0.0" + ignore "^5.1.1" + is-core-module "^2.11.0" + minimatch "^3.1.2" + resolve "^1.22.1" + semver "^7.3.8" + eslint-plugin-no-only-tests@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.1.0.tgz" @@ -5409,6 +5470,11 @@ eslint-plugin-primer-react@^1.0.1: lodash "^4.17.21" styled-system "^5.1.5" +eslint-plugin-promise@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz#269a3e2772f62875661220631bd4dafcb4083816" + integrity sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig== + eslint-plugin-react-hooks@^4.5.0, eslint-plugin-react-hooks@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz" @@ -5460,6 +5526,13 @@ eslint-traverse@^1.0.0: resolved "https://registry.npmjs.org/eslint-traverse/-/eslint-traverse-1.0.0.tgz" integrity sha512-bSp37rQs93LF8rZ409EI369DGCI4tELbFVmFNxI6QbuveS7VRxYVyUhwDafKN/enMyUh88HQQ7ZoGUHtPuGdcw== +eslint-utils@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== + dependencies: + eslint-visitor-keys "^1.1.0" + eslint-utils@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz" @@ -5467,6 +5540,11 @@ eslint-utils@^3.0.0: dependencies: eslint-visitor-keys "^2.0.0" +eslint-visitor-keys@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + eslint-visitor-keys@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz" @@ -6112,6 +6190,11 @@ ignore@^5.0.5, ignore@^5.2.0: resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz" integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== +ignore@^5.1.1: + version "5.2.4" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" + integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== + import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" @@ -6214,6 +6297,13 @@ is-core-module@^2.10.0, is-core-module@^2.8.1, is-core-module@^2.9.0: dependencies: has "^1.0.3" +is-core-module@^2.11.0: + version "2.12.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4" + integrity sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ== + dependencies: + has "^1.0.3" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz" @@ -6847,7 +6937,39 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsdom@^20.0.0, jsdom@^20.0.1: +jsdom@^20.0.0: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" + integrity sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ== + dependencies: + abab "^2.0.6" + acorn "^8.8.1" + acorn-globals "^7.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.4.2" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.2" + parse5 "^7.1.1" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.11.0" + xml-name-validator "^4.0.0" + +jsdom@^20.0.1: version "20.0.2" resolved "https://registry.npmjs.org/jsdom/-/jsdom-20.0.2.tgz" integrity sha512-AHWa+QO/cgRg4N+DsmHg1Y7xnz+8KU3EflM0LVDTdmrYOc1WWTSkOjtpUveQH+1Bqd5rtcVnb/DuxV/UjDO4rA== @@ -7271,6 +7393,11 @@ markdown-table@^3.0.0: resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.2.tgz" integrity sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA== +math-expression-evaluator@^1.2.14: + version "1.4.0" + resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz#3d66031117fbb7b9715ea6c9c68c2cd2eebd37e2" + integrity sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw== + mdast-util-definitions@^5.0.0: version "5.1.1" resolved "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.1.tgz" @@ -8248,7 +8375,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8420,6 +8547,22 @@ react@^18.2.0: dependencies: loose-envify "^1.1.0" +reduce-css-calc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + integrity sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA== + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f" + integrity sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ== + dependencies: + balanced-match "^1.0.0" + regenerate-unicode-properties@^10.1.0: version "10.1.0" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz" @@ -8458,7 +8601,7 @@ regexp.prototype.flags@^1.4.3: define-properties "^1.1.3" functions-have-names "^1.2.2" -regexpp@^3.2.0: +regexpp@^3.0.0, regexpp@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== @@ -8562,6 +8705,15 @@ resolve@^1.12.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.2 path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.22.1: + version "1.22.2" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" + integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== + dependencies: + is-core-module "^2.11.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.3: version "2.0.0-next.4" resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz" @@ -8699,6 +8851,13 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.0.0, semver@^7.3.8: + version "7.5.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" + integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== + dependencies: + lru-cache "^6.0.0" + serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz" @@ -9467,6 +9626,13 @@ w3c-xmlserializer@^3.0.0: dependencies: xml-name-validator "^4.0.0" +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== + dependencies: + xml-name-validator "^4.0.0" + walker@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz" @@ -9753,7 +9919,7 @@ write-file-atomic@^4.0.1: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@^8.9.0: +ws@^8.11.0, ws@^8.9.0: version "8.11.0" resolved "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz" integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==