Skip to content

Commit

Permalink
feat: implement direct children list view (#341)
Browse files Browse the repository at this point in the history
* feat(listView): add tab

* feat(listView): extremely basic list view

* feat(listview): some basic styling

* feat(listview): bullet icons and connectorlines

* feat(listview): fix alignment and wrapping

* feat(listview): coloring and styles

* fix(listview): unstarted milestone bullets

* feat(listview): header alignment & linkification

* feat(listview): finishing touches on linkability and description rendering

* feat(listview): parse description from github body

* chore(listview): code cleanup

* chore(listview): date sort function cleanup

* chore(listview): remove circle progress label

* fix(listView): 🗑️ Fixing build

* chore: address PR comments

* chore: fix lint

---------

Co-authored-by: Nishant Arora <[email protected]>
  • Loading branch information
SgtPooki and whizzzkid authored Mar 22, 2023
1 parent 4acf5f1 commit 555e6b4
Show file tree
Hide file tree
Showing 21 changed files with 485 additions and 22 deletions.
12 changes: 12 additions & 0 deletions components/RoadmapList/BulletConnector.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.bulletConnector {
/**
* This top value must be set to the size of the .bulletIcon_Wrapper class in order to not appear underneath
* transparent colors
*/
top: 26px;
width: 2px;
bottom: 0;
/** Spotlight Blue */
background: var(--chakra-colors-spotLightBlue);
position: absolute;
}
15 changes: 15 additions & 0 deletions components/RoadmapList/BulletConnector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import styles from './BulletConnector.module.css';

/**
* The vertical line connecting each bulletIcon across all rows
* @returns
*/
export default function BulletConnector ({ isLast, children }: {isLast: boolean, children?: React.ReactNode}) {
if (isLast) {
return null
}
return (
<span className={styles.bulletConnector}>{children}</span>
)
}
51 changes: 51 additions & 0 deletions components/RoadmapList/BulletIcon.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.bulletIcon_Wrapper {
background: white;
height: 32px;
width: 32px;
border-radius: 100%;
flex-shrink: 0;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}

.bulletIcon_Wrapper.notStarted {
border: 6px solid var(--chakra-colors-inactiveAccent);
}

.bulletIcon_Wrapper.inProgress {
border: 6px solid var(--chakra-colors-progressGreenAccent);
}

.bulletIcon_Wrapper.completed {
border: 6px solid var(--chakra-colors-progressGreenAccent);
}

.bulletIcon {
height: 24px;
width: 24px;
border-radius: 100%;
flex-shrink: 0;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}

.bulletIcon.notStarted {
border: 1px solid var(--chakra-colors-inactive);
}

.bulletIcon.inProgress {
z-index: 1; /** Just to get rid of empty ruleset warning */
}

.bulletIcon.completed {
background: var(--chakra-colors-progressGreen);
border: 2px solid var(--chakra-colors-progressGreen);
}
29 changes: 29 additions & 0 deletions components/RoadmapList/BulletIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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}) {
const wrapperClassNames = [styles.bulletIcon_Wrapper]
const iconClassNames = [styles.bulletIcon]
let color = 'inactive'
if (completion_rate === 100) {
color = 'progressGreenAccent'
iconClassNames.push(styles.completed)
wrapperClassNames.push(styles.completed)
} else if (completion_rate > 0) {
iconClassNames.push(styles.inProgress)
wrapperClassNames.push(styles.inProgress)
color = 'progressGreen'
} else {
iconClassNames.push(styles.notStarted)
wrapperClassNames.push(styles.notStarted)
}

return (
<span className={wrapperClassNames.join(' ')}>
<span className={iconClassNames.join(' ')}>
<CircularProgress value={completion_rate} size='30px' trackColor="transparent" color={color} />
</span>
</span>
)
}
3 changes: 3 additions & 0 deletions components/RoadmapList/RoadmapList.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.roadmapList {
padding-top: 1vh;
}
101 changes: 101 additions & 0 deletions components/RoadmapList/RoadmapListItemDefault.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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 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';

interface RoadmapListItemDefaultProps {
issue: ListIssueViewModel
index: number
issues: ListIssueViewModel[]
}

function TitleText ({ hasChildren, issue }: Pick<RoadmapListItemDefaultProps, 'issue'> & {hasChildren: boolean}) {
return (
<Text fontWeight="semibold" color="linkBlue" fontSize={"xl"} lineHeight="32px">
{hasChildren ? <LinkIcon lineHeight="32px" boxSize="10px" /> : null} {issue.title}
</Text>
)
}

