[website] Connect burnup charts to API (#425)

* feat: add queries to get epics links and repos and epic burnup data

* feat: add codegen and api fetcher util to create named custom hook query functions

* feat: add more queries with fetcher and infinite scroll

* feat: add some improvements on existing components

* feat: add more functionalities to table issues

* feat: add available fitlers and search to table issues

* feat: add more missing features to the burnup chart

* feat: fix some issues with scroll

* fix: color epic overview and fixe repos card icons

* feat: add overview page data with filters

* fix: changes from review

* fix: component removed from pages folder

* fix import

* fix: several changes to improve performance and handles some edge cases

* fix: infinite scroll epics overview page

---------

Co-authored-by: marcelines <marcio@significa.co>
Co-authored-by: Pavel Prichodko <14926950+prichodko@users.noreply.github.com>
This commit is contained in:
marcelines 2023-06-30 16:32:52 +01:00 committed by GitHub
parent 0d5a0fe21e
commit e52b72f731
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 4558 additions and 843 deletions

12
apps/website/@types/custom.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
type ApiError = {
extensions: { code?: string; [key: string]: string }
locations: { column: number; line: number }[]
message: string
path: string[]
}
declare interface GraphqlApiError {
response?: {
errors?: ApiError[]
}
}

36
apps/website/codegen.yml Normal file
View File

@ -0,0 +1,36 @@
schema:
- https://hasura.infra.status.im/v1/graphql
documents:
- ./src/**/*.tsx
- ./src/**/*.ts
overwrite: true
hooks:
afterAllFileWrite:
- prettier --write
- eslint --fix
generates:
./src/lib/graphql/generated/schemas.ts:
plugins:
- typescript
./src/lib/graphql/generated/operations.ts:
preset: import-types
presetConfig:
typesPath: ./schemas
plugins:
- typescript-operations
./src/lib/graphql/generated/hooks.ts:
preset: import-types
presetConfig:
typesPath: ./operations
plugins:
- typescript-react-query
config:
fetcher: '../api#createFetcher'
exposeQueryKeys: true
errorType: GraphqlApiError

View File

@ -9,7 +9,9 @@
"lint": "next lint", "lint": "next lint",
"typecheck": "contentlayer build && tsc", "typecheck": "contentlayer build && tsc",
"clean": "rimraf .next .tamagui .turbo .vercel/output node_modules", "clean": "rimraf .next .tamagui .turbo .vercel/output node_modules",
"preview": "next start --port 8151" "preview": "next start --port 8151",
"codegen": "graphql-codegen -r dotenv/config --config codegen.yml",
"codegen:watch": "yarn run codegen -- --watch"
}, },
"dependencies": { "dependencies": {
"@mdx-js/react": "^2.3.0", "@mdx-js/react": "^2.3.0",
@ -48,6 +50,12 @@
}, },
"devDependencies": { "devDependencies": {
"@achingbrain/ssdp": "^4.0.1", "@achingbrain/ssdp": "^4.0.1",
"@graphql-codegen/cli": "^4.0.1",
"@graphql-codegen/import-types-preset": "^2.2.6",
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-react-query": "^4.1.0",
"@status-im/eslint-config": "*",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tamagui/next-plugin": "1.36.4", "@tamagui/next-plugin": "1.36.4",
"@types/d3-array": "^3.0.4", "@types/d3-array": "^3.0.4",
@ -76,7 +84,6 @@
"typescript": "^5.0.3", "typescript": "^5.0.3",
"unist-util-visit": "^4.1.2", "unist-util-visit": "^4.1.2",
"@types/tryghost__content-api": "^1.3.11", "@types/tryghost__content-api": "^1.3.11",
"@status-im/eslint-config": "*",
"rehype-parse": "^8.0.4", "rehype-parse": "^8.0.4",
"rehype-react": "^7.2.0", "rehype-react": "^7.2.0",
"rehype-stringify": "^9.0.3", "rehype-stringify": "^9.0.3",

View File

@ -1,7 +1,7 @@
import { Stack } from '@tamagui/core' import { Stack } from '@tamagui/core'
import { ParentSize } from '@visx/responsive' import { ParentSize } from '@visx/responsive'
import { ChartComponent, Empty, Loading } from './components' import { ChartComponent, Loading } from './components'
type DayType = { type DayType = {
date: string date: string
@ -26,14 +26,6 @@ const Chart = (props: Props) => {
) )
} }
if (!data.length) {
return (
<Stack width="100%" height={rest.height}>
<Empty />
</Stack>
)
}
return ( return (
<ParentSize style={{ maxHeight: 326 }}> <ParentSize style={{ maxHeight: 326 }}>
{({ width }) => { {({ width }) => {

View File

@ -1,5 +1,5 @@
import { animated } from '@react-spring/web' import { animated } from '@react-spring/web'
import { curveMonotoneX } from '@visx/curve' import { curveBasis } from '@visx/curve'
import { LinearGradient } from '@visx/gradient' import { LinearGradient } from '@visx/gradient'
import { AreaClosed } from '@visx/shape' import { AreaClosed } from '@visx/shape'
@ -71,7 +71,7 @@ const Areas = (props: Props) => {
}} }}
yScale={yScale} yScale={yScale}
fill="url(#gradient)" fill="url(#gradient)"
curve={curveMonotoneX} curve={curveBasis}
style={{ ...clipPathAnimation, zIndex: 10 }} style={{ ...clipPathAnimation, zIndex: 10 }}
/> />
@ -88,7 +88,7 @@ const Areas = (props: Props) => {
}} }}
yScale={yScale} yScale={yScale}
fill="url(#gradient-open)" fill="url(#gradient-open)"
curve={curveMonotoneX} curve={curveBasis}
style={{ ...clipPathAnimation, zIndex: 10 }} style={{ ...clipPathAnimation, zIndex: 10 }}
/> />
</> </>

View File

@ -63,7 +63,10 @@ const ChartComponent = (props: Props): JSX.Element => {
const yScale = scaleLinear({ const yScale = scaleLinear({
domain: [0, max(totalIssues) || 0], domain: [0, max(totalIssues) || 0],
range: [innerHeight, 0], range: [
innerHeight,
totalIssues.every(issue => issue === 0) ? innerHeight : 0,
], // Adjusted the range to start from innerHeight instead of 0
nice: true, nice: true,
}) })

View File

@ -91,7 +91,7 @@ const ChartTooltip = (props: Props) => {
<DoneIcon size={16} color="$neutral-40" /> <DoneIcon size={16} color="$neutral-40" />
<Stack px={4}> <Stack px={4}>
<Text size={13} weight="medium"> <Text size={13} weight="medium">
{tooltipData.closedIssues} closes {tooltipData.closedIssues} closed
</Text> </Text>
</Stack> </Stack>
<Stack <Stack

View File

@ -7,7 +7,7 @@ const Empty = () => {
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-10 flex flex-col items-center justify-center"> <div className="relative z-10 flex flex-col items-center justify-center">
<Image src={'/assets/chart/empty.png'} width={80} height={80} /> <Image src="/assets/chart/empty.png" width={80} height={80} />
<div className="pb-3" /> <div className="pb-3" />
<Text size={15} weight="semibold"> <Text size={15} weight="semibold">
No results found No results found
@ -17,7 +17,7 @@ const Empty = () => {
Try adjusting your search or filter to find what youre looking for. Try adjusting your search or filter to find what youre looking for.
</Text> </Text>
</div> </div>
<div className="left-50 absolute top-16 w-full opacity-60"> <div className="absolute left-[50%] top-16 w-full translate-x-[-50%] opacity-60">
<LineA /> <LineA />
</div> </div>
<div className="absolute left-0 top-28 w-full opacity-60"> <div className="absolute left-0 top-28 w-full opacity-60">

View File

@ -1,5 +1,5 @@
import { animated } from '@react-spring/web' import { animated } from '@react-spring/web'
import { curveMonotoneX } from '@visx/curve' import { curveBasis } from '@visx/curve'
import { LinePath } from '@visx/shape' import { LinePath } from '@visx/shape'
import { colors } from './chart-component' import { colors } from './chart-component'
@ -47,7 +47,7 @@ const Lines = (props: Props) => {
}} }}
stroke={colors.total} stroke={colors.total}
strokeWidth={2} strokeWidth={2}
curve={curveMonotoneX} curve={curveBasis}
style={drawingLineStyle} style={drawingLineStyle}
/> />
@ -64,7 +64,7 @@ const Lines = (props: Props) => {
}} }}
stroke={colors.closed} stroke={colors.closed}
strokeWidth={2} strokeWidth={2}
curve={curveMonotoneX} curve={curveBasis}
style={drawingLineStyle} style={drawingLineStyle}
/> />
</> </>

View File

