| 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> | |
| ); | |
| } |