function TitleTextMaybeLink ({ issue, hasChildren, index, childLink }: Pick<RoadmapListItemDefaultProps, 'issue'> & {hasChildren: boolean, index: number, childLink: string}) {
const titleText = <TitleText hasChildren={hasChildren} issue={issue} />
if (!hasChildren) {
return titleText
}
return (
<NextLink key={`roadmapItem-${index}`} href={childLink} passHref>
<Link cursor="pointer" _hover={{ textDecoration: 'none' }}>
{titleText}
</Link>
</NextLink>
)
}

export default function RoadmapListItemDefault ({ issue, index, issues }: RoadmapListItemDefaultProps) {
const { owner, repo, issue_number } = paramsFromUrl(issue.html_url)
const childLink = getLinkForRoadmapChild({ issueData: issue, query: useRouter().query, viewMode: ViewMode.List })
const globalLoadingState = useGlobalLoadingState();
const hasChildren = childLink !== '#'
const issueDueDate = issue.due_date ? dayjs(issue.due_date).format('MMM D, YYYY') : 'unknown'

return (
<Grid width="100%" key={issue.html_url}
templateAreas={`"date icon title"
"empty line description"`}
gridTemplateRows={'1fr auto'}
gridTemplateColumns={'1fr 1fr 8fr'}
columnGap={0}
rowGap={0}>
<GridItem area="date" lineHeight="32px">
<Center>
<Skeleton isLoaded={!globalLoadingState.get()}>
<Text size="l" color="spotLightBlue" lineHeight="32px">{issueDueDate}</Text>
</Skeleton>
</Center>
</GridItem>
<GridItem area="icon" lineHeight="32px">
<Center>
<Skeleton isLoaded={!globalLoadingState.get()}>
<BulletIcon completion_rate={issue.completion_rate} />
</Skeleton>
</Center>
</GridItem>
<GridItem area="title">
<HStack gap={0} alignItems="flex-start">
<Link href={issue.html_url} lineHeight="32px" isExternal>
<Skeleton isLoaded={!globalLoadingState.get()}>
<HStack gap={0} alignItems="center" wrap={"nowrap"}>
<SvgGitHubLogo color="text" style={{ display:'inline', color: '#313239' }} fill="#313239" />
<Text color="text" style={{ whiteSpace: 'nowrap' }} fontSize="large">{owner}/{repo}#{issue_number}</Text>
</HStack>
</Skeleton>
</Link>
<Skeleton isLoaded={!globalLoadingState.get()}>
<TitleTextMaybeLink hasChildren={hasChildren} issue={issue} index={index} childLink={childLink} />
</Skeleton>
</HStack>
</GridItem>
<GridItem gridRow="1/-1" area="line" pos="relative">
<Center height="100%">
<BulletConnector isLast={index === issues.length - 1} />
</Center>
</GridItem>
<GridItem area="description" pb="2rem">
<Skeleton isLoaded={!globalLoadingState.get()}>
<Text fontSize="medium">{issue.description}</Text>
</Skeleton>
</GridItem>
</Grid>
)
}
78 changes: 78 additions & 0 deletions components/RoadmapList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Radio, RadioGroup, Stack, Text } from '@chakra-ui/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
}

/**
* Sorts milestones by due date, in ascending order (2022-01-01 before 2023-01-01) with invalid dates at the end.
* @param {ListIssueViewModel | IssueData} a
* @param {ListIssueViewModel | IssueData} b
* @returns
*/
function sortMilestones (a, b) {
// Get either a unix timestamp or Number.MAX_VALUE
const aTime = getTimeFromDateString(a.due_date, Number.MAX_VALUE)
const bTime = getTimeFromDateString(b.due_date, Number.MAX_VALUE)

// if a is valid and b is not, MAX_VALUE - validTime will be positive.
// if b is valid and a is not, MAX_VALUE - validTime will be negative.
// if both are valid, or invalid, result is 0.
return aTime - bTime
}

export default function RoadmapList({ issueDataState }: RoadmapListProps): JSX.Element {
const [groupBy, setGroupBy] = useState('directChildren')

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 }))
const sortedIssuesWithDueDates = flattenedIssues.sort(sortMilestones)

let groupByToggle: JSX.Element | null = null
if (isDevModeGroupBy) {
groupByToggle = (
<RadioGroup onChange={setGroupBy} value={groupBy}>
<Stack direction='row'>
<Text>Group By:</Text>
<Radio value='directChildren'>Current Children</Radio>
<Radio value='parent'>Parent/Child</Radio>
<Radio value='month'>Month</Radio>
<Radio value='none'>None (flat)</Radio>
</Stack>
</RadioGroup>
)
}

