| import React, { ReactNode, useRef } from "react"; |
| import { Transition, Popover } from "@headlessui/react"; |
| import classNames from "classnames"; |
| import { |
| formatDistance, |
| differenceInDays, |
| differenceInHours, |
| format, |
| isAfter, |
| } from "date-fns"; |
| |
| import { |
| GithubIssueListItem, |
| GithubIssueListParams, |
| GithubIssueListStatus, |
| useGithubIssueList, |
| GithubIssueList as GithubIssueListData, |
| GithubIssueListSort, |
| useGithubIssueListActionOwner, |
| GithubIssueListResult, |
| useGithubIssueListLabel, |
| } from "./data/GithubIssueList"; |
| import { useGithubRepo } from "./data/GithubRepo"; |
| |
| const LABEL_P0 = "P0"; |
| const LABEL_P1 = "P1"; |
| const LABEL_P2 = "P2"; |
| const LABEL_TYPE_BUG = "type: bug"; |
| |
| function Status(props: { |
| name: string; |
| active?: boolean; |
| count?: GithubIssueListResult; |
| changeStatus: () => void; |
| }) { |
| const active = props.active; |
| const result = props.count; |
| const count = |
| (result && |
| !result.loading && |
| !result.error && |
| result.data && |
| result.data.total) || |
| 0; |
| return ( |
| <a |
| className={classNames( |
| "cursor-pointer text-base relative hover:text-black flex-shrink-0", |
| active ? "text-black" : "text-gray-600", |
| { |
| "font-medium": active, |
| } |
| )} |
| onClick={() => props.changeStatus()} |
| > |
| {props.name} |
| {count > 0 && ( |
| <span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-100 transform translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full"> |
| {count > 99 ? "99+" : count} |
| </span> |
| )} |
| </a> |
| ); |
| } |
| |
| function colorHexToRGB(hex: string) { |
| const r = parseInt(hex.substring(1, 3), 16); |
| const g = parseInt(hex.substring(3, 5), 16); |
| const b = parseInt(hex.substring(5, 7), 16); |
| return { r, g, b }; |
| } |
| |
| function Label({ |
| name, |
| colorHex, |
| description, |
| onClick, |
| }: { |
| name: string; |
| colorHex: string; |
| description: string; |
| onClick: () => void; |
| }) { |
| const color = colorHexToRGB(colorHex); |
| return ( |
| <a |
| className="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full issue-label cursor-pointer" |
| onClick={onClick} |
| style={ |
| { |
| "--label-r": color.r, |
| "--label-g": color.g, |
| "--label-b": color.b, |
| } as any |
| } |
| title={description} |
| > |
| {name} |
| </a> |
| ); |
| } |
| |
| function userLink(login: string) { |
| return `https://github.com/${login}`; |
| } |
| |
| function dateDistance(date: Date, base: Date) { |
| const diff = Math.abs(differenceInDays(base, date)); |
| if (diff > 356) { |
| return "on " + format(date, "MMM d"); |
| } else if (diff > 30) { |
| return "on " + format(date, "MMM d"); |
| } else { |
| return formatDistance(date, base, { addSuffix: true }); |
| } |
| } |
| |
| function dayText(diff: number) { |
| diff = Math.abs(diff); |
| if (diff == 0) { |
| return "less than 1 day"; |
| } else if (diff == 1) { |
| return "1 day"; |
| } else { |
| return `${diff} days`; |
| } |
| } |
| |
| function SLOStatus(props: { expectedRespondAt: string; updatedAt: string }) { |
| const now = new Date(); |
| const expectedRespondAt = new Date(props.expectedRespondAt); |
| |
| const diffInDays = differenceInDays(expectedRespondAt, now); |
| const days = dayText(diffInDays); |
| const overdue = isAfter(now, expectedRespondAt); |
| let colorBg = "#ecd5d5"; |
| let colorFg = "#de3b3b"; |
| let percentage = 0.0; |
| |
| let suffix; |
| if (overdue) { |
| suffix = " overdue"; |
| } else { |
| suffix = " to respond"; |
| const total = differenceInHours( |
| expectedRespondAt, |
| new Date(props.updatedAt) |
| ); |
| if (total > 0) { |
| percentage = differenceInHours(expectedRespondAt, now) / total; |
| if (percentage > 0.7) { |
| colorBg = "#ddecdb"; |
| colorFg = "#92cb8a"; |
| } else if (percentage > 0.3) { |
| colorBg = "#e2e0d3"; |
| colorFg = "#cfc50f"; |
| } |
| } |
| } |
| |
| return ( |
| <div className="flex flex-col w-full"> |
| <span className="mr-1 text-center text-sm"> |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| className="h-5 w-5 inline-block mr-1 text-red-600" |
| style={{ color: colorFg }} |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" |
| /> |
| </svg> |
| {days} |
| {suffix} |
| </span> |
| |
| {percentage > 0.0 && ( |
| <div |
| className="overflow-hidden h-1 text-xs flex rounded flex-auto my-2 mx-4" |
| style={{ |
| backgroundColor: colorBg, |
| }} |
| > |
| <div |
| style={{ |
| width: `${Math.round(percentage * 100)}%`, |
| backgroundColor: colorFg, |
| }} |
| className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center" |
| /> |
| </div> |
| )} |
| </div> |
| ); |
| } |
| |
| function ListItem(props: { |
| item: GithubIssueListItem; |
| changeActionOwner: (actionOwner: string) => void; |
| filterByLabel: (label: string) => void; |
| }) { |
| const item = props.item; |
| const issueLink = `https://github.com/${item.owner}/${item.repo}/issues/${item.issueNumber}`; |
| return ( |
| <div className="flex flex-row"> |
| {!item.data.pull_request ? ( |
| <svg |
| className="flex-shrink-0 w-4 h-4 text-green-700 ml-4 mt-3" |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 16 16" |
| fill="currentColor" |
| > |
| <path |
| fillRule="evenodd" |
| d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z" |
| /> |
| </svg> |
| ) : ( |
| <svg |
| className="flex-shrink-0 w-4 h-4 text-green-700 ml-4 mt-3" |
| viewBox="0 0 16 16" |
| xmlns="http://www.w3.org/2000/svg" |
| fill="currentColor" |
| > |
| <path |
| fillRule="evenodd" |
| d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z" |
| /> |
| </svg> |
| )} |
| |
| <div className="flex-auto flex flex-col space-y-1 p-2"> |
| <div className="flex flex-row space-x-1 space-y-1 flex-wrap"> |
| <a |
| className="text-base font-medium hover:text-blue-github break-all" |
| target="_blank" |
| href={issueLink} |
| > |
| {item.data.title} |
| </a> |
| {item.data.labels.map((label) => ( |
| <Label |
| key={label.id} |
| name={label.name} |
| colorHex={`#${label.color}`} |
| description={label.description} |
| onClick={() => props.filterByLabel(label.name)} |
| /> |
| ))} |
| </div> |
| <div className="flex flex-row text-gray-700 text-sm space-x-4"> |
| <span> |
| <a |
| href={issueLink} |
| target="_blank" |
| className="hover:text-blue-github" |
| >{`#${item.issueNumber}`}</a> |
| {" opened "} |
| {dateDistance(new Date(item.data.created_at), new Date())} |
| {" by "} |
| <a |
| target="_blank" |
| href={userLink(item.data.user.login)} |
| className="hover:text-blue-github" |
| > |
| {item.data.user.login} |
| </a> |
| {item.data.updated_at != item.data.created_at && ( |
| <span> |
| {", updated "} |
| {dateDistance(new Date(item.data.updated_at), new Date())} |
| </span> |
| )} |
| </span> |
| </div> |
| </div> |
| <div className="flex flex-shrink-0 w-4/12 flex-row space-x-2"> |
| <div className="flex-1 mt-4"> |
| <div className="flex flex-row -space-x-4 hover:space-x-1 justify-center"> |
| {item.data.assignees.map((assignee) => ( |
| <a |
| key={assignee.login} |
| title={`Assigned to ${assignee.login}`} |
| className="transition-all" |
| href={userLink(assignee.login)} |
| target="_blank" |
| > |
| <img |
| className="inline object-cover w-6 h-6 rounded-full" |
| src={assignee.avatar_url} |
| alt={`@${assignee.login}`} |
| /> |
| </a> |
| ))} |
| </div> |
| </div> |
| |
| <div className="flex-1 flex flex-row mt-4 justify-center"> |
| {item.actionOwner && ( |
| <a |
| className="cursor-pointer hover:font-bold" |
| onClick={() => props.changeActionOwner(item.actionOwner!)} |
| > |
| {item.actionOwner} |
| </a> |
| )} |
| </div> |
| |
| <div className="flex-1 flex flex-col mt-4 items-center"> |
| {item.expectedRespondAt && ( |
| <SLOStatus |
| expectedRespondAt={item.expectedRespondAt} |
| updatedAt={item.data.updated_at} |
| /> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |
| function defaultGithubIssueListParams(): GithubIssueListParams { |
| return { |
| status: "TO_BE_REVIEWED", |
| }; |
| } |
| |
| function Loading() { |
| return ( |
| <svg |
| className="animate-spin h-8 w-8 text-gray-700" |
| xmlns="http://www.w3.org/2000/svg" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| className="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| strokeWidth="4" |
| /> |
| <path |
| className="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| /> |
| </svg> |
| ); |
| } |
| |
| function GithubIssueListBody(props: { |
| data?: GithubIssueListData; |
| loading: boolean; |
| error?: any; |
| changeActionOwner: (actionOwner: string) => void; |
| filterByLabel: (label: string) => void; |
| }) { |
| const data = props.data; |
| |
| if (props.loading) { |
| return ( |
| <div className="flex justify-center py-8"> |
| <Loading /> |
| </div> |
| ); |
| } |
| |
| if (props.error || !data || !data.items) { |
| return <div className="p-4">Error</div>; |
| } |
| |
| if (data.items.length == 0) { |
| return ( |
| <div className="flex flex-col items-center p-12"> |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| className="h-8 w-8 text-gray-300" |
| viewBox="0 0 24 24" |
| fill="currentColor" |
| > |
| <path d="M12 7a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0112 7zm1 9a1 1 0 11-2 0 1 1 0 012 0z" /> |
| <path |
| fillRule="evenodd" |
| d="M12 1C5.925 1 1 5.925 1 12s4.925 11 11 11 11-4.925 11-11S18.075 1 12 1zM2.5 12a9.5 9.5 0 1119 0 9.5 9.5 0 01-19 0z" |
| /> |
| </svg> |
| <div className="text-xl font-medium mt-4">No results found.</div> |
| </div> |
| ); |
| } |
| |
| return ( |
| <div className="flex flex-col"> |
| {data.items.map((item) => ( |
| <div key={item.data.id} className="border-t hover:bg-gray-100"> |
| <ListItem |
| item={item} |
| changeActionOwner={props.changeActionOwner} |
| filterByLabel={props.filterByLabel} |
| /> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
| |
| function GithubIssueListFooter(props: { |
| data: GithubIssueListData; |
| goToPage: (page: number) => void; |
| changePageSize: (pageSize: number) => void; |
| }) { |
| const buttonRef = useRef<HTMLButtonElement>(null); |
| const data = props.data; |
| const page = data.page; |
| const total = data.total; |
| const pageSize = data.pageSize; |
| const lastPage = Math.ceil(total / pageSize); |
| |
| const start = 1 + pageSize * (page - 1); |
| const end = Math.min(total, start + pageSize - 1); |
| |
| if (start > total) { |
| return null; |
| } |
| |
| return ( |
| <div className="flex flex-row-reverse mt-2"> |
| <div className="flex flex-row items-center"> |
| <div className="mr-8 flex flex-row"> |
| <span>Rows per page: </span> |
| <Popover className="relative"> |
| {({ open }) => ( |
| <> |
| <Popover.Button |
| ref={buttonRef} |
| className="ml-1 flex flex-row items-center cursor-pointer relative focus:outline-none" |
| > |
| <span>{pageSize}</span> |
| <span className="ml-1"> |
| <DownIcon /> |
| </span> |
| </Popover.Button> |
| |
| <Transition |
| show={open} |
| enter="transition duration-100 ease-out" |
| enterFrom="transform translate-y-10 opacity-0" |
| enterTo="transform translate-y-0 opacity-100" |
| leave="transition duration-100 ease-out" |
| leaveFrom="transform opacity-100" |
| leaveTo="transform opacity-0" |
| > |
| <Popover.Panel className="absolute bg-white right-0 bottom-8 border shadow rounded bg-white z-popup flex flex-col mt-2"> |
| <ul className="flex flex-col text-center cursor-pointer"> |
| {[10, 25, 50, 100].map((pageSize) => ( |
| <li |
| key={pageSize} |
| className="py-1 px-2 border-b hover:bg-gray-100" |
| onClick={() => { |
| if (buttonRef.current) { |
| buttonRef.current.click(); |
| } |
| props.changePageSize(pageSize); |
| }} |
| > |
| {pageSize} |
| </li> |
| ))} |
| </ul> |
| </Popover.Panel> |
| </Transition> |
| </> |
| )} |
| </Popover> |
| </div> |
| |
| <span className="mr-4"> |
| {start}-{end} of {total} |
| </span> |
| |
| <button |
| className="h-8 px-1 rounded-lg ring-gray-300 hover:ring-1 focus:outline-none disabled:text-gray-400 mr-2" |
| disabled={page == 1} |
| onClick={() => props.goToPage(page - 1)} |
| > |
| <svg |
| className="w-6 h-6" |
| focusable="false" |
| viewBox="0 0 24 24" |
| aria-hidden="true" |
| fill="currentColor" |
| > |
| <path d="M15.41 16.09l-4.58-4.59 4.58-4.59L14 5.5l-6 6 6 6z" /> |
| </svg> |
| </button> |
| |
| <button |
| className="h-8 px-1 rounded-lg ring-gray-300 hover:ring-1 focus:outline-none disabled:text-gray-400" |
| disabled={page == lastPage} |
| onClick={() => props.goToPage(page + 1)} |
| > |
| <svg |
| className="w-6 h-6" |
| focusable="false" |
| viewBox="0 0 24 24" |
| aria-hidden="true" |
| fill="currentColor" |
| > |
| <path d="M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| ); |
| } |
| |
| function DownIcon() { |
| return ( |
| <svg |
| className="w-3 h-3" |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 1024 1024" |
| fill="currentColor" |
| > |
| <path d="M163.446154 275.692308h697.107692c19.692308 0 33.476923 25.6 17.723077 43.323077L537.6 736.492308c-11.815385 15.753846-37.415385 15.753846-49.230769 0L143.753846 319.015385c-13.784615-17.723077-1.969231-43.323077 19.692308-43.323077z" /> |
| </svg> |
| ); |
| } |
| |
| function ActionOwnerFilterBody( |
| props: ActionOwnerFilterProps & { onClose: () => void } |
| ) { |
| const newParams = { ...props.params }; |
| newParams.actionOwner = undefined; |
| const result = useGithubIssueListActionOwner(newParams); |
| |
| const { data, loading, error } = result; |
| if (loading || error) { |
| return ( |
| <div className="flex flex-row justify-center p-4"> |
| <Loading /> |
| </div> |
| ); |
| } |
| |
| if (!data || data.length == 0) { |
| return <div className="px-5 py-2 border-b">No results found.</div>; |
| } |
| |
| return ( |
| <div className="max-h-[300px] overflow-y-auto"> |
| {data.map((owner) => ( |
| <div |
| key={owner} |
| className="p-2 border-b flex flex-row space-x-2 items-center hover:bg-gray-100 cursor-pointer" |
| onClick={() => { |
| props.changeActionOwner(owner); |
| props.onClose(); |
| }} |
| > |
| <Check active={owner === props.activeOwner} /> |
| |
| <span |
| className={classNames("text-base", { |
| "font-bold": owner === props.activeOwner, |
| })} |
| > |
| {owner} |
| </span> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
| |
| export interface ActionOwnerFilterProps { |
| changeActionOwner: (actionOwner: string) => void; |
| activeOwner?: string; |
| params?: GithubIssueListParams; |
| } |
| |
| function FilterPopoverTextButton({ name }: { name: string }) { |
| return ( |
| <a className="flex flex-row items-center space-x-1 text-gray-600 hover:text-black cursor-pointer select-none"> |
| <span className="text-base font-medium">{name}</span> |
| <DownIcon /> |
| </a> |
| ); |
| } |
| |
| function FilterPopover(props: { |
| title: string; |
| button: ReactNode; |
| children: (props: { close: () => void }) => ReactNode; |
| left?: boolean; |
| }) { |
| const buttonRef = useRef<HTMLButtonElement>(null); |
| const close = () => { |
| if (buttonRef.current) { |
| buttonRef.current.click(); |
| } |
| }; |
| |
| return ( |
| <Popover className="relative"> |
| {({ open }) => ( |
| <> |
| <Popover.Button ref={buttonRef} className="focus:outline-none"> |
| {props.button} |
| </Popover.Button> |
| |
| <div |
| className={classNames("absolute z-popup mt-2", { |
| "right-0": !props.left, |
| "left-0": props.left, |
| })} |
| > |
| <Transition |
| show={open} |
| enter="transition-transform duration-100 ease-out" |
| enterFrom="transform -translate-y-2 opacity-0" |
| enterTo="transform translate-y-0 opacity-100" |
| leave="transition duration-100 ease-out" |
| leaveFrom="transform opacity-100" |
| leaveTo="transform opacity-0" |
| > |
| <Popover.Panel className="border shadow rounded bg-white flex flex-col"> |
| <div className="w-[300px]"> |
| <div className="flex flex-row justify-between items-center border-b px-2"> |
| <span className="p-2 text-base font-bold"> |
| {props.title} |
| </span> |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| className="h-4 w-4 cursor-pointer" |
| onClick={close} |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M6 18L18 6M6 6l12 12" |
| /> |
| </svg> |
| </div> |
| {props.children({ close })} |
| </div> |
| </Popover.Panel> |
| </Transition> |
| </div> |
| </> |
| )} |
| </Popover> |
| ); |
| } |
| |
| function Check({ active }: { active?: boolean }) { |
| if (!active) { |
| return <div className="w-5 h-5" />; |
| } |
| |
| return ( |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| className="h-5 w-5" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M5 13l4 4L19 7" |
| /> |
| </svg> |
| ); |
| } |
| |
| function ActionOwnerFilter(props: ActionOwnerFilterProps) { |
| return ( |
| <FilterPopover |
| title="Filter by action owner" |
| button={<FilterPopoverTextButton name="Owner" />} |
| > |
| {({ close }) => <ActionOwnerFilterBody {...props} onClose={close} />} |
| </FilterPopover> |
| ); |
| } |
| |
| function SortFilter(props: { |
| changeSort: (sort: GithubIssueListSort) => void; |
| activeSort?: GithubIssueListSort; |
| }) { |
| return ( |
| <FilterPopover |
| title="Sort by" |
| button={<FilterPopoverTextButton name="Sort" />} |
| > |
| {({ close }) => { |
| return [ |
| { name: "Most urgent", key: "EXPECTED_RESPOND_AT_ASC" }, |
| { name: "Newest", key: "EXPECTED_RESPOND_AT_DESC" }, |
| ].map((sort) => ( |
| <div |
| key={sort.key} |
| className="p-2 border-b flex flex-row space-x-2 items-center hover:bg-gray-100 cursor-pointer w-[300px]" |
| onClick={() => { |
| close(); |
| props.changeSort(sort.key as any); |
| }} |
| > |
| <Check active={sort.key == props.activeSort} /> |
| |
| <span |
| className={classNames("text-base", { |
| "font-bold": sort.key == props.activeSort, |
| })} |
| > |
| {sort.name} |
| </span> |
| </div> |
| )); |
| }} |
| </FilterPopover> |
| ); |
| } |
| |
| function FilterLabel(props: { name: string; onClear: () => void }) { |
| return ( |
| <a className="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full bg-gray-300 space-x-1 flex-shrink-0"> |
| <span className="text-s">{props.name}</span> |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| className="h-3 w-3 cursor-pointer" |
| onClick={() => props.onClear()} |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M6 18L18 6M6 6l12 12" |
| /> |
| </svg> |
| </a> |
| ); |
| } |
| |
| interface Repo { |
| owner: string; |
| repo: string; |
| } |
| |
| function RepoFilterBody(props: RepoFilterProps & { close: () => void }) { |
| const { data, loading, error } = useGithubRepo(); |
| |
| if (loading || error) { |
| return ( |
| <div className="flex flex-row justify-center p-4"> |
| <Loading /> |
| </div> |
| ); |
| } |
| |
| const activeRepo = props.activeRepo; |
| const isActive = (repo: Repo) => { |
| if (!activeRepo) { |
| return false; |
| } |
| return repo.owner === activeRepo.owner && repo.repo === activeRepo.repo; |
| }; |
| |
| return ( |
| <ul className="max-h-[300px] overflow-y-auto cursor-pointer"> |
| {data.map((repo) => ( |
| <li |
| key={`${repo.owner}/${repo.repo}`} |
| className="py-2 px-4 text-base flex flex-row space-x-2 hover:bg-gray-100 border-b" |
| onClick={() => { |
| props.close(); |
| props.changeRepo(repo); |
| }} |
| > |
| <Check active={isActive(repo)} /> |
| <span |
| className={classNames({ "font-medium": isActive(repo) })} |
| >{`${repo.owner}/${repo.repo}`}</span> |
| </li> |
| ))} |
| </ul> |
| ); |
| } |
| |
| interface RepoFilterProps { |
| activeRepo?: Repo; |
| changeRepo: (repo: Repo) => void; |
| } |
| |
| function RepoFilter(props: RepoFilterProps) { |
| return ( |
| <FilterPopover |
| title="Filter by Repo" |
| button={ |
| <div className="flex flex-row items-center py-1.5 px-4 bg-gray-50 hover:bg-gray-100 border-r-2 rounded-l-lg"> |
| <a className="flex flex-row items-center space-x-1 select-none"> |
| <span className="text-base font-medium">Repo</span> |
| <DownIcon /> |
| </a> |
| </div> |
| } |
| left |
| > |
| {({ close }) => <RepoFilterBody {...props} close={close} />} |
| </FilterPopover> |
| ); |
| } |
| |
| function LabelFilterBody(props: LabelFilterProps & { close: () => void }) { |
| const { data, loading, error } = useGithubIssueListLabel(props.params); |
| if (loading || error) { |
| return ( |
| <div className="flex flex-row justify-center p-4"> |
| <Loading /> |
| </div> |
| ); |
| } |
| |
| const isActive = (label: string) => { |
| return labelContains(props.params?.extraLabels, label); |
| }; |
| |
| return ( |
| <ul className="max-h-[300px] overflow-y-auto cursor-pointer"> |
| {data.map((label) => ( |
| <li |
| key={label} |
| className="py-2 px-4 text-base flex flex-row space-x-2 hover:bg-gray-100 border-b" |
| onClick={() => { |
| props.close(); |
| props.filterByLabel(label); |
| }} |
| > |
| <Check active={isActive(label)} /> |
| <span className={classNames({ "font-medium": isActive(label) })}> |
| {label} |
| </span> |
| </li> |
| ))} |
| </ul> |
| ); |
| } |
| |
| interface LabelFilterProps { |
| params?: GithubIssueListParams; |
| filterByLabel: (label: string) => void; |
| } |
| |
| function LabelFilter(props: LabelFilterProps) { |
| return ( |
| <FilterPopover |
| title="Filter by Label" |
| button={ |
| <div className="flex flex-row items-center py-1.5 px-4 bg-gray-50 rounded-lg hover:bg-gray-100"> |
| <a className="flex flex-row items-center space-x-1 select-none"> |
| <span className="text-base font-medium">Labels</span> |
| <DownIcon /> |
| </a> |
| </div> |
| } |
| > |
| {({ close }) => <LabelFilterBody {...props} close={close} />} |
| </FilterPopover> |
| ); |
| } |
| |
| interface TypeFilterProps { |
| params?: GithubIssueListParams; |
| filterByType: (isPullRequest: boolean) => void; |
| } |
| |
| function TypeFilter(props: TypeFilterProps) { |
| const isActive = (isPullRequest: boolean) => { |
| return props.params?.isPullRequest === isPullRequest; |
| }; |
| |
| return ( |
| <FilterPopover |
| title="Filter by Type" |
| button={ |
| <div className="flex flex-row items-center py-1.5 px-4 bg-gray-50 rounded-lg hover:bg-gray-100"> |
| <a className="flex flex-row items-center space-x-1 select-none"> |
| <span className="text-base font-medium">Type</span> |
| <DownIcon /> |
| </a> |
| </div> |
| } |
| > |
| {({ close }) => ( |
| <ul className="max-h-[300px] overflow-y-auto cursor-pointer"> |
| {[ |
| { name: "Issues", isPullRequest: false }, |
| { name: "Pull Requests", isPullRequest: true }, |
| ].map((type) => ( |
| <li |
| key={type.name} |
| className="py-2 px-4 text-base flex flex-row space-x-2 hover:bg-gray-100 border-b" |
| onClick={() => { |
| close(); |
| props.filterByType(type.isPullRequest); |
| }} |
| > |
| <Check active={isActive(type.isPullRequest)} /> |
| <span |
| className={classNames({ |
| "font-medium": isActive(type.isPullRequest), |
| })} |
| > |
| {type.name} |
| </span> |
| </li> |
| ))} |
| </ul> |
| )} |
| </FilterPopover> |
| ); |
| } |
| |
| function labelContains( |
| labels: Array<string> | undefined, |
| label: string |
| ): boolean { |
| if (!labels) { |
| return false; |
| } |
| |
| return !!labels.find((l) => l === label); |
| } |
| |
| function labelRemove(labels: Array<string> | undefined, label: string) { |
| if (!labels) { |
| return labels; |
| } |
| |
| const index = labels.findIndex((l) => l === label); |
| if (index >= 0) { |
| const newLabels = [...labels]; |
| newLabels.splice(index, 1); |
| return newLabels; |
| } |
| |
| return labels; |
| } |
| |
| function labelAdd(labels: Array<string> | undefined, label: string) { |
| if (!labels) { |
| return [label]; |
| } |
| |
| const index = labels.findIndex((l) => l === label); |
| if (index >= 0) { |
| return labels; |
| } |
| |
| return [...labels, label]; |
| } |
| |
| function useGithubIssueListForListBody(params: GithubIssueListParams) { |
| let requestParams = { ...params }; |
| if (params.status === "TO_BE_REVIEWED") { |
| requestParams.actionOwner = undefined; |
| requestParams.extraLabels = undefined; |
| } |
| if ( |
| !params.sort && |
| (labelContains(params.labels, LABEL_P0) || |
| labelContains(params.labels, LABEL_P1) || |
| labelContains(params.labels, LABEL_P2)) |
| ) { |
| requestParams.sort = "EXPECTED_RESPOND_AT_ASC"; |
| } |
| return useGithubIssueList(requestParams); |
| } |
| |
| export default function GithubIssueList(props: { |
| params?: GithubIssueListParams; |
| changeParams: (params: GithubIssueListParams) => void; |
| }) { |
| let params: GithubIssueListParams = |
| props.params || defaultGithubIssueListParams(); |
| |
| const needReviewCount = useGithubIssueList({ |
| owner: params.owner, |
| repo: params.repo, |
| status: "TO_BE_REVIEWED", |
| isPullRequest: params.isPullRequest, |
| actionOwner: params.actionOwner, |
| }); |
| const needTriageCount = useGithubIssueList({ |
| owner: params.owner, |
| repo: params.repo, |
| status: "REVIEWED", |
| isPullRequest: params.isPullRequest, |
| actionOwner: params.actionOwner, |
| extraLabels: params.extraLabels, |
| }); |
| const p0BugsCount = useGithubIssueList({ |
| owner: params.owner, |
| repo: params.repo, |
| status: "TRIAGED", |
| labels: [LABEL_P0, LABEL_TYPE_BUG], |
| isPullRequest: params.isPullRequest, |
| actionOwner: params.actionOwner, |
| extraLabels: params.extraLabels, |
| }); |
| const p1BugsCount = useGithubIssueList({ |
| owner: params.owner, |
| repo: params.repo, |
| status: "TRIAGED", |
| labels: [LABEL_P1, LABEL_TYPE_BUG], |
| isPullRequest: params.isPullRequest, |
| actionOwner: params.actionOwner, |
| extraLabels: params.extraLabels, |
| }); |
| const p2BugsCount = useGithubIssueList({ |
| owner: params.owner, |
| repo: params.repo, |
| status: "TRIAGED", |
| labels: [LABEL_P2, LABEL_TYPE_BUG], |
| isPullRequest: params.isPullRequest, |
| actionOwner: params.actionOwner, |
| extraLabels: params.extraLabels, |
| }); |
| |
| const githubIssueList = useGithubIssueListForListBody(params); |
| |
| const changeParams = props.changeParams; |
| |
| const changeRepo = (repo?: Repo) => { |
| let newParams = { ...params }; |
| newParams.owner = repo?.owner; |
| newParams.repo = repo?.repo; |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const changeExtraLabels = (extraLabels?: Array<string>) => { |
| let newParams = { ...params }; |
| newParams.extraLabels = extraLabels; |
| if (newParams.extraLabels && newParams.extraLabels.length == 0) { |
| newParams.extraLabels = undefined; |
| } |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const changeIsPullRequest = (isPullRequest: boolean | undefined) => { |
| let newParams = { ...params }; |
| newParams.isPullRequest = isPullRequest; |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const changeStatus = ( |
| status?: GithubIssueListStatus, |
| labels?: Array<string>, |
| ) => { |
| let newParams = { ...params }; |
| newParams.status = status; |
| newParams.labels = labels; |
| if (newParams.labels && newParams.labels.length == 0) { |
| newParams.labels = undefined; |
| } |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const changeActionOwner = (actionOwner: string | undefined) => { |
| let newParams = { ...params }; |
| newParams.actionOwner = actionOwner; |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const changeSort = (sort: GithubIssueListSort | undefined) => { |
| let newParams = { ...params }; |
| newParams.sort = sort; |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| const goToPage = (page: number) => { |
| let newParams = { ...params }; |
| newParams.page = page; |
| changeParams(newParams); |
| }; |
| |
| const changePageSize = (pageSize: number) => { |
| let newParams = { ...params }; |
| newParams.pageSize = pageSize; |
| newParams.page = 1; |
| changeParams(newParams); |
| }; |
| |
| let activeRepo = undefined; |
| if (params.owner && params.repo) { |
| activeRepo = { owner: params.owner, repo: params.repo }; |
| } |
| |
| return ( |
| <div className="flex flex-col"> |
| <div className="flex flex-row space-x-6 mb-4"> |
| <div className="flex-auto flex flex-row border rounded-lg shadow bg-gray-100"> |
| <RepoFilter activeRepo={activeRepo} changeRepo={changeRepo} /> |
| |
| <div className="flex flex-row items-center ml-4 space-x-2 overflow-x-auto"> |
| {params.owner && params.repo && ( |
| <FilterLabel |
| name={`Repo: ${params.owner}/${params.repo}`} |
| onClear={() => changeRepo(undefined)} |
| /> |
| )} |
| |
| {typeof params.isPullRequest !== "undefined" && ( |
| <FilterLabel |
| name={`Type: ${ |
| params.isPullRequest ? "Pull Requests" : "Issues" |
| }`} |
| onClear={() => changeIsPullRequest(undefined)} |
| /> |
| )} |
| |
| {[...(params.extraLabels ? params.extraLabels : [])].map( |
| (label) => ( |
| <FilterLabel |
| key={label} |
| name={`Label: ${label}`} |
| onClear={() => |
| changeExtraLabels(labelRemove(params.extraLabels, label)) |
| } |
| /> |
| ) |
| )} |
| |
| {params.actionOwner && ( |
| <FilterLabel |
| name={`Owner: ${params.actionOwner}`} |
| onClear={() => changeActionOwner(undefined)} |
| /> |
| )} |
| |
| {params.sort && ( |
| <FilterLabel |
| name={`Sort: ${params.sort}`} |
| onClear={() => changeSort(undefined)} |
| /> |
| )} |
| </div> |
| </div> |
| |
| <div className="flex-shrink-0 border rounded-lg shadow"> |
| <TypeFilter |
| params={params} |
| filterByType={(isPullRequest) => changeIsPullRequest(isPullRequest)} |
| /> |
| </div> |
| |
| <div className="flex-shrink-0 border rounded-lg shadow"> |
| <LabelFilter |
| params={params} |
| filterByLabel={(label) => |
| changeExtraLabels(labelAdd(params.extraLabels, label)) |
| } |
| /> |
| </div> |
| </div> |
| <div className="flex flex-col border shadow rounded bg-white ring-1 ring-black ring-opacity-5"> |
| <div className="bg-gray-100 flex flex-row items-center"> |
| <div className="flex-auto flex space-x-6 p-4 overflow-x-auto"> |
| <Status |
| name="Need Review" |
| active={params.status == "TO_BE_REVIEWED"} |
| count={needReviewCount} |
| changeStatus={() => changeStatus("TO_BE_REVIEWED")} |
| /> |
| <Status |
| name="Need Triage" |
| active={params.status == "REVIEWED"} |
| count={needTriageCount} |
| changeStatus={() => changeStatus("REVIEWED")} |
| /> |
| <Status |
| name="P0 Issues" |
| active={ |
| params.status == "TRIAGED" && |
| labelContains(params.labels, LABEL_P0) |
| } |
| count={p0BugsCount} |
| changeStatus={() => changeStatus("TRIAGED", [LABEL_P0])} |
| /> |
| <Status |
| name="P1 Issues" |
| active={ |
| params.status == "TRIAGED" && |
| labelContains(params.labels, LABEL_P1) |
| } |
| count={p1BugsCount} |
| changeStatus={() => changeStatus("TRIAGED", [LABEL_P1])} |
| /> |
| <Status |
| name="P2 Issues" |
| active={ |
| params.status == "TRIAGED" && |
| labelContains(params.labels, LABEL_P2) |
| } |
| count={p2BugsCount} |
| changeStatus={() => changeStatus("TRIAGED", [LABEL_P2])} |
| /> |
| </div> |
| |
| <div className="flex flex-shrink-0 w-4/12 flex-row space-x-2"> |
| <div className="flex-1 flex flex-row justify-center"> |
| <span className="text-base text-gray-600 font-medium"> |
| {/* intentionally empty */} |
| </span> |
| </div> |
| <div className="flex-1 flex flex-row justify-center"> |
| <ActionOwnerFilter |
| changeActionOwner={changeActionOwner} |
| activeOwner={params.actionOwner} |
| params={params} |
| /> |
| </div> |
| <div className="flex-1 flex flex-row justify-center"> |
| <SortFilter changeSort={changeSort} activeSort={params.sort} /> |
| </div> |
| </div> |
| </div> |
| |
| <GithubIssueListBody |
| {...githubIssueList} |
| changeActionOwner={changeActionOwner} |
| filterByLabel={(label) => |
| changeExtraLabels(labelAdd(params.extraLabels, label)) |
| } |
| /> |
| </div> |
| {!githubIssueList.loading && |
| !githubIssueList.error && |
| githubIssueList.data && ( |
| <GithubIssueListFooter |
| data={githubIssueList.data} |
| goToPage={goToPage} |
| changePageSize={changePageSize} |
| /> |
| )} |
| </div> |
| ); |
| } |