@ -98,9 +98,10 @@ const useChartTooltip = (props: Props) => {
const totalIssues = getTotalIssues(d) const totalIssues = getTotalIssues(d)
const openIssues = totalIssues - closedIssues const openIssues = totalIssues - closedIssues
const completedIssuesPercentage = getPercentage(closedIssues, totalIssues) const completedIssuesPercentage =
getPercentage(closedIssues, totalIssues) || 0
const openIssuesPercentage = getPercentage(openIssues, totalIssues) const openIssuesPercentage = getPercentage(openIssues, totalIssues) || 0
showTooltip({ showTooltip({
tooltipData: { tooltipData: {

View File

@ -1,6 +1,7 @@
import { Calendar } from '@status-im/components/src/calendar/calendar' import { Calendar } from '@status-im/components/src/calendar/calendar'
import { Popover } from '@status-im/components/src/popover' import { Popover } from '@status-im/components/src/popover'
import { EditIcon } from '@status-im/icons' import { EditIcon } from '@status-im/icons'
import { addDays } from 'date-fns'
import { formatDate } from '../chart/utils/format-time' import { formatDate } from '../chart/utils/format-time'
@ -35,6 +36,7 @@ const DatePicker = (props: Props) => {
selected={selected} selected={selected}
onSelect={onSelect} onSelect={onSelect}
fixedWeeks fixedWeeks
disabled={{ from: addDays(new Date(), 1) }}
/> />
</Popover.Content> </Popover.Content>
</Popover> </Popover>

View File

@ -1,112 +1,101 @@
import { useEffect, useState } from 'react'
import { Tag, Text } from '@status-im/components' import { Tag, Text } from '@status-im/components'
import { OpenIcon } from '@status-im/icons' import { OpenIcon } from '@status-im/icons'
import { Chart } from './chart/chart' import { Chart } from './chart/chart'
import { DatePicker } from './datepicker/datepicker'
const DATA = [ import type { GetBurnupQuery } from '@/lib/graphql/generated/operations'
{ import type { DateRange } from '@status-im/components/src/calendar/calendar'
date: '2022-01-25',
open_issues: 100,
closed_issues: 2,
},
{
date: '2022-01-26',
open_issues: 100,
closed_issues: 10,
},
{
date: '2022-01-27',
open_issues: 100,
closed_issues: 20,
},
{
date: '2022-01-28',
open_issues: 90,
closed_issues: 30,
},
{
date: '2022-01-29',
open_issues: 80,
closed_issues: 40,
},
{
date: '2022-01-30',
open_issues: 40,
closed_issues: 80,
},
{
date: '2022-01-31',
open_issues: 30,
closed_issues: 90,
},
{
date: '2022-02-01',
open_issues: 25,
closed_issues: 95,
},
{
date: '2022-02-02',
open_issues: 20,
closed_issues: 98,
},
{
date: '2022-02-03',
open_issues: 10,
closed_issues: 130,
},
]
type Props = { type Props = {
title: string title: string
description: string description?: string
color?: `#${string}`
fullscreen?: boolean fullscreen?: boolean
isLoading?: boolean
burnup?: GetBurnupQuery['gh_burnup']
selectedDates?: DateRange
setSelectedDates: (date?: DateRange) => void
showPicker?: boolean
} }
export const EpicOverview = (props: Props) => { export const EpicOverview = (props: Props) => {
const { title, description, fullscreen } = props const {
title,
description,
color,
fullscreen,
isLoading,
burnup,
selectedDates,
setSelectedDates,
showPicker = true,
} = props
// Simulating loading state const filteredData = burnup?.reduce(
const [isLoading, setIsLoading] = useState(true) (
useEffect(() => { accumulator: {
const timeout = setTimeout(() => { date: string
setIsLoading(false) open_issues: number
}, 2000) closed_issues: number
}[],
return () => { current: GetBurnupQuery['gh_burnup'][0]
clearTimeout(timeout) ) => {
const existingItem = accumulator.find(
item => item.date === current.date_field
)
if (!existingItem) {
accumulator.push({
date: current?.date_field,
open_issues:
current?.total_opened_issues - current?.total_closed_issues,
closed_issues: current?.total_closed_issues,
})
} }
}, [])
return accumulator
},
[]
)
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div className="flex justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Text size={fullscreen ? 27 : 19} weight="semibold"> <Text size={fullscreen ? 27 : 19} weight="semibold">
{title} {title}
</Text> </Text>
<OpenIcon size={20} /> <OpenIcon size={20} />
</div> </div>
{showPicker && (
<DatePicker selected={selectedDates} onSelect={setSelectedDates} />
)}
</div>
{Boolean(description) && (
<Text size={fullscreen ? 19 : 15} color="$neutral-50"> <Text size={fullscreen ? 19 : 15} color="$neutral-50">
{description} {description}
</Text> </Text>
)}
<div className="flex py-3"> <div className="flex py-3">
<Tag size={24} label="E:CommunitiesProtocol" color="$blue-50" /> <Tag size={24} label={title} color={color} />
</div> </div>
<Chart data={DATA} height={300} isLoading={isLoading} /> <Chart data={filteredData || []} height={300} isLoading={isLoading} />
<div className="flex justify-between pt-3"> {/* TODO - Add theses when we have milestones and/or labels */}
{/* <div className="flex justify-between pt-3">
<div className="flex gap-1"> <div className="flex gap-1">
<Tag size={24} label="Communities" color="#FF7D46" icon="🧙‍♂️" /> <Tag size={24} label="Communities" color="#FF7D46" icon="🧙‍♂️" />
<Tag size={24} label="Wallet" color="#7140FD" icon="🎎" /> <Tag size={24} label="Wallet" color="#7140FD" icon="🎎" />
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Tag size={24} label="M:0.11.0" color="$danger-50" /> <Tag size={24} label="M:0.11.0" color="$danger-50" />
<Tag size={24} label="M:0.12.0" color="$success-50" /> <Tag size={24} label="M:0.12.0" color="$success-50" />
</div> </div>
</div> </div> */}
</div> </div>
) )
} }

View File

@ -1,2 +1,4 @@
export { Breadcrumbs } from './breadcrumbs'
export { EpicOverview } from './epic-overview' export { EpicOverview } from './epic-overview'
export { SidebarMenu } from './sidebar-menu'
export { TableIssues } from './table-issues/table-issues' export { TableIssues } from './table-issues/table-issues'

View File

@ -25,11 +25,10 @@ const FloatingMenu = (): JSX.Element => {
useLockScroll(open) useLockScroll(open)
useScroll({ useScroll({
onChange: ({ value: { scrollYProgress } }) => { onChange: ({ value: { scrollY } }) => {
const isMenuOpen = openRef.current const isMenuOpen = openRef.current
const isScrollingUp = scrollYProgress < scrollYRef.current const isScrollingUp = scrollY < scrollYRef.current
const detectionPoint = scrollYProgress > 0.005 const detectionPoint = scrollY > 0.005
if (detectionPoint && isScrollingUp) { if (detectionPoint && isScrollingUp) {
if (!visibleRef.current) { if (!visibleRef.current) {
setVisible(true) setVisible(true)
@ -39,7 +38,7 @@ const FloatingMenu = (): JSX.Element => {
setVisible(false) setVisible(false)
} }
} }
scrollYRef.current = scrollYProgress scrollYRef.current = scrollY
}, },
default: { default: {
immediate: true, immediate: true,
@ -58,7 +57,7 @@ const FloatingMenu = (): JSX.Element => {
'rounded-2xl border border-neutral-80/5 bg-blur-neutral-80/80 backdrop-blur-md', 'rounded-2xl border border-neutral-80/5 bg-blur-neutral-80/80 backdrop-blur-md',
' w-[calc(100%-24px)]', ' w-[calc(100%-24px)]',
' opacity-0 transition-opacity data-[visible=true]:opacity-100', ' opacity-0 transition-opacity data-[visible=true]:opacity-100',
'z-10', 'z-20',
])} ])}
> >
<FloatingMobile open={open} setOpen={setOpen} /> <FloatingMobile open={open} setOpen={setOpen} />

View File

@ -0,0 +1,38 @@
import { Shadow, Skeleton } from '@status-im/components'
const LoadingSkeleton = () => {
return (
<div className="grid grid-cols-3 gap-5 p-10">
{[...Array(6)].map((_, index) => (
<Shadow key={index}>
<div className="flex h-[124px] flex-col justify-between rounded-2xl border border-neutral-10 px-4 py-3 ">
<Skeleton height={12} width={120} borderRadius="$6" />
<Skeleton height={12} width={220} borderRadius="$6" />
<div className="flex flex-row items-center gap-3">
<Skeleton
height={12}
width={40}
borderRadius="$6"
variant="secondary"
/>
<Skeleton
height={12}
width={40}
borderRadius="$6"
variant="secondary"
/>
<Skeleton
height={12}
width={40}
borderRadius="$6"
variant="secondary"
/>
</div>
</div>
</Shadow>
))}
</div>
)
}
export { LoadingSkeleton }

View File

@ -5,8 +5,11 @@ import { useRouter } from 'next/router'
import { NavLink } from './nav-link' import { NavLink } from './nav-link'
import { NavNestedLinks } from './nav-nested-links' import { NavNestedLinks } from './nav-nested-links'
import { SkeletonPlaceholder } from './skeleton-placeholder'
import { decodeUriComponent } from './utils'
type Props = { type Props = {
isLoading?: boolean
items: { items: {
label: string label: string
href?: string href?: string
@ -22,7 +25,7 @@ type Props = {
} }
const SidebarMenu = (props: Props) => { const SidebarMenu = (props: Props) => {
const { items } = props const { items, isLoading } = props
const [label, setLabel] = useState<string>('') const [label, setLabel] = useState<string>('')
@ -30,7 +33,8 @@ const SidebarMenu = (props: Props) => {
const defaultLabel = items?.find( const defaultLabel = items?.find(
item => item =>
item.href === asPath || item.links?.find(link => link.href === asPath) item.href === decodeUriComponent(asPath) ||
item.links?.find(link => link.href === decodeUriComponent(asPath))
)?.label )?.label
useEffect(() => { useEffect(() => {
@ -40,6 +44,9 @@ const SidebarMenu = (props: Props) => {
return ( return (
<div className="border-r border-neutral-10 p-5"> <div className="border-r border-neutral-10 p-5">
<aside className=" sticky top-5 min-w-[320px]"> <aside className=" sticky top-5 min-w-[320px]">
{isLoading ? (
<SkeletonPlaceholder />
) : (
<Accordion.Root <Accordion.Root
type="single" type="single"
collapsible collapsible
@ -59,11 +66,7 @@ const SidebarMenu = (props: Props) => {
} }
return ( return (
<Accordion.Item <Accordion.Item key={item.label} value={item.label}>
key={item.label}
value={item.label}
className="accordion-item"
>
<Accordion.Trigger <Accordion.Trigger
className="accordion-trigger" className="accordion-trigger"
onClick={() => setLabel(item.label)} onClick={() => setLabel(item.label)}
@ -74,6 +77,7 @@ const SidebarMenu = (props: Props) => {
) )
})} })}
</Accordion.Root> </Accordion.Root>
)}
</aside> </aside>
</div> </div>
) )

View File

@ -5,6 +5,7 @@ import { ChevronRightIcon } from '@status-im/icons'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Link } from '../link' import { Link } from '../link'
import { decodeUriComponent } from './utils'
import type { Url } from 'next/dist/shared/lib/router/router' import type { Url } from 'next/dist/shared/lib/router/router'
@ -26,7 +27,7 @@ const NavNestedLinks = (props: NavLinkProps) => {
const { asPath } = useRouter() const { asPath } = useRouter()
return ( return (
<Accordion.Item value={label} className="accordion-item"> <Accordion.Item value={label}>
<div> <div>
<Accordion.Trigger className="accordion-trigger"> <Accordion.Trigger className="accordion-trigger">
<div className="accordion-chevron inline-flex h-5 w-5"> <div className="accordion-chevron inline-flex h-5 w-5">
@ -44,7 +45,7 @@ const NavNestedLinks = (props: NavLinkProps) => {
}} }}
> >
{links.map((link, index) => { {links.map((link, index) => {
const active = asPath === link.href const active = decodeUriComponent(asPath) === link.href
const paddingClassName = index === 0 ? 'pt-5' : 'pt-2' const paddingClassName = index === 0 ? 'pt-5' : 'pt-2'
const paddingLastChild = index === links.length - 1 ? 'pb-5' : '' const paddingLastChild = index === links.length - 1 ? 'pb-5' : ''

View File

@ -0,0 +1,89 @@
import { Skeleton } from '@status-im/components'
import { Stack } from '@tamagui/core'
const SkeletonPlaceholder = () => {
return (
<Stack height="100%">
<Stack
paddingBottom={16}
paddingTop={16}
backgroundColor="$background"
zIndex={10}
>
<Stack paddingBottom={16}>
<Stack mb={27}>
<Skeleton height={12} width={120} borderRadius="$6" mb={19} />
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={200}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={100}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={130}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
<Stack flexDirection="row" alignItems="center">
<Skeleton
height={12}
width={90}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
</Stack>
<Stack>
<Skeleton height={12} width={50} borderRadius={5} mb={19} />
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={120}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={100}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
<Stack flexDirection="row" alignItems="center" mb={16}>
<Skeleton
height={12}
width={200}
borderRadius="$6"
variant="secondary"
ml={12}
/>
</Stack>
</Stack>
</Stack>
</Stack>
</Stack>
)
}
export { SkeletonPlaceholder }

View File

@ -0,0 +1,3 @@
export function decodeUriComponent(str: string): string {
return decodeURIComponent(str.replace(/\+/g, '%20'))
}

View File

@ -0,0 +1,98 @@
import { Avatar, Skeleton, Tag, Text } from '@status-im/components'
import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link'
import type {
GetIssuesByEpicQuery,
GetOrphansQuery,
} from '@/lib/graphql/generated/operations'
type Props = {
isLoading?: boolean
data?: GetOrphansQuery['gh_orphans'] | GetIssuesByEpicQuery['gh_epic_issues']
count?: {
total: number
closed: number
open: number
}
}
// function isOrphans(
// data: GetOrphansQuery['gh_orphans'] | GetIssuesByEpicQuery['gh_epic_issues']
// ): data is GetOrphansQuery['gh_orphans'] {
// return 'labels' in data[0]
// }
function isIssues(
data: GetOrphansQuery['gh_orphans'] | GetIssuesByEpicQuery['gh_epic_issues']
): data is GetIssuesByEpicQuery['gh_epic_issues'] {
return 'assignee' in data[0]
}
export const TableIssues = (props: Props) => {
const { data, count, isLoading } = props
const issues = data || []
return (
<div className="mb-8 overflow-hidden rounded-2xl border border-neutral-10 transition-opacity">
<div className="border-b border-neutral-10 bg-neutral-5 p-3">
<Text size={15} weight="medium">
{count?.open || 0} Open
</Text>
<Text size={15} weight="medium">
{count?.closed || 0} Closed
</Text>
</div>
<div className="divide-y divide-neutral-10">
{issues.length !== 0 &&
isIssues(issues) &&
issues.map(issue => (
<Link
key={issue.issue_number}
href={`https://github.com/status-im/${issue.repository}/issues/${issue.issue_number}`}
className="flex items-center justify-between px-4 py-3 transition-colors duration-200 hover:bg-neutral-5"
>
<div className="flex flex-col">
<Text size={15} weight="medium">
{issue.title}
</Text>
<Text size={13} color="$neutral-50">
#{issue.issue_number} {' '}
{formatDistanceToNow(new Date(issue.created_at), {
addSuffix: true,
})}{' '}
by {issue.author}
</Text>
</div>
<div className="flex gap-3">
<div className="flex gap-1">
<Tag
size={24}
label={issue.epic_name || ''}
color={`#${issue.epic_color}` || '$primary'}
/>
</div>
<Avatar type="user" size={24} name={issue.author || ''} />
</div>
</Link>
))}
{isLoading && (
<div className="flex items-center justify-between px-4 py-3">
<div className="flex h-10 grow flex-col justify-between">
<Skeleton width={340} height={18} />
<Skeleton width={200} height={12} />
</div>
<div className="flex flex-auto flex-row justify-end gap-2">
<Skeleton width={85} height={24} />
<Skeleton width={24} height={24} />
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { Image, Text } from '@status-im/components'
const Empty = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-10 flex flex-col items-center justify-center">
<Image src="/assets/chart/empty.png" width={80} height={80} />
<div className="pb-3" />
<Text size={15} weight="semibold">
No results found
</Text>
<div className="pb-1" />
<Text size={13} color="$neutral-50">
Try adjusting your search or filter to find what youre looking for.
</Text>
</div>
<div
className="absolute left-0 top-0 h-24 w-full"
style={{
background: `linear-gradient(180deg, white, transparent)`,
}}
/>
<div className="absolute flex h-full w-full justify-between opacity-80">
{Array.from({ length: 12 }).map((_, i) => (
<div
className="h-full w-1"
style={{
borderLeft: '1px dashed #F0F2F5',
}}
key={i}
/>
))}
</div>
</div>
</div>
)
}
export { Empty }

View File

@ -1,4 +1,4 @@
import { cloneElement, useState } from 'react' import { cloneElement, useMemo, useState } from 'react'
import { Avatar, Button, Input, Text } from '@status-im/components' import { Avatar, Button, Input, Text } from '@status-im/components'
import { DropdownMenu } from '@status-im/components/src/dropdown-menu' import { DropdownMenu } from '@status-im/components/src/dropdown-menu'
@ -12,7 +12,7 @@ import { ColorCircle } from './components/color-circle'
import type { ColorTokens } from '@tamagui/core' import type { ColorTokens } from '@tamagui/core'
type Data = { type Data = {
id: number id: string
name: string name: string
avatar?: string | React.ReactElement avatar?: string | React.ReactElement
color?: ColorTokens | `#${string}` color?: ColorTokens | `#${string}`
@ -22,6 +22,8 @@ type Props = {
data: Data[] data: Data[]
label: string label: string
placeholder?: string placeholder?: string
selectedValues: string[]
onSelectedValuesChange: (values: string[]) => void
} }
const isAvatar = (value: unknown): value is string => { const isAvatar = (value: unknown): value is string => {
@ -45,23 +47,26 @@ const RenderIcon = (props: Data) => {
} }
const DropdownFilter = (props: Props) => { const DropdownFilter = (props: Props) => {
const { data, label, placeholder } = props const { data, label, placeholder, selectedValues, onSelectedValuesChange } =
props
const [filterText, setFilterText] = useState('') const [filterText, setFilterText] = useState('')
// TODO - this will be improved by having a debounced search and use memoization const filteredData = useMemo(
const filteredData = data.filter(label => () =>
data.filter(label =>
label.name.toLowerCase().includes(filterText.toLowerCase()) label.name.toLowerCase().includes(filterText.toLowerCase())
),
[data, filterText]
) )
const [selectedValues, setSelectedValues] = useState<number[]>([])
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const currentBreakpoint = useCurrentBreakpoint() const currentBreakpoint = useCurrentBreakpoint()
return ( return (
<div> <div>
<DropdownMenu onOpenChange={() => setIsOpen(!isOpen)}> <DropdownMenu onOpenChange={() => setIsOpen(!isOpen)} modal={false}>
<Button <Button
size={32} size={32}
variant="outline" variant="outline"
@ -77,6 +82,7 @@ const DropdownFilter = (props: Props) => {
> >
{label} {label}
</Button> </Button>
<DropdownMenu.Content <DropdownMenu.Content
sideOffset={10} sideOffset={10}
align={currentBreakpoint === '2xl' ? 'end' : 'start'} align={currentBreakpoint === '2xl' ? 'end' : 'start'}
@ -99,11 +105,11 @@ const DropdownFilter = (props: Props) => {
checked={selectedValues.includes(filtered.id)} checked={selectedValues.includes(filtered.id)}
onSelect={() => { onSelect={() => {
if (selectedValues.includes(filtered.id)) { if (selectedValues.includes(filtered.id)) {
setSelectedValues( onSelectedValuesChange(
selectedValues.filter(id => id !== filtered.id) selectedValues.filter(id => id !== filtered.id)
) )
} else { } else {
setSelectedValues([...selectedValues, filtered.id]) onSelectedValuesChange([...selectedValues, filtered.id])
} }
}} }}
/> />

View File

@ -1,23 +1,22 @@
import { useState } from 'react'
import { IconButton, Text } from '@status-im/components' import { IconButton, Text } from '@status-im/components'
import { DropdownMenu } from '@status-im/components/src/dropdown-menu' import { DropdownMenu } from '@status-im/components/src/dropdown-menu'
import { SortIcon } from '@status-im/icons' import { SortIcon } from '@status-im/icons'
import Image from 'next/image'
import type { Order_By } from '@/lib/graphql/generated/schemas'
type Data = { type Data = {
id: number id: Order_By
name: string name: string
} }
type Props = { type Props = {
data: Data[] data: Data[]
orderByValue: Order_By
onOrderByValueChange: (value: Order_By) => void
} }
const DropdownSort = (props: Props) => { const DropdownSort = (props: Props) => {
const { data } = props const { data, orderByValue, onOrderByValueChange } = props
const [selectedValue, setSelectedValue] = useState<number>()
return ( return (
<div> <div>
@ -37,31 +36,12 @@ const DropdownSort = (props: Props) => {
key={option.id} key={option.id}
label={option.name} label={option.name}
onSelect={() => { onSelect={() => {
setSelectedValue(option.id) onOrderByValueChange(option.id)
}} }}
selected={selectedValue === option.id} selected={orderByValue === option.id}
/> />
) )
})} })}
{data.length === 0 && (
<div className="flex flex-col items-center justify-center p-2 py-1">
<Image
className="pb-3 invert"
alt="No results"
src={'/assets/filters/empty.png'}
width={80}
height={80}
/>
<div className="pb-[2px]">
<Text size={15} weight="semibold">
No options found
</Text>
</div>
<div className="text-center">
<Text size={13}>We didn&apos;t find any results</Text>
</div>
</div>
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@ -1,10 +1,15 @@
import { useState } from 'react' import { ActiveMembersIcon, OpenIcon } from '@status-im/icons'
import { DoneIcon, OpenIcon } from '@status-im/icons'
const Tabs = (): JSX.Element => {
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open')
type Props = {
count: {
open?: number
closed?: number
}
activeTab: 'open' | 'closed'
onTabChange: (tab: 'open' | 'closed') => void
}
const Tabs = (props: Props): JSX.Element => {
const { count, activeTab, onTabChange } = props
const isOpen = activeTab === 'open' const isOpen = activeTab === 'open'
return ( return (
@ -14,10 +19,12 @@ const Tabs = (): JSX.Element => {
className={`flex cursor-pointer flex-row items-center transition-colors ${ className={`flex cursor-pointer flex-row items-center transition-colors ${
isOpen ? 'text-neutral-100' : 'text-neutral-50' isOpen ? 'text-neutral-100' : 'text-neutral-50'
}`} }`}
onClick={() => setActiveTab('open')} onClick={() => onTabChange('open')}
> >
<OpenIcon size={20} color={isOpen ? '$neutral-100' : '$neutral-50'} /> <OpenIcon size={20} color={isOpen ? '$neutral-100' : '$neutral-50'} />
<span className="pl-1 text-[15px]">784 Open</span> <span className="pl-1 text-[15px]">
{typeof count.open === 'number' ? count.open : '-'} Open
</span>
</button> </button>
</div> </div>
<div className="flex items-center pr-3"> <div className="flex items-center pr-3">
@ -25,13 +32,15 @@ const Tabs = (): JSX.Element => {
className={`flex cursor-pointer flex-row items-center transition-colors ${ className={`flex cursor-pointer flex-row items-center transition-colors ${
!isOpen ? 'text-neutral-100' : 'text-neutral-50' !isOpen ? 'text-neutral-100' : 'text-neutral-50'
}`} }`}
onClick={() => setActiveTab('closed')} onClick={() => onTabChange('closed')}
> >
<DoneIcon <ActiveMembersIcon
size={20} size={20}
color={!isOpen ? '$neutral-100' : '$neutral-50'} color={!isOpen ? '$neutral-100' : '$neutral-50'}
/> />
<span className="pl-1 text-[15px]">1012 Closed</span> <span className="pl-1 text-[15px]">
{typeof count.closed === 'number' ? count.closed : '-'} Closed
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,273 +1,155 @@
import { useState } from 'react' import { Avatar, Input, Skeleton, Tag, Text } from '@status-im/components'
import { ActiveMembersIcon, OpenIcon, SearchIcon } from '@status-im/icons'
import { Avatar, Button, Input, Tag, Text } from '@status-im/components' import { formatDistanceToNow } from 'date-fns'
import { ProfileIcon, SearchIcon } from '@status-im/icons'
import Link from 'next/link' import Link from 'next/link'
import { useCurrentBreakpoint } from '@/hooks/use-current-breakpoint' import { useCurrentBreakpoint } from '@/hooks/use-current-breakpoint'
import { Order_By } from '@/lib/graphql/generated/schemas'
import { Empty } from './empty'
import { DropdownFilter, DropdownSort, Tabs } from './filters' import { DropdownFilter, DropdownSort, Tabs } from './filters'
import type { DropdownFilterProps } from './filters/dropdown-filter'
import type { DropdownSortProps } from './filters/dropdown-sort' import type { DropdownSortProps } from './filters/dropdown-sort'
import type {
const issues = [ GetFiltersForOrphansQuery,
{ GetFiltersWithEpicQuery,
id: 5154, GetIssuesByEpicQuery,
title: 'Add support for encrypted communities', GetOrphansQuery,
status: 'open', } from '@/lib/graphql/generated/operations'
},
{
id: 5155,
title: 'Add support for encrypted communities',
status: 'open',
},
{
id: 4,
title: 'Add support for encrypted communities',
status: 'open',
},
{
id: 4324,
title: 'Add support for encrypted communities',
status: 'open',
},
{
id: 134,
title: 'Add support for encrypted communities',
status: 'closed',
},
{
id: 999,
title: 'Add support for encrypted communities',
status: 'closed',
},
{
id: 873,
title: 'Add support for encrypted communities',
status: 'open',
},
{
id: 123,
title: 'Add support for encrypted communities',
status: 'open',
},
]
const authors: DropdownFilterProps['data'] = [
{
id: 4,
name: 'marcelines',
avatar: 'https://avatars.githubusercontent.com/u/29401404?v=4',
},
{
id: 5,
name: 'prichodko',
avatar: 'https://avatars.githubusercontent.com/u/14926950?v=4',
},
{
id: 6,
name: 'felicio',
avatar: 'https://avatars.githubusercontent.com/u/13265126?v=4',
},
{
id: 7,
name: 'jkbktl',
avatar: 'https://avatars.githubusercontent.com/u/520927?v=4',
},
]
const epics: DropdownFilterProps['data'] = [
{
id: 4,
name: 'E:ActivityCenter',
color: '$orange-60',
},
{
id: 5,
name: 'E:Keycard',
color: '$purple-60',
},
{
id: 6,
name: 'E:Wallet',
color: '$pink-60',
},
{
id: 7,
name: 'E:Chat',
color: '$beige-60',
},
]
const labels: DropdownFilterProps['data'] = [
{
id: 4,
name: 'Mobile',
color: '$blue-60',
},
{
id: 5,
name: 'Frontend',
color: '$brown-50',
},
{
id: 6,
name: 'Backend',
color: '$red-60',
},
{
id: 7,
name: 'Desktop',
color: '$green-60',
},
]
const assignees: DropdownFilterProps['data'] = [
{
id: 1,
name: 'Unassigned',
avatar: <ProfileIcon size={16} />,
},
{
id: 4,
name: 'marcelines',
avatar: 'https://avatars.githubusercontent.com/u/29401404?v=4',
},
{
id: 5,
name: 'prichodko',
avatar: 'https://avatars.githubusercontent.com/u/14926950?v=4',
},
{
id: 6,
name: 'felicio',
avatar: 'https://avatars.githubusercontent.com/u/13265126?v=4',
},
{
id: 7,
name: 'jkbktl',
avatar: 'https://avatars.githubusercontent.com/u/520927?v=4',
},
]
const repositories: DropdownFilterProps['data'] = [
{
id: 1,
name: 'status-mobile',
},
{
id: 2,
name: 'status-desktop',
},
{
id: 3,
name: 'status-web',
},
{
id: 4,
name: 'status-go',
},
{
id: 5,
name: 'nwaku',
},
{
id: 6,
name: 'go-waku',
},
{
id: 7,
name: 'js-waku',
},
{
id: 8,
name: 'nimbus-eth2',
},
{
id: 9,
name: 'help.status.im',
},
]
const sortOptions: DropdownSortProps['data'] = [ const sortOptions: DropdownSortProps['data'] = [
{ {
id: 1, id: Order_By.Asc,
name: 'Default', name: 'Ascending',
}, },
{ {
id: 2, id: Order_By.Desc,
name: 'Alphabetical', name: 'Descending',
},
{
id: 3,
name: 'Creation date',
},
{
id: 4,
name: 'Updated',
},
{
id: 5,
name: 'Completion',
}, },
] ]
const TableIssues = () => { type Props = {
const [issuesSearchText, setIssuesSearchText] = useState('') isLoading?: boolean
data?: GetOrphansQuery['gh_orphans'] | GetIssuesByEpicQuery['gh_epic_issues']
filters?: GetFiltersWithEpicQuery | GetFiltersForOrphansQuery
count?: {
total?: number
closed?: number
open?: number
}
activeTab: 'open' | 'closed'
handleTabChange: (tab: 'open' | 'closed') => void
selectedAuthors: string[]
handleSelectedAuthors: (values: string[]) => void
selectedAssignees: string[]
handleSelectedAssignees: (values: string[]) => void
selectedRepos: string[]
handleSelectedRepos: (values: string[]) => void
orderByValue: Order_By
handleOrderByValue: (value: Order_By) => void
handleSearchFilter: (value: string) => void
searchFilterValue: string
}
const TableIssues = (props: Props) => {
const currentBreakpoint = useCurrentBreakpoint() const currentBreakpoint = useCurrentBreakpoint()
const {
data = [],
count,
isLoading,
filters,
handleTabChange,
activeTab,
selectedAuthors,
handleSelectedAuthors,
selectedAssignees,
handleSelectedAssignees,
selectedRepos,
handleSelectedRepos,
orderByValue,
handleOrderByValue,
handleSearchFilter,
searchFilterValue,
} = props
return ( return (
<div className="overflow-hidden rounded-2xl border border-neutral-10"> <div className="overflow-hidden rounded-2xl border border-neutral-20 shadow-1">
<div className="flex border-b border-neutral-10 bg-neutral-5 p-3"> <div className="flex border-b border-neutral-20 bg-neutral-5 p-3">
<div className="flex w-full flex-col 2xl:flex-row 2xl:justify-between"> <div className="flex w-full flex-col 2xl:flex-row 2xl:justify-between">
<Tabs /> <Tabs
onTabChange={handleTabChange}
activeTab={activeTab}
count={{
closed: count?.closed,
open: count?.open,
}}
/>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center 2xl:justify-end"> <div className="flex items-center 2xl:justify-end">
<div className="flex w-full justify-between pt-4 2xl:justify-end 2xl:pt-0"> <div className="flex w-full justify-between pt-4 2xl:justify-end 2xl:pt-0">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="transition-all"> <div className="transition-all">
<Input <Input
placeholder="Find Author" placeholder="Find issue..."
icon={<SearchIcon size={20} />} icon={<SearchIcon size={20} />}
size={32} size={32}
value={issuesSearchText} value={searchFilterValue}
onChangeText={setIssuesSearchText} onChangeText={handleSearchFilter}
variant="retractable" variant="retractable"
direction={currentBreakpoint === '2xl' ? 'rtl' : 'ltr'} direction={currentBreakpoint === '2xl' ? 'rtl' : 'ltr'}
/> />
</div> </div>
<DropdownFilter <DropdownFilter
data={authors} onSelectedValuesChange={handleSelectedAuthors}
selectedValues={selectedAuthors}
data={
filters?.authors.map(author => {
return {
id: author.author || '',
name: author.author || '',
}
}) || []
}
label="Author" label="Author"
placeholder="Find author" placeholder="Find author "
/> />
<DropdownFilter <DropdownFilter
data={epics} onSelectedValuesChange={handleSelectedAssignees}
label="Epics" selectedValues={selectedAssignees}
placeholder="Find epic" data={
/> filters?.assignees.map(assignee => {
<DropdownFilter return {
data={labels} id: assignee.assignee || '',
label="Labels" name: assignee.assignee || '',
placeholder="Find label" }
/> }) || []
<DropdownFilter }
data={assignees}
label="Assignee" label="Assignee"
placeholder="Find assignee" placeholder="Find assignee"
/> />
<DropdownFilter <DropdownFilter
data={repositories} onSelectedValuesChange={handleSelectedRepos}
selectedValues={selectedRepos}
data={
filters?.repos.map(repo => {
return {
id: repo.repository || '',
name: repo.repository || '',
}
}) || []
}
label="Repos" label="Repos"
placeholder="Find repo" placeholder="Find repo"
/> />
</div> </div>
<div className="pl-2"> <div className="pl-2">
<DropdownSort data={sortOptions} /> <DropdownSort
data={sortOptions}
onOrderByValueChange={handleOrderByValue}
orderByValue={orderByValue}
/>
</div> </div>
</div> </div>
</div> </div>
@ -275,47 +157,78 @@ const TableIssues = () => {
</div> </div>
</div> </div>
<div className="divide-y divide-neutral-10"> <div className="relative divide-y divide-neutral-10">
{issues.map(issue => ( {data.length !== 0 &&
data.map(issue => (
<Link <Link
key={issue.id} key={issue.issue_number}
href={`https://github.com/status-im/status-react/issues/${issue.id}`} href={issue.issue_url || ''}
className="flex items-center justify-between px-4 py-3 transition-colors duration-200 hover:bg-neutral-5" className="flex items-center justify-between px-4 py-3 transition-colors duration-200 hover:bg-neutral-5"
> >
<div className="flex flex-col"> <div className="flex flex-row items-start gap-2 ">
<Text size={15} weight="medium"> <div className="pt-1">
{issue.stage === 'open' ? (
<OpenIcon size={20} color="$neutral-50" />
) : (
<ActiveMembersIcon size={20} color="$neutral-50" />
)}
</div>
<div className="flex max-w-lg flex-col">
<Text size={15} weight="medium" truncate>
{issue.title} {issue.title}
</Text> </Text>
<Text size={13} color="$neutral-50"> <Text size={13} color="$neutral-50">
#9667 Opened 2 days ago by slaedjenic #{issue.issue_number} {' '}
{formatDistanceToNow(new Date(issue.created_at), {
addSuffix: true,
})}{' '}
by {issue.author}
</Text> </Text>
</div> </div>
</div>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex gap-1"> {'epic_name' in issue && issue.epic_name && (
<Tag size={24} label="E:Syncing" color="$orange-50" /> <Tag
<Tag size={24} label="E:Wallet" color="$green-50" />
<Tag size={24} label="Feature" color="$pink-50" />
<Tag size={24} label="Web" color="$purple-50" />
</div>
<Tag size={24} label="9435" />
<Avatar
type="user"
size={24} size={24}
name="jkbktl" label={issue.epic_name || ''}
src="https://images.unsplash.com/photo-1552058544-f2b08422138a?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1299&q=80" color={`#${issue.epic_color}` || '$primary'}
/> />
)}
{'labels' in issue &&
issue.labels &&
JSON.parse(issue.labels).map(
(label: { id: string; name: string; color: string }) => (
<Tag
key={label.id}
size={24}
label={label.name}
color={`#${label.color}`}
/>
)
)}
<Avatar type="user" size={24} name={issue.author || ''} />
</div> </div>
</Link> </Link>
))} ))}
{data.length === 0 && !isLoading && (
<div className="py-11">
<Empty />
</div> </div>
)}
<div className="p-3"> {isLoading && (
<Button size={40} variant="outline"> <div className="flex items-center justify-between px-4 py-3">
Show more 10 <div className="flex h-10 grow flex-col justify-between">
</Button> <Skeleton width={340} height={18} />
<Skeleton width={200} height={12} />
</div>
<div className="flex flex-auto flex-row justify-end gap-2">
<Skeleton width={85} height={24} />
<Skeleton width={24} height={24} />
</div>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 300)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
import type { RefObject } from 'react'
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean
}
export function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0,
root = null,
rootMargin = '0%',
freezeOnceVisible = false,
}: Args
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>()
const frozen = entry?.isIntersecting && freezeOnceVisible
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry)
}
useEffect(() => {
const node = elementRef?.current // DOM Ref
const hasIOSupport = !!window.IntersectionObserver
if (!hasIOSupport || frozen || !node) return
const observerParams = { threshold, root, rootMargin }
const observer = new IntersectionObserver(updateEntry, observerParams)
observer.observe(node)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, JSON.stringify(threshold), root, rootMargin, frozen])
return entry
}

View File

@ -0,0 +1,85 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
const TIMEOUT = 600
type Props = {
initialVisible?: boolean
defaultHeight?: number
visibleOffset?: number
disabled?: boolean
root?: HTMLElement | null
rootElement?: string
rootElementClass?: string
children: React.ReactNode
placeholderComponent?: React.ReactNode
}
export const RenderIfVisible = ({
initialVisible = false,
defaultHeight = 300,
visibleOffset = 1000,
disabled = false,
root = null,
rootElement = 'div',
rootElementClass = '',
placeholderComponent: PlaceholderComponent,
children,
}: Props) => {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible)
const wasVisible = useRef<boolean>(initialVisible)
const placeholderHeight = useRef<number>(defaultHeight)
const intersectionRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (intersectionRef.current) {
const localRef = intersectionRef.current
const observer = new IntersectionObserver(
entries => {
if (!entries[0].isIntersecting) {
placeholderHeight.current = localRef!.offsetHeight
}
if (typeof window !== undefined && window.requestIdleCallback) {
window.requestIdleCallback(
() => setIsVisible(entries[0].isIntersecting),
{
timeout: TIMEOUT,
}
)
} else {
setIsVisible(entries[0].isIntersecting)
}
},
{ root, rootMargin: `${visibleOffset}px 0px ${visibleOffset}px 0px` }
)
observer.observe(localRef)
return () => {
if (localRef) {
observer.unobserve(localRef)
}
}
}
}, [root, visibleOffset])
useEffect(() => {
if (isVisible) {
wasVisible.current = true
}
}, [isVisible])
const rootClasses = useMemo(
() => `renderIfVisible ${rootElementClass}`,
[rootElementClass]
)
return React.createElement(
rootElement,
{
ref: intersectionRef,
className: rootClasses,
},
isVisible || (disabled && wasVisible.current)
? children
: PlaceholderComponent || null
)
}

View File

@ -1,74 +1,7 @@
import { SidebarMenu } from '../components/sidebar-menu' import { SidebarMenu } from '../components'
import { AppLayout } from './app-layout' import { AppLayout } from './app-layout'
// Eventually this will be fetched from the API, at least the nested links const STATIC_LINKS = [
const MENU_LINKS = [
{
label: 'Epics',
links: [
{
label: 'Overview',
href: '/insights/epics',
},
{
label: 'Community Protocol',
href: '/insights/epics/1',
},
{
label: 'Keycard',
href: '/insights/epics/keycard',
},
{
label: 'Notifications Settings',
href: '/insights/epics/notifications-settings',
},
{
label: 'Wallet',
href: '/insights/epics/wallet',
},
{
label: 'Communities',
href: '/insights/epics/communities',
},
{
label: 'Acitivity Center',
href: '/insights/epics/activity-center',
},
],
},
{
label: 'Workstreams',
links: [
{
label: 'Overview',
href: '/insights/workstreams',
},
{
label: 'Community Protocol 2',
href: '/insights/workstreams/community-protocol-2',
},
{
label: 'Keycard 2',
href: '/insights/workstreams/keycard-2',
},
{
label: 'Notifications Settings 2',
href: '/insights/workstreams/notifications-settings-2',
},
{
label: 'Wallet 2',
href: '/insights/workstreams/wallet-2',
},
{
label: 'Communities 2',
href: '/insights/workstreams/communities-2',
},
{
label: 'Acitivity Center 2',
href: '/insights/workstreams/activity-center-2',
},
],
},
{ {
label: 'Orphans', label: 'Orphans',
href: '/insights/orphans', href: '/insights/orphans',
@ -81,14 +14,40 @@ const MENU_LINKS = [
interface InsightsLayoutProps { interface InsightsLayoutProps {
children: React.ReactNode children: React.ReactNode
links: string[]
} }
export const InsightsLayout: React.FC<InsightsLayoutProps> = ({ children }) => { export const InsightsLayout: React.FC<InsightsLayoutProps> = ({
children,
links: linksFromProps,
}) => {
const epicLinks =
linksFromProps?.map(epic => {
return {
label: epic || '',
href: `/insights/epics/${epic}`,
}
}) || []
const links = [
{
label: 'Epics',
links: [
{
label: 'Overview',
href: '/insights/epics',
},
...epicLinks,
],
},
...STATIC_LINKS,
]
return ( return (
<AppLayout hasPreFooter={false}> <AppLayout hasPreFooter={false}>
<div className="relative mx-1 flex min-h-[calc(100vh-56px-4px)] w-full rounded-3xl bg-white-100"> <div className="relative flex min-h-[calc(100vh-56px-4px)] w-full rounded-3xl bg-white-100">
<SidebarMenu items={MENU_LINKS} /> {<SidebarMenu items={links} />}
<main className="flex-1">{children}</main> <main className="flex-1 pb-8">{children}</main>
</div> </div>
</AppLayout> </AppLayout>
) )

View File

@ -0,0 +1,178 @@
export const GET_BURNUP = /* GraphQL */ `
query getBurnup($epicNames: [String!], $from: timestamptz, $to: timestamptz) {
gh_burnup(
where: {
epic_name: { _in: $epicNames }
_or: [
{
_and: [
{ date_field: { _gte: $from } }
{ date_field: { _lt: $to } }
]
}
{
_and: [
{ date_field: { _gt: $from } }
{ date_field: { _lte: $to } }
]
}
]
}
order_by: { date_field: asc }
) {
epic_name
total_closed_issues
total_opened_issues
date_field
}
}
`
export const GET_ISSUES_BY_EPIC = /* GraphQL */ `
query getIssuesByEpic(
$where: gh_epic_issues_bool_exp!
$limit: Int!
$offset: Int!
$orderBy: order_by
) {
gh_epic_issues(
where: $where
order_by: { created_at: $orderBy }
limit: $limit
offset: $offset
) {
assignee
author
closed_at
created_at
epic_color
epic_name
repository
stage
title
issue_number
issue_url
}
}
`
export const GET_EPIC_ISSUES_COUNT = /* GraphQL */ `
query getEpicIssuesCount($where: gh_epic_issues_bool_exp!) {
gh_epic_issues(where: $where) {
closed_at
}
}
`
export const GET_FILTERS_WITH_EPIC = /* GraphQL */ `
query getFiltersWithEpic($epicName: String!) {
authors: gh_epic_issues(
where: { epic_name: { _eq: $epicName }, author: { _is_null: false } }
distinct_on: author
) {
author
}
assignees: gh_epic_issues(
where: { epic_name: { _eq: $epicName }, assignee: { _is_null: false } }
distinct_on: assignee
) {
assignee
}
repos: gh_epic_issues(
where: { epic_name: { _eq: $epicName } }
distinct_on: repository
) {
repository
}
}
`
export const GET_EPIC_LINKS = /* GraphQL */ `
query getEpicMenuLinks(
$where: gh_epics_bool_exp
$orderBy: [gh_epics_order_by!]
$limit: Int
$offset: Int
) {
gh_epics(
where: $where
order_by: $orderBy
limit: $limit
offset: $offset
distinct_on: epic_name
) {
epic_name
epic_color
epic_description
status
}
}
`
export const GET_REPOS = /* GraphQL */ `
query getRepositories {
gh_repositories {
description
full_name
name
open_issues_count
stargazers_count
visibility
}
}
`
export const GET_ORPHANS = /* GraphQL */ `
query getOrphans(
$where: gh_orphans_bool_exp!
$limit: Int!
$offset: Int!
$orderBy: order_by
) {
gh_orphans(
where: $where
order_by: { created_at: $orderBy }
limit: $limit
offset: $offset
) {
labels
assignee
author
issue_number
issue_url
created_at
closed_at
repository
stage
title
}
}
`
export const GET_ORPHANS_COUNT = /* GraphQL */ `
query getOrphansCount($where: gh_orphans_bool_exp!) {
gh_orphans(where: $where) {
closed_at
}
}
`
export const GET_FILTERS_FOR_ORPHANS = /* GraphQL */ `
query getFiltersForOrphans {
authors: gh_orphans(
where: { author: { _is_null: false } }
distinct_on: author
) {
author
}
assignees: gh_orphans(
where: { assignee: { _is_null: false } }
distinct_on: assignee
) {
assignee
}
repos: gh_orphans(distinct_on: repository) {
repository
}
}
`

View File

@ -0,0 +1,27 @@
import { GraphQLClient } from 'graphql-request'
import type { RequestDocument, Variables } from 'graphql-request'
export const GRAPHQL_ENDPOINT = `https://hasura.infra.status.im/v1/graphql`
export const api = <T, V extends Variables = Variables>(
operation: RequestDocument,
variables?: V,
headers?: Record<string, string>
): Promise<T> => {
const client = new GraphQLClient(GRAPHQL_ENDPOINT, {
headers: {
'Content-Type': 'application/json',
...headers,
},
})
return client.request<T>(operation, variables as Variables)
}
export const createFetcher = <T, V extends Variables = Variables>(
operation: string,
variables?: V
) => {
return () => api<T, V>(operation, variables)
}

View File

@ -0,0 +1,327 @@
import { useQuery } from '@tanstack/react-query'
import { createFetcher } from '../api'
import type * as Types from './operations'
import type { UseQueryOptions } from '@tanstack/react-query'
export const GetBurnupDocument = `
query getBurnup($epicNames: [String!], $from: timestamptz, $to: timestamptz) {
gh_burnup(
where: {epic_name: {_in: $epicNames}, _or: [{_and: [{date_field: {_gte: $from}}, {date_field: {_lt: $to}}]}, {_and: [{date_field: {_gt: $from}}, {date_field: {_lte: $to}}]}]}
order_by: {date_field: asc}
) {
epic_name
total_closed_issues
total_opened_issues
date_field
}
}
`
export const useGetBurnupQuery = <
TData = Types.GetBurnupQuery,
TError = GraphqlApiError
>(
variables?: Types.GetBurnupQueryVariables,
options?: UseQueryOptions<Types.GetBurnupQuery, TError, TData>
) =>
useQuery<Types.GetBurnupQuery, TError, TData>(
variables === undefined ? ['getBurnup'] : ['getBurnup', variables],
createFetcher<Types.GetBurnupQuery, Types.GetBurnupQueryVariables>(
GetBurnupDocument,
variables
),
options
)
useGetBurnupQuery.getKey = (variables?: Types.GetBurnupQueryVariables) =>
variables === undefined ? ['getBurnup'] : ['getBurnup', variables]
export const GetIssuesByEpicDocument = `
query getIssuesByEpic($where: gh_epic_issues_bool_exp!, $limit: Int!, $offset: Int!, $orderBy: order_by) {
gh_epic_issues(
where: $where
order_by: {created_at: $orderBy}
limit: $limit
offset: $offset
) {
assignee
author
closed_at
created_at
epic_color
epic_name
repository
stage
title
issue_number
issue_url
}
}
`
export const useGetIssuesByEpicQuery = <
TData = Types.GetIssuesByEpicQuery,
TError = GraphqlApiError
>(
variables: Types.GetIssuesByEpicQueryVariables,
options?: UseQueryOptions<Types.GetIssuesByEpicQuery, TError, TData>
) =>
useQuery<Types.GetIssuesByEpicQuery, TError, TData>(
['getIssuesByEpic', variables],
createFetcher<
Types.GetIssuesByEpicQuery,
Types.GetIssuesByEpicQueryVariables
>(GetIssuesByEpicDocument, variables),
options
)
useGetIssuesByEpicQuery.getKey = (
variables: Types.GetIssuesByEpicQueryVariables
) => ['getIssuesByEpic', variables]
export const GetEpicIssuesCountDocument = `
query getEpicIssuesCount($where: gh_epic_issues_bool_exp!) {
gh_epic_issues(where: $where) {
closed_at
}
}
`
export const useGetEpicIssuesCountQuery = <
TData = Types.GetEpicIssuesCountQuery,
TError = GraphqlApiError
>(
variables: Types.GetEpicIssuesCountQueryVariables,
options?: UseQueryOptions<Types.GetEpicIssuesCountQuery, TError, TData>
) =>
useQuery<Types.GetEpicIssuesCountQuery, TError, TData>(
['getEpicIssuesCount', variables],
createFetcher<
Types.GetEpicIssuesCountQuery,
Types.GetEpicIssuesCountQueryVariables
>(GetEpicIssuesCountDocument, variables),
options
)
useGetEpicIssuesCountQuery.getKey = (
variables: Types.GetEpicIssuesCountQueryVariables
) => ['getEpicIssuesCount', variables]
export const GetFiltersWithEpicDocument = `
query getFiltersWithEpic($epicName: String!) {
authors: gh_epic_issues(
where: {epic_name: {_eq: $epicName}, author: {_is_null: false}}
distinct_on: author
) {
author
}
assignees: gh_epic_issues(
where: {epic_name: {_eq: $epicName}, assignee: {_is_null: false}}
distinct_on: assignee
) {
assignee
}
repos: gh_epic_issues(
where: {epic_name: {_eq: $epicName}}
distinct_on: repository
) {
repository
}
}
`
export const useGetFiltersWithEpicQuery = <
TData = Types.GetFiltersWithEpicQuery,
TError = GraphqlApiError
>(
variables: Types.GetFiltersWithEpicQueryVariables,
options?: UseQueryOptions<Types.GetFiltersWithEpicQuery, TError, TData>
) =>
useQuery<Types.GetFiltersWithEpicQuery, TError, TData>(
['getFiltersWithEpic', variables],
createFetcher<
Types.GetFiltersWithEpicQuery,
Types.GetFiltersWithEpicQueryVariables
>(GetFiltersWithEpicDocument, variables),
options
)
useGetFiltersWithEpicQuery.getKey = (
variables: Types.GetFiltersWithEpicQueryVariables
) => ['getFiltersWithEpic', variables]
export const GetEpicMenuLinksDocument = `
query getEpicMenuLinks($where: gh_epics_bool_exp, $orderBy: [gh_epics_order_by!], $limit: Int, $offset: Int) {
gh_epics(
where: $where
order_by: $orderBy
limit: $limit
offset: $offset
distinct_on: epic_name
) {
epic_name
epic_color
epic_description
status
}
}
`
export const useGetEpicMenuLinksQuery = <
TData = Types.GetEpicMenuLinksQuery,
TError = GraphqlApiError
>(
variables?: Types.GetEpicMenuLinksQueryVariables,
options?: UseQueryOptions<Types.GetEpicMenuLinksQuery, TError, TData>
) =>
useQuery<Types.GetEpicMenuLinksQuery, TError, TData>(
variables === undefined
? ['getEpicMenuLinks']
: ['getEpicMenuLinks', variables],
createFetcher<
Types.GetEpicMenuLinksQuery,
Types.GetEpicMenuLinksQueryVariables
>(GetEpicMenuLinksDocument, variables),
options
)
useGetEpicMenuLinksQuery.getKey = (
variables?: Types.GetEpicMenuLinksQueryVariables
) =>
variables === undefined
? ['getEpicMenuLinks']
: ['getEpicMenuLinks', variables]
export const GetRepositoriesDocument = `
query getRepositories {
gh_repositories {
description
full_name
name
open_issues_count
stargazers_count
visibility
}
}
`
export const useGetRepositoriesQuery = <
TData = Types.GetRepositoriesQuery,
TError = GraphqlApiError
>(
variables?: Types.GetRepositoriesQueryVariables,
options?: UseQueryOptions<Types.GetRepositoriesQuery, TError, TData>
) =>
useQuery<Types.GetRepositoriesQuery, TError, TData>(
variables === undefined
? ['getRepositories']
: ['getRepositories', variables],
createFetcher<
Types.GetRepositoriesQuery,
Types.GetRepositoriesQueryVariables
>(GetRepositoriesDocument, variables),
options
)
useGetRepositoriesQuery.getKey = (
variables?: Types.GetRepositoriesQueryVariables
) =>
variables === undefined ? ['getRepositories'] : ['getRepositories', variables]
export const GetOrphansDocument = `
query getOrphans($where: gh_orphans_bool_exp!, $limit: Int!, $offset: Int!, $orderBy: order_by) {
gh_orphans(
where: $where
order_by: {created_at: $orderBy}
limit: $limit
offset: $offset
) {
labels
assignee
author
issue_number
issue_url
created_at
closed_at
repository
stage
title
}
}
`
export const useGetOrphansQuery = <
TData = Types.GetOrphansQuery,
TError = GraphqlApiError
>(
variables: Types.GetOrphansQueryVariables,
options?: UseQueryOptions<Types.GetOrphansQuery, TError, TData>
) =>
useQuery<Types.GetOrphansQuery, TError, TData>(
['getOrphans', variables],
createFetcher<Types.GetOrphansQuery, Types.GetOrphansQueryVariables>(
GetOrphansDocument,
variables
),
options
)
useGetOrphansQuery.getKey = (variables: Types.GetOrphansQueryVariables) => [
'getOrphans',
variables,
]
export const GetOrphansCountDocument = `
query getOrphansCount($where: gh_orphans_bool_exp!) {
gh_orphans(where: $where) {
closed_at
}
}
`
export const useGetOrphansCountQuery = <
TData = Types.GetOrphansCountQuery,
TError = GraphqlApiError
>(
variables: Types.GetOrphansCountQueryVariables,
options?: UseQueryOptions<Types.GetOrphansCountQuery, TError, TData>
) =>
useQuery<Types.GetOrphansCountQuery, TError, TData>(
['getOrphansCount', variables],
createFetcher<
Types.GetOrphansCountQuery,
Types.GetOrphansCountQueryVariables
>(GetOrphansCountDocument, variables),
options
)
useGetOrphansCountQuery.getKey = (
variables: Types.GetOrphansCountQueryVariables
) => ['getOrphansCount', variables]
export const GetFiltersForOrphansDocument = `
query getFiltersForOrphans {
authors: gh_orphans(where: {author: {_is_null: false}}, distinct_on: author) {
author
}
assignees: gh_orphans(
where: {assignee: {_is_null: false}}
distinct_on: assignee
) {
assignee
}
repos: gh_orphans(distinct_on: repository) {
repository
}
}
`
export const useGetFiltersForOrphansQuery = <
TData = Types.GetFiltersForOrphansQuery,
TError = GraphqlApiError
>(
variables?: Types.GetFiltersForOrphansQueryVariables,
options?: UseQueryOptions<Types.GetFiltersForOrphansQuery, TError, TData>
) =>
useQuery<Types.GetFiltersForOrphansQuery, TError, TData>(
variables === undefined
? ['getFiltersForOrphans']
: ['getFiltersForOrphans', variables],
createFetcher<
Types.GetFiltersForOrphansQuery,
Types.GetFiltersForOrphansQueryVariables
>(GetFiltersForOrphansDocument, variables),
options
)
useGetFiltersForOrphansQuery.getKey = (
variables?: Types.GetFiltersForOrphansQueryVariables
) =>
variables === undefined
? ['getFiltersForOrphans']
: ['getFiltersForOrphans', variables]

View File

@ -0,0 +1,149 @@
import type * as Types from './schemas'
export type GetBurnupQueryVariables = Types.Exact<{
epicNames?: Types.InputMaybe<
Array<Types.Scalars['String']['input']> | Types.Scalars['String']['input']
>
from?: Types.InputMaybe<Types.Scalars['timestamptz']['input']>
to?: Types.InputMaybe<Types.Scalars['timestamptz']['input']>
}>
export type GetBurnupQuery = {
__typename?: 'query_root'
gh_burnup: Array<{
__typename?: 'gh_burnup'
epic_name?: string | null
total_closed_issues?: any | null
total_opened_issues?: any | null
date_field?: any | null
}>
}
export type GetIssuesByEpicQueryVariables = Types.Exact<{
where: Types.Gh_Epic_Issues_Bool_Exp
limit: Types.Scalars['Int']['input']
offset: Types.Scalars['Int']['input']
orderBy?: Types.InputMaybe<Types.Order_By>
}>
export type GetIssuesByEpicQuery = {
__typename?: 'query_root'
gh_epic_issues: Array<{
__typename?: 'gh_epic_issues'
assignee?: string | null
author?: string | null
closed_at?: any | null
created_at?: any | null
epic_color?: string | null
epic_name?: string | null
repository?: string | null
stage?: string | null
title?: string | null
issue_number?: any | null
issue_url?: string | null
}>
}
export type GetEpicIssuesCountQueryVariables = Types.Exact<{
where: Types.Gh_Epic_Issues_Bool_Exp
}>
export type GetEpicIssuesCountQuery = {
__typename?: 'query_root'
gh_epic_issues: Array<{
__typename?: 'gh_epic_issues'
closed_at?: any | null
}>
}
export type GetFiltersWithEpicQueryVariables = Types.Exact<{
epicName: Types.Scalars['String']['input']
}>
export type GetFiltersWithEpicQuery = {
__typename?: 'query_root'
authors: Array<{ __typename?: 'gh_epic_issues'; author?: string | null }>
assignees: Array<{ __typename?: 'gh_epic_issues'; assignee?: string | null }>
repos: Array<{ __typename?: 'gh_epic_issues'; repository?: string | null }>
}
export type GetEpicMenuLinksQueryVariables = Types.Exact<{
where?: Types.InputMaybe<Types.Gh_Epics_Bool_Exp>
orderBy?: Types.InputMaybe<
Array<Types.Gh_Epics_Order_By> | Types.Gh_Epics_Order_By
>
limit?: Types.InputMaybe<Types.Scalars['Int']['input']>
offset?: Types.InputMaybe<Types.Scalars['Int']['input']>
}>
export type GetEpicMenuLinksQuery = {
__typename?: 'query_root'
gh_epics: Array<{
__typename?: 'gh_epics'
epic_name?: string | null
epic_color?: string | null
epic_description?: string | null
status?: string | null
}>
}
export type GetRepositoriesQueryVariables = Types.Exact<{
[key: string]: never
}>
export type GetRepositoriesQuery = {
__typename?: 'query_root'
gh_repositories: Array<{
__typename?: 'gh_repositories'
description?: string | null
full_name?: string | null
name?: string | null
open_issues_count?: any | null
stargazers_count?: any | null
visibility?: string | null
}>
}
export type GetOrphansQueryVariables = Types.Exact<{
where: Types.Gh_Orphans_Bool_Exp
limit: Types.Scalars['Int']['input']
offset: Types.Scalars['Int']['input']
orderBy?: Types.InputMaybe<Types.Order_By>
}>
export type GetOrphansQuery = {
__typename?: 'query_root'
gh_orphans: Array<{
__typename?: 'gh_orphans'
labels?: string | null
assignee?: string | null
author?: string | null
issue_number?: any | null
issue_url?: string | null
created_at?: any | null
closed_at?: any | null
repository?: string | null
stage?: string | null
title?: string | null
}>
}
export type GetOrphansCountQueryVariables = Types.Exact<{
where: Types.Gh_Orphans_Bool_Exp
}>
export type GetOrphansCountQuery = {
__typename?: 'query_root'
gh_orphans: Array<{ __typename?: 'gh_orphans'; closed_at?: any | null }>
}
export type GetFiltersForOrphansQueryVariables = Types.Exact<{
[key: string]: never
}>
export type GetFiltersForOrphansQuery = {
__typename?: 'query_root'
authors: Array<{ __typename?: 'gh_orphans'; author?: string | null }>
assignees: Array<{ __typename?: 'gh_orphans'; assignee?: string | null }>
repos: Array<{ __typename?: 'gh_orphans'; repository?: string | null }>
}

View File

@ -0,0 +1,775 @@
export type Maybe<T> = T | null
export type InputMaybe<T> = Maybe<T>
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]: T[K]
}
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]?: Maybe<T[SubKey]>
}
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]: Maybe<T[SubKey]>
}
export type MakeEmpty<
T extends { [key: string]: unknown },
K extends keyof T
> = { [_ in K]?: never }
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never
}
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string }
String: { input: string; output: string }
Boolean: { input: boolean; output: boolean }
Int: { input: number; output: number }
Float: { input: number; output: number }
bigint: { input: any; output: any }
timestamptz: { input: any; output: any }
}
/** Boolean expression to compare columns of type "String". All fields are combined with logical 'AND'. */
export type String_Comparison_Exp = {
_eq?: InputMaybe<Scalars['String']['input']>
_gt?: InputMaybe<Scalars['String']['input']>
_gte?: InputMaybe<Scalars['String']['input']>
/** does the column match the given case-insensitive pattern */
_ilike?: InputMaybe<Scalars['String']['input']>
_in?: InputMaybe<Array<Scalars['String']['input']>>
/** does the column match the given POSIX regular expression, case insensitive */
_iregex?: InputMaybe<Scalars['String']['input']>
_is_null?: InputMaybe<Scalars['Boolean']['input']>
/** does the column match the given pattern */
_like?: InputMaybe<Scalars['String']['input']>
_lt?: InputMaybe<Scalars['String']['input']>
_lte?: InputMaybe<Scalars['String']['input']>
_neq?: InputMaybe<Scalars['String']['input']>
/** does the column NOT match the given case-insensitive pattern */
_nilike?: InputMaybe<Scalars['String']['input']>
_nin?: InputMaybe<Array<Scalars['String']['input']>>
/** does the column NOT match the given POSIX regular expression, case insensitive */
_niregex?: InputMaybe<Scalars['String']['input']>
/** does the column NOT match the given pattern */
_nlike?: InputMaybe<Scalars['String']['input']>
/** does the column NOT match the given POSIX regular expression, case sensitive */
_nregex?: InputMaybe<Scalars['String']['input']>
/** does the column NOT match the given SQL regular expression */
_nsimilar?: InputMaybe<Scalars['String']['input']>
/** does the column match the given POSIX regular expression, case sensitive */
_regex?: InputMaybe<Scalars['String']['input']>
/** does the column match the given SQL regular expression */
_similar?: InputMaybe<Scalars['String']['input']>
}
/** Boolean expression to compare columns of type "bigint". All fields are combined with logical 'AND'. */
export type Bigint_Comparison_Exp = {
_eq?: InputMaybe<Scalars['bigint']['input']>
_gt?: InputMaybe<Scalars['bigint']['input']>
_gte?: InputMaybe<Scalars['bigint']['input']>
_in?: InputMaybe<Array<Scalars['bigint']['input']>>
_is_null?: InputMaybe<Scalars['Boolean']['input']>
_lt?: InputMaybe<Scalars['bigint']['input']>
_lte?: InputMaybe<Scalars['bigint']['input']>
_neq?: InputMaybe<Scalars['bigint']['input']>
_nin?: InputMaybe<Array<Scalars['bigint']['input']>>
}
/** ordering argument of a cursor */
export enum Cursor_Ordering {
/** ascending ordering of the cursor */
Asc = 'ASC',
/** descending ordering of the cursor */
Desc = 'DESC',
}
/** columns and relationships of "gh_burnup" */
export type Gh_Burnup = {
__typename?: 'gh_burnup'
date_field?: Maybe<Scalars['timestamptz']['output']>
epic_name?: Maybe<Scalars['String']['output']>
total_closed_issues?: Maybe<Scalars['bigint']['output']>
total_opened_issues?: Maybe<Scalars['bigint']['output']>
}
/** Boolean expression to filter rows from the table "gh_burnup". All fields are combined with a logical 'AND'. */
export type Gh_Burnup_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Burnup_Bool_Exp>>
_not?: InputMaybe<Gh_Burnup_Bool_Exp>
_or?: InputMaybe<Array<Gh_Burnup_Bool_Exp>>
date_field?: InputMaybe<Timestamptz_Comparison_Exp>
epic_name?: InputMaybe<String_Comparison_Exp>
total_closed_issues?: InputMaybe<Bigint_Comparison_Exp>
total_opened_issues?: InputMaybe<Bigint_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_burnup". */
export type Gh_Burnup_Order_By = {
date_field?: InputMaybe<Order_By>
epic_name?: InputMaybe<Order_By>
total_closed_issues?: InputMaybe<Order_By>
total_opened_issues?: InputMaybe<Order_By>
}
/** select columns of table "gh_burnup" */
export enum Gh_Burnup_Select_Column {
/** column name */
DateField = 'date_field',
/** column name */
EpicName = 'epic_name',
/** column name */
TotalClosedIssues = 'total_closed_issues',
/** column name */
TotalOpenedIssues = 'total_opened_issues',
}
/** Streaming cursor of the table "gh_burnup" */
export type Gh_Burnup_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Burnup_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Burnup_Stream_Cursor_Value_Input = {
date_field?: InputMaybe<Scalars['timestamptz']['input']>
epic_name?: InputMaybe<Scalars['String']['input']>
total_closed_issues?: InputMaybe<Scalars['bigint']['input']>
total_opened_issues?: InputMaybe<Scalars['bigint']['input']>
}
/** columns and relationships of "gh_epic_issues" */
export type Gh_Epic_Issues = {
__typename?: 'gh_epic_issues'
assignee?: Maybe<Scalars['String']['output']>
author?: Maybe<Scalars['String']['output']>
closed_at?: Maybe<Scalars['timestamptz']['output']>
created_at?: Maybe<Scalars['timestamptz']['output']>
epic_color?: Maybe<Scalars['String']['output']>
epic_name?: Maybe<Scalars['String']['output']>
issue_number?: Maybe<Scalars['bigint']['output']>
issue_url?: Maybe<Scalars['String']['output']>
labels?: Maybe<Scalars['String']['output']>
repository?: Maybe<Scalars['String']['output']>
stage?: Maybe<Scalars['String']['output']>
title?: Maybe<Scalars['String']['output']>
}
/** Boolean expression to filter rows from the table "gh_epic_issues". All fields are combined with a logical 'AND'. */
export type Gh_Epic_Issues_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Epic_Issues_Bool_Exp>>
_not?: InputMaybe<Gh_Epic_Issues_Bool_Exp>
_or?: InputMaybe<Array<Gh_Epic_Issues_Bool_Exp>>
assignee?: InputMaybe<String_Comparison_Exp>
author?: InputMaybe<String_Comparison_Exp>
closed_at?: InputMaybe<Timestamptz_Comparison_Exp>
created_at?: InputMaybe<Timestamptz_Comparison_Exp>
epic_color?: InputMaybe<String_Comparison_Exp>
epic_name?: InputMaybe<String_Comparison_Exp>
issue_number?: InputMaybe<Bigint_Comparison_Exp>
issue_url?: InputMaybe<String_Comparison_Exp>
labels?: InputMaybe<String_Comparison_Exp>
repository?: InputMaybe<String_Comparison_Exp>
stage?: InputMaybe<String_Comparison_Exp>
title?: InputMaybe<String_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_epic_issues". */
export type Gh_Epic_Issues_Order_By = {
assignee?: InputMaybe<Order_By>
author?: InputMaybe<Order_By>
closed_at?: InputMaybe<Order_By>
created_at?: InputMaybe<Order_By>
epic_color?: InputMaybe<Order_By>
epic_name?: InputMaybe<Order_By>
issue_number?: InputMaybe<Order_By>
issue_url?: InputMaybe<Order_By>
labels?: InputMaybe<Order_By>
repository?: InputMaybe<Order_By>
stage?: InputMaybe<Order_By>
title?: InputMaybe<Order_By>
}
/** select columns of table "gh_epic_issues" */
export enum Gh_Epic_Issues_Select_Column {
/** column name */
Assignee = 'assignee',
/** column name */
Author = 'author',
/** column name */
ClosedAt = 'closed_at',
/** column name */
CreatedAt = 'created_at',
/** column name */
EpicColor = 'epic_color',
/** column name */
EpicName = 'epic_name',
/** column name */
IssueNumber = 'issue_number',
/** column name */
IssueUrl = 'issue_url',
/** column name */
Labels = 'labels',
/** column name */
Repository = 'repository',
/** column name */
Stage = 'stage',
/** column name */
Title = 'title',
}
/** Streaming cursor of the table "gh_epic_issues" */
export type Gh_Epic_Issues_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Epic_Issues_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Epic_Issues_Stream_Cursor_Value_Input = {
assignee?: InputMaybe<Scalars['String']['input']>
author?: InputMaybe<Scalars['String']['input']>
closed_at?: InputMaybe<Scalars['timestamptz']['input']>
created_at?: InputMaybe<Scalars['timestamptz']['input']>
epic_color?: InputMaybe<Scalars['String']['input']>
epic_name?: InputMaybe<Scalars['String']['input']>
issue_number?: InputMaybe<Scalars['bigint']['input']>
issue_url?: InputMaybe<Scalars['String']['input']>
labels?: InputMaybe<Scalars['String']['input']>
repository?: InputMaybe<Scalars['String']['input']>
stage?: InputMaybe<Scalars['String']['input']>
title?: InputMaybe<Scalars['String']['input']>
}
/** columns and relationships of "gh_epics" */
export type Gh_Epics = {
__typename?: 'gh_epics'
closed_count?: Maybe<Scalars['bigint']['output']>
epic_color?: Maybe<Scalars['String']['output']>
epic_description?: Maybe<Scalars['String']['output']>
epic_name?: Maybe<Scalars['String']['output']>
opened_count?: Maybe<Scalars['bigint']['output']>
status?: Maybe<Scalars['String']['output']>
total_count?: Maybe<Scalars['bigint']['output']>
}
/** Boolean expression to filter rows from the table "gh_epics". All fields are combined with a logical 'AND'. */
export type Gh_Epics_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Epics_Bool_Exp>>
_not?: InputMaybe<Gh_Epics_Bool_Exp>
_or?: InputMaybe<Array<Gh_Epics_Bool_Exp>>
closed_count?: InputMaybe<Bigint_Comparison_Exp>
epic_color?: InputMaybe<String_Comparison_Exp>
epic_description?: InputMaybe<String_Comparison_Exp>
epic_name?: InputMaybe<String_Comparison_Exp>
opened_count?: InputMaybe<Bigint_Comparison_Exp>
status?: InputMaybe<String_Comparison_Exp>
total_count?: InputMaybe<Bigint_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_epics". */
export type Gh_Epics_Order_By = {
closed_count?: InputMaybe<Order_By>
epic_color?: InputMaybe<Order_By>
epic_description?: InputMaybe<Order_By>
epic_name?: InputMaybe<Order_By>
opened_count?: InputMaybe<Order_By>
status?: InputMaybe<Order_By>
total_count?: InputMaybe<Order_By>
}
/** select columns of table "gh_epics" */
export enum Gh_Epics_Select_Column {
/** column name */
ClosedCount = 'closed_count',
/** column name */
EpicColor = 'epic_color',
/** column name */
EpicDescription = 'epic_description',
/** column name */
EpicName = 'epic_name',
/** column name */
OpenedCount = 'opened_count',
/** column name */
Status = 'status',
/** column name */
TotalCount = 'total_count',
}
/** Streaming cursor of the table "gh_epics" */
export type Gh_Epics_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Epics_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Epics_Stream_Cursor_Value_Input = {
closed_count?: InputMaybe<Scalars['bigint']['input']>
epic_color?: InputMaybe<Scalars['String']['input']>
epic_description?: InputMaybe<Scalars['String']['input']>
epic_name?: InputMaybe<Scalars['String']['input']>
opened_count?: InputMaybe<Scalars['bigint']['input']>
status?: InputMaybe<Scalars['String']['input']>
total_count?: InputMaybe<Scalars['bigint']['input']>
}
/** columns and relationships of "gh_issues" */
export type Gh_Issues = {
__typename?: 'gh_issues'
assignee?: Maybe<Scalars['String']['output']>
author?: Maybe<Scalars['String']['output']>
closed_at?: Maybe<Scalars['timestamptz']['output']>
created_at?: Maybe<Scalars['timestamptz']['output']>
issue_number?: Maybe<Scalars['bigint']['output']>
issue_url?: Maybe<Scalars['String']['output']>
labels?: Maybe<Scalars['String']['output']>
repository?: Maybe<Scalars['String']['output']>
stage?: Maybe<Scalars['String']['output']>
title?: Maybe<Scalars['String']['output']>
}
/** Boolean expression to filter rows from the table "gh_issues". All fields are combined with a logical 'AND'. */
export type Gh_Issues_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Issues_Bool_Exp>>
_not?: InputMaybe<Gh_Issues_Bool_Exp>
_or?: InputMaybe<Array<Gh_Issues_Bool_Exp>>
assignee?: InputMaybe<String_Comparison_Exp>
author?: InputMaybe<String_Comparison_Exp>
closed_at?: InputMaybe<Timestamptz_Comparison_Exp>
created_at?: InputMaybe<Timestamptz_Comparison_Exp>
issue_number?: InputMaybe<Bigint_Comparison_Exp>
issue_url?: InputMaybe<String_Comparison_Exp>
labels?: InputMaybe<String_Comparison_Exp>
repository?: InputMaybe<String_Comparison_Exp>
stage?: InputMaybe<String_Comparison_Exp>
title?: InputMaybe<String_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_issues". */
export type Gh_Issues_Order_By = {
assignee?: InputMaybe<Order_By>
author?: InputMaybe<Order_By>
closed_at?: InputMaybe<Order_By>
created_at?: InputMaybe<Order_By>
issue_number?: InputMaybe<Order_By>
issue_url?: InputMaybe<Order_By>
labels?: InputMaybe<Order_By>
repository?: InputMaybe<Order_By>
stage?: InputMaybe<Order_By>
title?: InputMaybe<Order_By>
}
/** select columns of table "gh_issues" */
export enum Gh_Issues_Select_Column {
/** column name */
Assignee = 'assignee',
/** column name */
Author = 'author',
/** column name */
ClosedAt = 'closed_at',
/** column name */
CreatedAt = 'created_at',
/** column name */
IssueNumber = 'issue_number',
/** column name */
IssueUrl = 'issue_url',
/** column name */
Labels = 'labels',
/** column name */
Repository = 'repository',
/** column name */
Stage = 'stage',
/** column name */
Title = 'title',
}
/** Streaming cursor of the table "gh_issues" */
export type Gh_Issues_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Issues_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Issues_Stream_Cursor_Value_Input = {
assignee?: InputMaybe<Scalars['String']['input']>
author?: InputMaybe<Scalars['String']['input']>
closed_at?: InputMaybe<Scalars['timestamptz']['input']>
created_at?: InputMaybe<Scalars['timestamptz']['input']>
issue_number?: InputMaybe<Scalars['bigint']['input']>
issue_url?: InputMaybe<Scalars['String']['input']>
labels?: InputMaybe<Scalars['String']['input']>
repository?: InputMaybe<Scalars['String']['input']>
stage?: InputMaybe<Scalars['String']['input']>
title?: InputMaybe<Scalars['String']['input']>
}
/** columns and relationships of "gh_orphans" */
export type Gh_Orphans = {
__typename?: 'gh_orphans'
assignee?: Maybe<Scalars['String']['output']>
author?: Maybe<Scalars['String']['output']>
closed_at?: Maybe<Scalars['timestamptz']['output']>
created_at?: Maybe<Scalars['timestamptz']['output']>
issue_number?: Maybe<Scalars['bigint']['output']>
issue_url?: Maybe<Scalars['String']['output']>
labels?: Maybe<Scalars['String']['output']>
repository?: Maybe<Scalars['String']['output']>
stage?: Maybe<Scalars['String']['output']>
title?: Maybe<Scalars['String']['output']>
}
/** Boolean expression to filter rows from the table "gh_orphans". All fields are combined with a logical 'AND'. */
export type Gh_Orphans_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Orphans_Bool_Exp>>
_not?: InputMaybe<Gh_Orphans_Bool_Exp>
_or?: InputMaybe<Array<Gh_Orphans_Bool_Exp>>
assignee?: InputMaybe<String_Comparison_Exp>
author?: InputMaybe<String_Comparison_Exp>
closed_at?: InputMaybe<Timestamptz_Comparison_Exp>
created_at?: InputMaybe<Timestamptz_Comparison_Exp>
issue_number?: InputMaybe<Bigint_Comparison_Exp>
issue_url?: InputMaybe<String_Comparison_Exp>
labels?: InputMaybe<String_Comparison_Exp>
repository?: InputMaybe<String_Comparison_Exp>
stage?: InputMaybe<String_Comparison_Exp>
title?: InputMaybe<String_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_orphans". */
export type Gh_Orphans_Order_By = {
assignee?: InputMaybe<Order_By>
author?: InputMaybe<Order_By>
closed_at?: InputMaybe<Order_By>
created_at?: InputMaybe<Order_By>
issue_number?: InputMaybe<Order_By>
issue_url?: InputMaybe<Order_By>
labels?: InputMaybe<Order_By>
repository?: InputMaybe<Order_By>
stage?: InputMaybe<Order_By>
title?: InputMaybe<Order_By>
}
/** select columns of table "gh_orphans" */
export enum Gh_Orphans_Select_Column {
/** column name */
Assignee = 'assignee',
/** column name */
Author = 'author',
/** column name */
ClosedAt = 'closed_at',
/** column name */
CreatedAt = 'created_at',
/** column name */
IssueNumber = 'issue_number',
/** column name */
IssueUrl = 'issue_url',
/** column name */
Labels = 'labels',
/** column name */
Repository = 'repository',
/** column name */
Stage = 'stage',
/** column name */
Title = 'title',
}
/** Streaming cursor of the table "gh_orphans" */
export type Gh_Orphans_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Orphans_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Orphans_Stream_Cursor_Value_Input = {
assignee?: InputMaybe<Scalars['String']['input']>
author?: InputMaybe<Scalars['String']['input']>
closed_at?: InputMaybe<Scalars['timestamptz']['input']>
created_at?: InputMaybe<Scalars['timestamptz']['input']>
issue_number?: InputMaybe<Scalars['bigint']['input']>
issue_url?: InputMaybe<Scalars['String']['input']>
labels?: InputMaybe<Scalars['String']['input']>
repository?: InputMaybe<Scalars['String']['input']>
stage?: InputMaybe<Scalars['String']['input']>
title?: InputMaybe<Scalars['String']['input']>
}
/** columns and relationships of "gh_repositories" */
export type Gh_Repositories = {
__typename?: 'gh_repositories'
description?: Maybe<Scalars['String']['output']>
full_name?: Maybe<Scalars['String']['output']>
name?: Maybe<Scalars['String']['output']>
open_issues_count?: Maybe<Scalars['bigint']['output']>
stargazers_count?: Maybe<Scalars['bigint']['output']>
visibility?: Maybe<Scalars['String']['output']>
}
/** Boolean expression to filter rows from the table "gh_repositories". All fields are combined with a logical 'AND'. */
export type Gh_Repositories_Bool_Exp = {
_and?: InputMaybe<Array<Gh_Repositories_Bool_Exp>>
_not?: InputMaybe<Gh_Repositories_Bool_Exp>
_or?: InputMaybe<Array<Gh_Repositories_Bool_Exp>>
description?: InputMaybe<String_Comparison_Exp>
full_name?: InputMaybe<String_Comparison_Exp>
name?: InputMaybe<String_Comparison_Exp>
open_issues_count?: InputMaybe<Bigint_Comparison_Exp>
stargazers_count?: InputMaybe<Bigint_Comparison_Exp>
visibility?: InputMaybe<String_Comparison_Exp>
}
/** Ordering options when selecting data from "gh_repositories". */
export type Gh_Repositories_Order_By = {
description?: InputMaybe<Order_By>
full_name?: InputMaybe<Order_By>
name?: InputMaybe<Order_By>
open_issues_count?: InputMaybe<Order_By>
stargazers_count?: InputMaybe<Order_By>
visibility?: InputMaybe<Order_By>
}
/** select columns of table "gh_repositories" */
export enum Gh_Repositories_Select_Column {
/** column name */
Description = 'description',
/** column name */
FullName = 'full_name',
/** column name */
Name = 'name',
/** column name */
OpenIssuesCount = 'open_issues_count',
/** column name */
StargazersCount = 'stargazers_count',
/** column name */
Visibility = 'visibility',
}
/** Streaming cursor of the table "gh_repositories" */
export type Gh_Repositories_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Gh_Repositories_Stream_Cursor_Value_Input
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>
}
/** Initial value of the column from where the streaming should start */
export type Gh_Repositories_Stream_Cursor_Value_Input = {
description?: InputMaybe<Scalars['String']['input']>
full_name?: InputMaybe<Scalars['String']['input']>
name?: InputMaybe<Scalars['String']['input']>
open_issues_count?: InputMaybe<Scalars['bigint']['input']>
stargazers_count?: InputMaybe<Scalars['bigint']['input']>
visibility?: InputMaybe<Scalars['String']['input']>
}
/** column ordering options */
export enum Order_By {
/** in ascending order, nulls last */
Asc = 'asc',
/** in ascending order, nulls first */
AscNullsFirst = 'asc_nulls_first',
/** in ascending order, nulls last */
AscNullsLast = 'asc_nulls_last',
/** in descending order, nulls first */
Desc = 'desc',
/** in descending order, nulls first */
DescNullsFirst = 'desc_nulls_first',
/** in descending order, nulls last */
DescNullsLast = 'desc_nulls_last',
}
export type Query_Root = {
__typename?: 'query_root'
/** fetch data from the table: "gh_burnup" */
gh_burnup: Array<Gh_Burnup>
/** fetch data from the table: "gh_epic_issues" */
gh_epic_issues: Array<Gh_Epic_Issues>
/** fetch data from the table: "gh_epics" */
gh_epics: Array<Gh_Epics>
/** fetch data from the table: "gh_issues" */
gh_issues: Array<Gh_Issues>
/** fetch data from the table: "gh_orphans" */
gh_orphans: Array<Gh_Orphans>
/** fetch data from the table: "gh_repositories" */
gh_repositories: Array<Gh_Repositories>
}
export type Query_RootGh_BurnupArgs = {
distinct_on?: InputMaybe<Array<Gh_Burnup_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Burnup_Order_By>>
where?: InputMaybe<Gh_Burnup_Bool_Exp>
}
export type Query_RootGh_Epic_IssuesArgs = {
distinct_on?: InputMaybe<Array<Gh_Epic_Issues_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Epic_Issues_Order_By>>
where?: InputMaybe<Gh_Epic_Issues_Bool_Exp>
}
export type Query_RootGh_EpicsArgs = {
distinct_on?: InputMaybe<Array<Gh_Epics_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Epics_Order_By>>
where?: InputMaybe<Gh_Epics_Bool_Exp>
}
export type Query_RootGh_IssuesArgs = {
distinct_on?: InputMaybe<Array<Gh_Issues_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Issues_Order_By>>
where?: InputMaybe<Gh_Issues_Bool_Exp>
}
export type Query_RootGh_OrphansArgs = {
distinct_on?: InputMaybe<Array<Gh_Orphans_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Orphans_Order_By>>
where?: InputMaybe<Gh_Orphans_Bool_Exp>
}
export type Query_RootGh_RepositoriesArgs = {
distinct_on?: InputMaybe<Array<Gh_Repositories_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Repositories_Order_By>>
where?: InputMaybe<Gh_Repositories_Bool_Exp>
}
export type Subscription_Root = {
__typename?: 'subscription_root'
/** fetch data from the table: "gh_burnup" */
gh_burnup: Array<Gh_Burnup>
/** fetch data from the table in a streaming manner: "gh_burnup" */
gh_burnup_stream: Array<Gh_Burnup>
/** fetch data from the table: "gh_epic_issues" */
gh_epic_issues: Array<Gh_Epic_Issues>
/** fetch data from the table in a streaming manner: "gh_epic_issues" */
gh_epic_issues_stream: Array<Gh_Epic_Issues>
/** fetch data from the table: "gh_epics" */
gh_epics: Array<Gh_Epics>
/** fetch data from the table in a streaming manner: "gh_epics" */
gh_epics_stream: Array<Gh_Epics>
/** fetch data from the table: "gh_issues" */
gh_issues: Array<Gh_Issues>
/** fetch data from the table in a streaming manner: "gh_issues" */
gh_issues_stream: Array<Gh_Issues>
/** fetch data from the table: "gh_orphans" */
gh_orphans: Array<Gh_Orphans>
/** fetch data from the table in a streaming manner: "gh_orphans" */
gh_orphans_stream: Array<Gh_Orphans>
/** fetch data from the table: "gh_repositories" */
gh_repositories: Array<Gh_Repositories>
/** fetch data from the table in a streaming manner: "gh_repositories" */
gh_repositories_stream: Array<Gh_Repositories>
}
export type Subscription_RootGh_BurnupArgs = {
distinct_on?: InputMaybe<Array<Gh_Burnup_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Burnup_Order_By>>
where?: InputMaybe<Gh_Burnup_Bool_Exp>
}
export type Subscription_RootGh_Burnup_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Burnup_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Burnup_Bool_Exp>
}
export type Subscription_RootGh_Epic_IssuesArgs = {
distinct_on?: InputMaybe<Array<Gh_Epic_Issues_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Epic_Issues_Order_By>>
where?: InputMaybe<Gh_Epic_Issues_Bool_Exp>
}
export type Subscription_RootGh_Epic_Issues_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Epic_Issues_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Epic_Issues_Bool_Exp>
}
export type Subscription_RootGh_EpicsArgs = {
distinct_on?: InputMaybe<Array<Gh_Epics_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Epics_Order_By>>
where?: InputMaybe<Gh_Epics_Bool_Exp>
}
export type Subscription_RootGh_Epics_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Epics_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Epics_Bool_Exp>
}
export type Subscription_RootGh_IssuesArgs = {
distinct_on?: InputMaybe<Array<Gh_Issues_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Issues_Order_By>>
where?: InputMaybe<Gh_Issues_Bool_Exp>
}
export type Subscription_RootGh_Issues_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Issues_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Issues_Bool_Exp>
}
export type Subscription_RootGh_OrphansArgs = {
distinct_on?: InputMaybe<Array<Gh_Orphans_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Orphans_Order_By>>
where?: InputMaybe<Gh_Orphans_Bool_Exp>
}
export type Subscription_RootGh_Orphans_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Orphans_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Orphans_Bool_Exp>
}
export type Subscription_RootGh_RepositoriesArgs = {
distinct_on?: InputMaybe<Array<Gh_Repositories_Select_Column>>
limit?: InputMaybe<Scalars['Int']['input']>
offset?: InputMaybe<Scalars['Int']['input']>
order_by?: InputMaybe<Array<Gh_Repositories_Order_By>>
where?: InputMaybe<Gh_Repositories_Bool_Exp>
}
export type Subscription_RootGh_Repositories_StreamArgs = {
batch_size: Scalars['Int']['input']
cursor: Array<InputMaybe<Gh_Repositories_Stream_Cursor_Input>>
where?: InputMaybe<Gh_Repositories_Bool_Exp>
}
/** Boolean expression to compare columns of type "timestamptz". All fields are combined with logical 'AND'. */
export type Timestamptz_Comparison_Exp = {
_eq?: InputMaybe<Scalars['timestamptz']['input']>
_gt?: InputMaybe<Scalars['timestamptz']['input']>
_gte?: InputMaybe<Scalars['timestamptz']['input']>
_in?: InputMaybe<Array<Scalars['timestamptz']['input']>>
_is_null?: InputMaybe<Scalars['Boolean']['input']>
_lt?: InputMaybe<Scalars['timestamptz']['input']>
_lte?: InputMaybe<Scalars['timestamptz']['input']>
_neq?: InputMaybe<Scalars['timestamptz']['input']>
_nin?: InputMaybe<Array<Scalars['timestamptz']['input']>>
}

View File

@ -0,0 +1 @@
export { api, GRAPHQL_ENDPOINT } from './api'

View File

@ -0,0 +1,14 @@
export const catchApiError = (
error: GraphqlApiError,
callback: (codes: string[]) => void
) => {
const codes: string[] = []
error.response?.errors?.forEach(error => {
if (error.extensions?.code) {
codes.push(error.extensions?.code)
}
})
return callback(codes)
}

View File

@ -0,0 +1,17 @@
export async function fetchQueryFromHasura(query: string) {
const response = await fetch('https://hasura.infra.status.im/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
}),
})
if (!response.ok) {
throw new Error('Failed to fetch data from Hasura.')
}
return response.json()
}

View File

@ -1,82 +1,352 @@
import { Breadcrumbs } from '@/components/breadcrumbs' import { useEffect, useMemo, useRef, useState } from 'react'
import { EpicOverview } from '@/components/epic-overview'
import { TableIssues } from '@/components/table-issues'
import { InsightsLayout } from '@/layouts/insights-layout'
import { epics } from '.' import { useInfiniteQuery } from '@tanstack/react-query'
import { differenceInCalendarDays, format } from 'date-fns'
import { useRouter } from 'next/router'
import { Breadcrumbs, EpicOverview, TableIssues } from '@/components'
import { useDebounce } from '@/hooks/use-debounce'
import { useIntersectionObserver } from '@/hooks/use-intersection-observer'
import { InsightsLayout } from '@/layouts/insights-layout'
import {
GET_EPIC_ISSUES_COUNT,
GET_EPIC_LINKS,
GET_FILTERS_WITH_EPIC,
GET_ISSUES_BY_EPIC,
} from '@/lib/burnup'
import { api } from '@/lib/graphql'
import {
useGetBurnupQuery,
useGetEpicIssuesCountQuery,
useGetFiltersWithEpicQuery,
} from '@/lib/graphql/generated/hooks'
import { Order_By } from '@/lib/graphql/generated/schemas'
import type { BreadcrumbsProps } from '@/components/breadcrumbs' import type { BreadcrumbsProps } from '@/components/breadcrumbs'
import type { GetStaticPaths, GetStaticProps, Page } from 'next' import type {
GetBurnupQuery,
GetEpicIssuesCountQuery,
GetEpicIssuesCountQueryVariables,
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables,
GetFiltersWithEpicQuery,
GetFiltersWithEpicQueryVariables,
GetIssuesByEpicQuery,
GetIssuesByEpicQueryVariables,
} from '@/lib/graphql/generated/operations'
import type { DateRange } from '@status-im/components/src/calendar/calendar'
import type { GetServerSidePropsContext, Page } from 'next'
type Params = { epic: string } type Epic = {
title: string
type Epic = (typeof epics)[number] color: `#${string}`
description: string
export const getStaticPaths: GetStaticPaths<Params> = async () => {
const paths = epics.map(epic => ({
params: { epic: epic.id },
}))
return { paths, fallback: false }
} }
export const getStaticProps: GetStaticProps<Props, Params> = async context => { type Props = {
const epic = epics.find(epic => epic.id === context.params!.epic)! links: string[]
epic: Epic
breadcrumbs: BreadcrumbsProps['items']
count: GetEpicIssuesCountQuery
filters: GetFiltersWithEpicQuery
initialDates: {
from: string
to: string
}
}
if (!epic) { const LIMIT = 10
const EpicsDetailPage: Page<Props> = props => {
const router = useRouter()
const { epic: epicName } = router.query
const { epic, breadcrumbs, links, initialDates } = props
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open')
const [selectedAuthors, setSelectedAuthors] = useState<string[]>([])
const [selectedAssignees, setSelectedAssignees] = useState<string[]>([])
const [selectedRepos, setSelectedRepos] = useState<string[]>([])
const [orderByValue, setOrderByValue] = useState<Order_By>(Order_By.Desc)
const [searchFilter, setSearchFilter] = useState<string>('')
const debouncedSearchFilter = useDebounce<string>(searchFilter)
const [selectedDates, setSelectedDates] = useState<DateRange>()
const [burnupData, setBurnupData] = useState<GetBurnupQuery['gh_burnup']>()
const { isFetching: isLoadingBurnup } = useGetBurnupQuery(
{
epicNames: epicName,
from: selectedDates?.from || initialDates.from,
to: selectedDates?.to || initialDates.to,
},
{
// Prevent animation if we go out of the page
refetchOnWindowFocus: false,
onSuccess: data => {
const differenceBetweenSelectedDates = differenceInCalendarDays(
selectedDates?.to || new Date(),
selectedDates?.from || new Date()
)
const rate = 50 // 1 sample per 50 days
let samplingRate = rate // Use the default rate as the initial value
if (differenceBetweenSelectedDates > 0) {
// Calculate the total number of data points within the selected date range
const totalDataPoints = data?.gh_burnup.length || 0
// Calculate the desired number of data points based on the sampling rate
const desiredDataPoints = Math.ceil(totalDataPoints / rate)
// Calculate the actual sampling rate based on the desired number of data points
samplingRate = Math.max(
1,
Math.floor(totalDataPoints / desiredDataPoints)
)
}
// Downsampling the burnup data
const downsampledData: GetBurnupQuery['gh_burnup'] = []
if (data?.gh_burnup.length > 0) {
data?.gh_burnup.forEach((dataPoint, index) => {
if (index % samplingRate === 0) {
downsampledData.push(dataPoint)
}
})
}
if (
selectedDates?.from &&
selectedDates?.to &&
data?.gh_burnup.length === 0
) {
downsampledData.push({
date_field: selectedDates.from,
total_closed_issues: 0,
total_opened_issues: 0,
})
downsampledData.push({
date_field: selectedDates.to,
total_closed_issues: 0,
total_opened_issues: 0,
})
}
setBurnupData(downsampledData)
},
}
)
const { data: dataCounter } = useGetEpicIssuesCountQuery({
where: {
epic_name: { _eq: epicName as string },
...(selectedAuthors.length > 0 && {
author: { _in: selectedAuthors },
}),
...(selectedAssignees.length > 0 && {
assignee: { _in: selectedAssignees },
}),
...(selectedRepos.length > 0 && {
repository: { _in: selectedRepos },
}),
title: { _ilike: `%${debouncedSearchFilter}%` },
},
})
const count = {
total: dataCounter?.gh_epic_issues.length,
closed: dataCounter?.gh_epic_issues.filter(issue => issue.closed_at).length,
open: dataCounter?.gh_epic_issues.filter(issue => !issue.closed_at).length,
}
const { data, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
[
'getIssuesByEpic',
epicName,
activeTab,
selectedAssignees,
selectedRepos,
selectedAuthors,
orderByValue,
debouncedSearchFilter,
],
async ({ pageParam = 0 }) => {
const result = await api<
GetIssuesByEpicQuery,
GetIssuesByEpicQueryVariables
>(GET_ISSUES_BY_EPIC, {
where: {
epic_name: { _eq: epicName as string },
stage: { _eq: activeTab },
...(selectedAuthors.length > 0 && {
author: { _in: selectedAuthors },
}),
...(selectedAssignees.length > 0 && {
assignee: { _in: selectedAssignees },
}),
...(selectedRepos.length > 0 && {
repository: { _in: selectedRepos },
}),
title: { _ilike: `%${debouncedSearchFilter}%` },
},
limit: LIMIT,
offset: pageParam,
orderBy: orderByValue,
})
return result?.gh_epic_issues || []
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < LIMIT) {
return undefined
}
return pages.length * LIMIT
},
}
)
const { data: filters } = useGetFiltersWithEpicQuery(
{
epicName: epicName as string,
},
{
initialData: props.filters,
}
)
const issues = useMemo(
() => data?.pages.flatMap(page => page) || [],
[data?.pages]
)
const endOfPageRef = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(endOfPageRef, {
rootMargin: '800px',
})
const isVisible = !!entry?.isIntersecting
useEffect(() => {
if (isVisible && !isFetchingNextPage && hasNextPage) {
fetchNextPage()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isVisible])
return (
<InsightsLayout links={links}>
<Breadcrumbs items={breadcrumbs} />
<div className="px-10 py-6">
<EpicOverview
title={epic.title}
description={epic.description}
color={epic.color}
fullscreen
burnup={burnupData}
isLoading={isLoadingBurnup}
selectedDates={selectedDates}
setSelectedDates={setSelectedDates}
/>
</div>
<div className="border-neutral-10 px-10 py-6">
<TableIssues
data={issues}
count={count}
isLoading={isFetchingNextPage || isFetching || hasNextPage}
filters={filters}
handleTabChange={setActiveTab}
activeTab={activeTab}
selectedAuthors={selectedAuthors}
handleSelectedAuthors={setSelectedAuthors}
selectedAssignees={selectedAssignees}
handleSelectedAssignees={setSelectedAssignees}
selectedRepos={selectedRepos}
handleSelectedRepos={setSelectedRepos}
orderByValue={orderByValue}
handleOrderByValue={setOrderByValue}
searchFilterValue={searchFilter}
handleSearchFilter={setSearchFilter}
/>
<div ref={endOfPageRef} />
</div>
</InsightsLayout>
)
}
export default EpicsDetailPage
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { epic } = context.query
const links = await api<
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables
>(GET_EPIC_LINKS)
const epicLinkExists = links?.gh_epics.find(link => link.epic_name === epic)
if (!epicLinkExists) {
return { return {
// notFound: true,
redirect: { destination: '/insights/epics', permanent: false }, redirect: { destination: '/insights/epics', permanent: false },
} }
} }
// TODO: get initial date based on the epic when available
const initialDates = {
from: '2017-01-01',
to: format(new Date(), 'yyyy-MM-dd'),
}
const [resultIssuesCount, resultFilters] = await Promise.all([
api<GetEpicIssuesCountQuery, GetEpicIssuesCountQueryVariables>(
GET_EPIC_ISSUES_COUNT,
{
where: {
epic_name: { _eq: String(epic) },
},
}
),
api<GetFiltersWithEpicQuery, GetFiltersWithEpicQueryVariables>(
GET_FILTERS_WITH_EPIC,
{
epicName: String(epic),
}
),
])
return { return {
props: { props: {
epic, links:
links?.gh_epics
.filter(epic => epic.status === 'In Progress')
.map(epic => epic.epic_name) || [],
count: resultIssuesCount?.gh_epic_issues,
filters: resultFilters || [],
initialDates,
epic: {
title: String(epic),
description: epicLinkExists?.epic_description || '',
color: epicLinkExists.epic_color
? `#${epicLinkExists.epic_color}`
: '#4360df',
},
breadcrumbs: [ breadcrumbs: [
{ {
label: 'Epics', label: 'Epics',
href: '/insights/epics', href: '/insights/epics',
}, },
{ {
label: epic.title, label: epic,
href: `/insights/epics/${epic.id}`, href: `/insights/epics/${epic}`,
}, },
], ],
key: epic,
}, },
} }
} }
type Props = {
epic: Epic
breadcrumbs: BreadcrumbsProps['items']
}
const EpicsDetailPage: Page<Props> = props => {
const { epic, breadcrumbs } = props
return (
<div>
<Breadcrumbs items={breadcrumbs} />
<div className="px-10 py-6">
<EpicOverview
title={epic.title}
description={epic.description}
fullscreen
/>
</div>
<div className="border-b border-neutral-10 px-10 py-6">
<div role="separator" className="-mx-6 my-6 h-px bg-neutral-10" />
<TableIssues />
</div>
</div>
)
}
EpicsDetailPage.getLayout = function getLayout(page) {
return <InsightsLayout>{page}</InsightsLayout>
}
export default EpicsDetailPage

View File

@ -1,39 +1,199 @@
import { useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { IconButton, Shadow, Tag, Text } from '@status-im/components' import { Input, Shadow, Tag, Text } from '@status-im/components'
import { import { DoneIcon, OpenIcon, SearchIcon } from '@status-im/icons'
DoneIcon, import { useInfiniteQuery } from '@tanstack/react-query'
NotStartedIcon, import { differenceInCalendarDays, format } from 'date-fns'
OpenIcon,
SearchIcon,
SortIcon,
} from '@status-im/icons'
import { Loading } from '@/components/chart/components'
import { DatePicker } from '@/components/datepicker/datepicker' import { DatePicker } from '@/components/datepicker/datepicker'
import { EpicOverview } from '@/components/epic-overview' import { EpicOverview } from '@/components/epic-overview'
import { Empty } from '@/components/table-issues/empty'
import { DropdownSort } from '@/components/table-issues/filters'
import { useDebounce } from '@/hooks/use-debounce'
import { useIntersectionObserver } from '@/hooks/use-intersection-observer'
import { RenderIfVisible } from '@/hooks/use-render-if-visible'
import { InsightsLayout } from '@/layouts/insights-layout' import { InsightsLayout } from '@/layouts/insights-layout'
import { GET_BURNUP, GET_EPIC_LINKS } from '@/lib/burnup'
import { api } from '@/lib/graphql'
import { Order_By } from '@/lib/graphql/generated/schemas'
import type { DropdownSortProps } from '@/components/table-issues/filters/dropdown-sort'
import type {
GetBurnupQuery,
GetBurnupQueryVariables,
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables,
} from '@/lib/graphql/generated/operations'
import type { DateRange } from '@status-im/components/src/calendar/calendar' import type { DateRange } from '@status-im/components/src/calendar/calendar'
import type { Page } from 'next' import type { Page } from 'next'
export const epics = [ type Props = {
links: string[]
}
const LIMIT = 3
const sortOptions: DropdownSortProps['data'] = [
{ {
id: '1', id: Order_By.Asc,
title: 'Communities protocol', name: 'Ascending',
description: 'Support Encrypted Communities',
}, },
{ {
id: '5155', id: Order_By.Desc,
title: 'Keycard', name: 'Descending',
description:
'Detecting keycard reader removal for the beginning of each flow',
}, },
] ]
const EpicsPage: Page = () => { const EpicsPage: Page<Props> = props => {
const { links } = props
const [selectedFilters, setSelectedFilters] = useState<string[]>([
'In Progress',
])
const [orderByValue, setOrderByValue] = useState<Order_By>(Order_By.Desc)
const [searchFilter, setSearchFilter] = useState<string>('')
const debouncedSearchFilter = useDebounce<string>(searchFilter)
const [selectedDates, setSelectedDates] = useState<DateRange>() const [selectedDates, setSelectedDates] = useState<DateRange>()
const handleFilter = (filter: string) => {
if (selectedFilters.includes(filter)) {
setSelectedFilters(selectedFilters.filter(f => f !== filter))
} else {
setSelectedFilters([...selectedFilters, filter])
}
}
const {
data,
isFetchedAfterMount,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery(
[
'getEpicsOverview',
orderByValue,
debouncedSearchFilter,
selectedDates,
selectedFilters,
],
async ({ pageParam = 0 }) => {
const result = await api<
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables
>(GET_EPIC_LINKS, {
where: {
status: {
_in:
selectedFilters.length > 0
? selectedFilters
: ['In Progress', 'Closed'],
},
epic_name: { _ilike: `%${debouncedSearchFilter}%` },
},
limit: LIMIT,
offset: pageParam,
orderBy: {
epic_name: orderByValue || Order_By.Asc,
},
})
const burnup = await api<GetBurnupQuery, GetBurnupQueryVariables>(
GET_BURNUP,
{
epicNames: result?.gh_epics.map(epic => epic.epic_name || '') || [],
from: selectedDates?.from || '2018-05-01',
to: selectedDates?.to || format(new Date(), 'yyyy-MM-dd'),
}
)
const differenceBetweenSelectedDates = differenceInCalendarDays(
selectedDates?.to || new Date(),
selectedDates?.from || new Date()
)
const rate = 50 // 1 sample per 50 days
let samplingRate = rate // Use the default rate as the initial value
if (differenceBetweenSelectedDates > 0) {
// Calculate the ratio between the difference in days and the desired sampling rate
const ratio = differenceBetweenSelectedDates / rate
// Calculate the sampling rate based on the ratio
samplingRate = Math.ceil(1 / ratio)
}
// Downsampling the burnup data
const downsampledData: GetBurnupQuery['gh_burnup'] = []
burnup?.gh_burnup.forEach((dataPoint, index) => {
if (index % samplingRate === 0) {
downsampledData.push(dataPoint)
}
})
return ( return (
result?.gh_epics.map(epic => {
const burnupData = downsampledData?.filter(
b => b.epic_name === epic.epic_name
)
return {
title: epic.epic_name,
description: epic.epic_description,
color: epic.epic_color ? `#${epic.epic_color}` : '#4360df',
burnup:
burnupData.length > 0
? burnupData
: [
{
date: selectedDates?.from,
total_closed_issues: 0,
total_open_issues: 0,
},
{
date: selectedDates?.to,
total_closed_issues: 0,
total_open_issues: 0,
},
],
}
}) || []
)
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < LIMIT) {
return undefined
}
return pages.length * LIMIT
},
}
)
const epics = useMemo(() => {
return data?.pages.flatMap(page => page) || []
}, [data])
const endOfPageRef = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(endOfPageRef, {})
const isVisible = !!entry?.isIntersecting
useEffect(() => {
if (isVisible && !isFetchingNextPage && hasNextPage) {
fetchNextPage()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isVisible])
return (
<InsightsLayout links={links}>
<div className="flex h-full flex-1 flex-col justify-between">
<div className="space-y-4 p-10"> <div className="space-y-4 p-10">
<Text size={27} weight="semibold"> <Text size={27} weight="semibold">
Epics Epics
@ -41,31 +201,110 @@ const EpicsPage: Page = () => {
<div className="flex justify-between"> <div className="flex justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<Tag size={32} label="In Progress" icon={OpenIcon} selected /> <Tag
<Tag size={32} label="Closed" icon={DoneIcon} /> size={32}
<Tag size={32} label="Not Started" icon={NotStartedIcon} /> label="In Progress"
icon={OpenIcon}
selected={selectedFilters.includes('In Progress')}
onPress={() => handleFilter('In Progress')}
/>
<Tag
size={32}
label="Closed"
icon={DoneIcon}
selected={selectedFilters.includes('Closed')}
onPress={() => handleFilter('Closed')}
/>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<IconButton variant="outline" icon={<SearchIcon size={20} />} /> <Input
<IconButton variant="outline" icon={<SortIcon size={20} />} /> direction="rtl"
variant="retractable"
placeholder="Search"
icon={<SearchIcon size={20} />}
size={32}
value={searchFilter}
onChangeText={setSearchFilter}
/>
<DropdownSort
data={sortOptions}
onOrderByValueChange={setOrderByValue}
orderByValue={orderByValue}
/>
</div> </div>
</div> </div>
<div className="grid gap-4"> <div className="grid gap-4">
{epics.map(epic => ( {epics.map(epic => (
<Shadow key={epic.id} variant="$2" className="rounded-2xl px-4 py-3"> <RenderIfVisible
<EpicOverview title={epic.title} description={epic.description} /> key={epic.title}
defaultHeight={398}
placeholderComponent={
<Shadow
variant="$2"
className="h-[398px] rounded-2xl px-4 py-3"
>
<div className="flex h-full flex-col p-5">
<Loading />
</div>
</Shadow> </Shadow>
}
>
<Shadow variant="$2" className="rounded-2xl px-4 py-3">
<EpicOverview
title={epic.title || ''}
description={epic.description || ''}
selectedDates={selectedDates}
setSelectedDates={setSelectedDates}
showPicker={false}
color={epic.color as `#${string}`}
burnup={epic.burnup}
isLoading={!isFetchedAfterMount}
/>
</Shadow>
</RenderIfVisible>
))} ))}
<div ref={endOfPageRef} />
{(isFetching || isFetchingNextPage || hasNextPage) && (
<Shadow
variant="$2"
className="mt-[-1rem] h-[398px] rounded-2xl px-4 py-3"
>
<div className="flex h-full flex-col p-5">
<Loading />
</div>
</Shadow>
)}
{!isFetching && !isFetchingNextPage && epics.length === 0 && (
<div className="pt-4">
<Empty />
</div>
)}
</div>
</div> </div>
<DatePicker selected={selectedDates} onSelect={setSelectedDates} /> <DatePicker selected={selectedDates} onSelect={setSelectedDates} />
</div> </div>
</InsightsLayout>
) )
} }
EpicsPage.getLayout = function getLayout(page) { export async function getServerSideProps() {
return <InsightsLayout>{page}</InsightsLayout> const epics = await api<
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables
>(GET_EPIC_LINKS)
return {
props: {
links:
epics?.gh_epics
.filter(epic => epic.status === 'In Progress')
.map(epic => epic.epic_name) || [],
},
}
} }
export default EpicsPage export default EpicsPage

View File

@ -1,24 +1,205 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Text } from '@status-im/components' import { Text } from '@status-im/components'
import { useInfiniteQuery } from '@tanstack/react-query'
import { TableIssues } from '@/components' import { TableIssues } from '@/components'
import { useDebounce } from '@/hooks/use-debounce'
import { useIntersectionObserver } from '@/hooks/use-intersection-observer'
import { InsightsLayout } from '@/layouts/insights-layout' import { InsightsLayout } from '@/layouts/insights-layout'
import {
GET_EPIC_LINKS,
GET_FILTERS_FOR_ORPHANS,
GET_ORPHANS,
GET_ORPHANS_COUNT,
} from '@/lib/burnup'
import { api } from '@/lib/graphql'
import { useGetOrphansCountQuery } from '@/lib/graphql/generated/hooks'
import { Order_By } from '@/lib/graphql/generated/schemas'
import type {
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables,
GetFiltersForOrphansQuery,
GetFiltersForOrphansQueryVariables,
GetOrphansCountQuery,
GetOrphansCountQueryVariables,
GetOrphansQuery,
GetOrphansQueryVariables,
} from '@/lib/graphql/generated/operations'
import type { Page } from 'next' import type { Page } from 'next'
const OrphansPage: Page = () => { type Props = {
orphans: GetOrphansQuery
filters: GetFiltersForOrphansQuery
links: string[]
}
const LIMIT = 50
const OrphansPage: Page<Props> = props => {
const { links } = props
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open')
const [selectedAuthors, setSelectedAuthors] = useState<string[]>([])
const [selectedAssignees, setSelectedAssignees] = useState<string[]>([])
const [selectedRepos, setSelectedRepos] = useState<string[]>([])
const [orderByValue, setOrderByValue] = useState<Order_By>(Order_By.Desc)
const [searchFilter, setSearchFilter] = useState<string>('')
const debouncedSearchFilter = useDebounce<string>(searchFilter)
const { data, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
[
'getOrphans',
activeTab,
selectedAssignees,
selectedRepos,
selectedAuthors,
orderByValue,
debouncedSearchFilter,
],
async ({ pageParam = 0 }) => {
const result = await api<GetOrphansQuery, GetOrphansQueryVariables>(
GET_ORPHANS,
{
where: {
stage: { _eq: activeTab },
...(selectedAuthors.length > 0 && {
author: { _in: selectedAuthors },
}),
...(selectedAssignees.length > 0 && {
assignee: { _in: selectedAssignees },
}),
...(selectedRepos.length > 0 && {
repository: { _in: selectedRepos },
}),
title: { _ilike: `%${debouncedSearchFilter}%` },
},
limit: LIMIT,
offset: pageParam,
orderBy: orderByValue,
}
)
return result?.gh_orphans || []
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < LIMIT) {
return undefined
}
return pages.length * LIMIT
},
}
)
const { data: dataCounter } = useGetOrphansCountQuery({
where: {
...(selectedAuthors.length > 0 && {
author: { _in: selectedAuthors },
}),
...(selectedAssignees.length > 0 && {
assignee: { _in: selectedAssignees },
}),
...(selectedRepos.length > 0 && {
repository: { _in: selectedRepos },
}),
title: { _ilike: `%${debouncedSearchFilter}%` },
},
})
const count = {
total: dataCounter?.gh_orphans.length,
closed: dataCounter?.gh_orphans.filter(issue => issue.closed_at).length,
open: dataCounter?.gh_orphans.filter(issue => !issue.closed_at).length,
}
const orphans = useMemo(
() => data?.pages.flatMap(page => page) || [],
[data?.pages]
)
const endOfPageRef = useRef<HTMLDivElement | null>(null)
const entry = useIntersectionObserver(endOfPageRef, {
rootMargin: '800px',
threshold: 0,
})
const isVisible = !!entry?.isIntersecting
useEffect(() => {
if (isVisible && !isFetchingNextPage && hasNextPage) {
fetchNextPage()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isVisible])
return ( return (
<div className="space-y-6 p-10"> <InsightsLayout links={links}>
<div className="space-y-6 scroll-smooth p-10">
<Text size={27} weight="semibold"> <Text size={27} weight="semibold">
Orphans Orphans
</Text> </Text>
<TableIssues
<TableIssues /> data={orphans}
count={count}
isLoading={isFetchingNextPage || isFetching || hasNextPage}
filters={props.filters}
handleTabChange={setActiveTab}
activeTab={activeTab}
selectedAuthors={selectedAuthors}
handleSelectedAuthors={setSelectedAuthors}
selectedAssignees={selectedAssignees}
handleSelectedAssignees={setSelectedAssignees}
selectedRepos={selectedRepos}
handleSelectedRepos={setSelectedRepos}
orderByValue={orderByValue}
handleOrderByValue={setOrderByValue}
searchFilterValue={searchFilter}
handleSearchFilter={setSearchFilter}
/>
<div ref={endOfPageRef} />
</div> </div>
</InsightsLayout>
) )
} }
OrphansPage.getLayout = function getLayout(page) { export async function getServerSideProps() {
return <InsightsLayout>{page}</InsightsLayout> const [links, repos, filters, resultIssuesCount] = await Promise.all([
api<GetEpicMenuLinksQuery, GetEpicMenuLinksQueryVariables>(GET_EPIC_LINKS),
api<GetOrphansQuery, GetOrphansQueryVariables>(GET_ORPHANS, {
where: {
stage: { _eq: 'open' },
},
limit: LIMIT,
offset: 0,
orderBy: Order_By.Desc,
}),
api<GetFiltersForOrphansQuery, GetFiltersForOrphansQueryVariables>(
GET_FILTERS_FOR_ORPHANS
),
api<GetOrphansCountQuery, GetOrphansCountQueryVariables>(
GET_ORPHANS_COUNT,
{
where: {
stage: { _eq: 'open' },
},
}
),
])
return {
props: {
links:
links?.gh_epics
.filter(epic => epic.status === 'In Progress')
.map(epic => epic.epic_name) || [],
repos: repos.gh_orphans || [],
filters,
count: resultIssuesCount,
},
}
} }
export default OrphansPage export default OrphansPage

View File

@ -1,129 +0,0 @@
import { Shadow, Text } from '@status-im/components'
import { OpenIcon, UnlockedIcon } from '@status-im/icons'
import { Link } from '@/components/link'
import { InsightsLayout } from '@/layouts/insights-layout'
import type { Page } from 'next'
const repos = [
{
name: 'status-web',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'status-mobile',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'status-desktop',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'status-go',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'nim-waku',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'go-waku',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'js-waku',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'nimbus-eth2',
description: 'a free (libre) open source, mobile OS for Ethereum.',
issues: 10,
stars: 5,
},
{
name: 'help.status.im',
description: 'help.status.im',
issues: 10,
stars: 5,
},
]
const ReposPage: Page = () => {
return (
<div className="p-10">
<div className="mb-6">
<Text size={27} weight="semibold">
Repos
</Text>
</div>
<div className="grid grid-cols-3 gap-5">
{repos.map(repo => (
<Shadow key={repo.name}>
<Link
href={`https://github.com/status-im/${repo.name}`}
className="flex h-[124px] flex-col justify-between rounded-2xl border border-neutral-10 px-4 py-3 transition-colors duration-200 hover:border-neutral-40"
>
<div className="flex flex-col">
<Text size={15} weight="semibold">
{repo.name}
</Text>
<Text size={15} color="$neutral-50">
{repo.description}
</Text>
</div>
<div className="flex gap-3 pt-4">
<div className="flex items-center">
<div className="pr-1">
<UnlockedIcon size={12} color="$neutral-50" />
</div>
<Text size={13} weight="medium" color="$neutral-100">
Public
</Text>
</div>
<div className="flex items-center">
<div className="pr-1">
<OpenIcon size={12} color="$neutral-50" />
</div>
<Text size={13} weight="medium" color="$neutral-100">
42 issues
</Text>
</div>
<div className="flex items-center">
<div className="pr-1">
<OpenIcon size={12} color="$neutral-50" />
</div>
<Text size={13} weight="medium" color="$neutral-100">
32
</Text>
</div>
</div>
</Link>
</Shadow>
))}
</div>
</div>
)
}
ReposPage.getLayout = function getLayout(page) {
return <InsightsLayout>{page}</InsightsLayout>
}
export default ReposPage

View File

@ -0,0 +1,139 @@
import { Shadow, Text } from '@status-im/components'
import { OpenIcon, UnlockedIcon } from '@status-im/icons'
import { Link } from '@/components/link'
import { LoadingSkeleton } from '@/components/repos/loading-skeleton'
import { InsightsLayout } from '@/layouts/insights-layout'
import { GET_EPIC_LINKS, GET_REPOS } from '@/lib/burnup'
import { api } from '@/lib/graphql'
import { useGetRepositoriesQuery } from '@/lib/graphql/generated/hooks'
import type {
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables,
GetRepositoriesQuery,
GetRepositoriesQueryVariables,
} from '@/lib/graphql/generated/operations'
import type { Page } from 'next'
type Props = {
repos: GetRepositoriesQuery
links: string[]
}
const capitalizeString = (word: string) =>
word.charAt(0).toUpperCase() + word.slice(1)
const ReposPage: Page<Props> = props => {
const { data, isLoading } = useGetRepositoriesQuery(undefined, {
initialData: props.repos,
})
if (isLoading) {
return <LoadingSkeleton />
}
const repos = data?.gh_repositories || []
return (
<InsightsLayout links={props.links}>
<div className="p-10">
<div className="mb-6">
<Text size={27} weight="semibold">
Repos
</Text>
</div>
<div className="grid grid-cols-3 gap-5">
{repos.map(repo => (
<Shadow key={repo.name}>
<Link
href={`https://github.com/status-im/${repo.name}`}
className="flex h-[124px] flex-col justify-between rounded-2xl border border-neutral-10 px-4 py-3 transition-colors duration-200 hover:border-neutral-40"
>
<div className="flex flex-col">
<Text size={15} weight="semibold">
{repo.name}
</Text>
<Text size={15} color="$neutral-50" truncate>
{repo.description}
</Text>
</div>
<div className="flex gap-3 pt-4">
<div className="flex items-center">
<div className="pr-1">
<UnlockedIcon size={12} color="$neutral-50" />
</div>
<Text size={13} weight="medium" color="$neutral-100">
{repo.visibility && capitalizeString(repo.visibility)}
</Text>
</div>
<div className="flex items-center">
<div className="pr-1">
<OpenIcon size={12} color="$neutral-50" />
</div>
<Text size={13} weight="medium" color="$neutral-100">
{repo.open_issues_count} Issues
</Text>
</div>
<div className="flex items-center">
<div className="pr-1">
{/* TODO Change the correct star icon when available */}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_3507_951)">
<path
d="M6.00004 1C6.33333 1 7.66667 4.32285 7.66667 4.32285C7.66667 4.32285 11 4.32285 11 4.7382C11 5.15356 8.08333 7.23033 8.08333 7.23033C8.08333 7.23033 9.66667 10.6363 9.33333 10.9685C9 11.3008 6.00004 8.89176 6.00004 8.89176C6.00004 8.89176 3 11.3008 2.66667 10.9685C2.33333 10.6363 3.91667 7.23033 3.91667 7.23033C3.91667 7.23033 1 5.15356 1 4.7382C1 4.32285 4.33333 4.32285 4.33333 4.32285C4.33333 4.32285 5.66674 1 6.00004 1Z"
stroke="#647084"
stroke-width="1.1"
/>
</g>
<defs>
<clipPath id="clip0_3507_951">
<rect width="12" height="12" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<Text size={13} weight="medium" color="$neutral-100">
{repo.stargazers_count} Stars
</Text>
</div>
</div>
</Link>
</Shadow>
))}
</div>
</div>
</InsightsLayout>
)
}
export async function getServerSideProps() {
const result = await api<GetRepositoriesQuery, GetRepositoriesQueryVariables>(
GET_REPOS,
undefined
)
const links = await api<
GetEpicMenuLinksQuery,
GetEpicMenuLinksQueryVariables
>(GET_EPIC_LINKS)
return {
props: {
links:
links?.gh_epics
.filter(epic => epic.status === 'In Progress')
.map(epic => epic.epic_name) || [],
repos: result.gh_repositories || [],
},
}
}
export default ReposPage

1352
yarn.lock

File diff suppressed because it is too large Load Diff