let dupeDateToggle: JSX.Element | null = null
if (isDevModeDuplicateDates) {
dupeDateToggle = (
<RadioGroup onChange={setDupeDateToggleValue} value={dupeDateToggleValue}>
<Stack direction='row'>
<Text>Duplicate Dates:</Text>
<Radio value='hide'>Hide</Radio>
<Radio value='show'>Show</Radio>
</Stack>
</RadioGroup>
)
}

return (
<>
{groupByToggle}
{dupeDateToggle}
<div className={styles.roadmapList}>
{sortedIssuesWithDueDates.map((issue, index) => (
<RoadmapListItemDefault key={index} issue={issue} index={index} issues={sortedIssuesWithDueDates} />
))}
</div>
</>
)
}
7 changes: 7 additions & 0 deletions components/RoadmapList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ImmutableObject } from '@hookstate/core';
import { IssueData } from '../../lib/types';

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ListIssueViewModel extends Pick<ImmutableObject<IssueData>, 'html_url' | 'title' | 'completion_rate' | 'due_date' | 'description' | 'children' | 'parent'> {

}
2 changes: 1 addition & 1 deletion components/errors/ErrorNotificationDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function ErrorNotificationDisplay ({ errorsState, issueDataState }: Error
return [];
}
const errors = errorsState.ornull.value;
if (viewMode != null && issueDataState.ornull != null) {
if (viewMode != null && issueDataState.ornull != null && errorFilters[viewMode] != null) {
return errorFilters[viewMode](errors, issueDataState.ornull.value)
}
return errors;
Expand Down
6 changes: 2 additions & 4 deletions components/icons/svgr/SvgGitHubLogo.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import * as React from 'react';

const SvgGitHubLogo = (props) => (<div>

const SvgGitHubLogo = (props) => (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32.58 31.77' height="18px" width="18px" {...props}>
<g data-name='Layer 2'>
<path
fill='#A3A3A3'
fill={props.fill ?? '#A3A3A3'}
fillRule='evenodd'
d='M16.29 0a16.29 16.29 0 0 0-5.15 31.75c.82.15 1.11-.36 1.11-.79v-2.77C7.7 29.18 6.74 26 6.74 26a4.36 4.36 0 0 0-1.81-2.39c-1.47-1 .12-1 .12-1a3.43 3.43 0 0 1 2.49 1.68 3.48 3.48 0 0 0 4.74 1.36 3.46 3.46 0 0 1 1-2.18c-3.62-.41-7.42-1.81-7.42-8a6.3 6.3 0 0 1 1.67-4.37 5.94 5.94 0 0 1 .16-4.31s1.37-.44 4.48 1.67a15.41 15.41 0 0 1 8.16 0c3.11-2.11 4.47-1.67 4.47-1.67a5.91 5.91 0 0 1 .2 4.28 6.3 6.3 0 0 1 1.67 4.37c0 6.26-3.81 7.63-7.44 8a3.85 3.85 0 0 1 1.11 3v4.47c0 .53.29.94 1.12.78A16.29 16.29 0 0 0 16.29 0Z'
data-name='Layer 1'
/>
</g>
</svg>
</div>
);
export default SvgGitHubLogo;
18 changes: 18 additions & 0 deletions components/icons/svgr/SvgListViewIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';

const SvgDetailViewIcon = () => (
/**
* From https://www.iconfinder.com/icons/326721/list_view_icon#svg
*/
<svg width="19" height="14" viewBox="0 0 19 14" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g fill="none" fill-rule="evenodd" id="Page-1" stroke="#fff" stroke-width="1">
<g fill="#000" id="Core" transform="translate(-87.000000, -509.000000)">
<g id="view-list" transform="translate(87.500000, 509.000000)">
<path d="M0,9 L4,9 L4,5 L0,5 L0,9 L0,9 Z M0,14 L4,14 L4,10 L0,10 L0,14 L0,14 Z M0,4 L4,4 L4,0 L0,0 L0,4 L0,4 Z M5,9 L17,9 L17,5 L5,5 L5,9 L5,9 Z M5,14 L17,14 L17,10 L5,10 L5,14 L5,14 Z M5,0 L5,4 L17,4 L17,0 L5,0 L5,0 Z" id="Shape"/>
</g>
</g>
</g>
</svg>
);

export default SvgDetailViewIcon;
Loading

1 comment on commit 555e6b4

@vercel
Copy link

@vercel vercel bot commented on 555e6b4 Mar 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.