From febccc7cbb3327024c3b368a7c2779823140a79a Mon Sep 17 00:00:00 2001 From: shivam-bit Date: Thu, 18 Apr 2024 02:49:21 +0530 Subject: [PATCH] Add color mapping for PR status and update legends menu component --- .../team/[team_id]/deployment_analytics.ts | 72 +- web-server/src/components/InsightChip.tsx | 44 + web-server/src/components/LegendItem.tsx | 26 + web-server/src/components/LegendsMenu.tsx | 42 + .../PRTable/PrTableWithPrExclusionMenu.tsx | 172 ++ .../PullRequestTableColumnSelector.tsx | 121 + .../components/PRTable/PullRequestsTable.tsx | 1137 +++++++++ .../PRTable/PullRequestsTableHead.tsx | 322 +++ .../PRTableMini/PullRequestsTableHeadMini.tsx | 97 + .../PRTableMini/PullRequestsTableMini.tsx | 561 +++++ web-server/src/components/ProgressBar.tsx | 81 + web-server/src/components/RepoCard.tsx | 43 + web-server/src/components/Shared.tsx | 231 ++ .../TicketsTableAddons/ColumnsSelector.tsx | 129 + .../TicketsTableAddons/SearchInput.tsx | 35 + web-server/src/components/TrendsLineChart.tsx | 169 ++ web-server/src/constants/lang-colors.ts | 2107 +++++++++++++++++ web-server/src/constants/overlays.ts | 9 +- .../DoraMetrics/DoraCards/ChangeTimeCard.tsx | 30 +- .../DoraCards/WeeklyDeliveryVolumeCard.tsx | 17 +- .../DeploymentInsightsOverlay.tsx | 832 +++++++ .../content/PullRequests/DeploymentItem.tsx | 136 ++ .../PullRequests/LeadTimeStatsCore.tsx | 300 +++ .../content/PullRequests/LegendAndStats.tsx | 129 + .../src/content/PullRequests/ProcessChart.tsx | 356 +++ .../ProcessChartWithContainer.tsx | 32 + .../content/PullRequests/TeamInsightsBody.tsx | 478 ++++ .../PullRequests/useChangeTimePipeline.ts | 146 ++ .../src/content/PullRequests/usePageData.tsx | 96 + .../src/hooks/useDoraMetricsGraph/index.tsx | 10 +- .../src/hooks/usePageRefreshCallback.ts | 46 + web-server/src/hooks/useStateTeamConfig.tsx | 18 + web-server/src/hooks/useTableSort.ts | 153 ++ web-server/src/slices/dora_metrics.ts | 20 + web-server/src/types/resources.ts | 23 + web-server/src/utils/adapt_deployments.ts | 38 + web-server/src/utils/user.ts | 13 + 37 files changed, 8213 insertions(+), 58 deletions(-) create mode 100644 web-server/src/components/InsightChip.tsx create mode 100644 web-server/src/components/LegendItem.tsx create mode 100644 web-server/src/components/LegendsMenu.tsx create mode 100644 web-server/src/components/PRTable/PrTableWithPrExclusionMenu.tsx create mode 100644 web-server/src/components/PRTable/PullRequestTableColumnSelector.tsx create mode 100644 web-server/src/components/PRTable/PullRequestsTable.tsx create mode 100644 web-server/src/components/PRTable/PullRequestsTableHead.tsx create mode 100644 web-server/src/components/PRTableMini/PullRequestsTableHeadMini.tsx create mode 100644 web-server/src/components/PRTableMini/PullRequestsTableMini.tsx create mode 100644 web-server/src/components/ProgressBar.tsx create mode 100644 web-server/src/components/RepoCard.tsx create mode 100644 web-server/src/components/TicketsTableAddons/ColumnsSelector.tsx create mode 100644 web-server/src/components/TicketsTableAddons/SearchInput.tsx create mode 100644 web-server/src/components/TrendsLineChart.tsx create mode 100644 web-server/src/constants/lang-colors.ts create mode 100644 web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx create mode 100644 web-server/src/content/PullRequests/DeploymentItem.tsx create mode 100644 web-server/src/content/PullRequests/LeadTimeStatsCore.tsx create mode 100644 web-server/src/content/PullRequests/LegendAndStats.tsx create mode 100644 web-server/src/content/PullRequests/ProcessChart.tsx create mode 100644 web-server/src/content/PullRequests/ProcessChartWithContainer.tsx create mode 100644 web-server/src/content/PullRequests/TeamInsightsBody.tsx create mode 100644 web-server/src/content/PullRequests/useChangeTimePipeline.ts create mode 100644 web-server/src/content/PullRequests/usePageData.tsx create mode 100644 web-server/src/hooks/usePageRefreshCallback.ts create mode 100644 web-server/src/hooks/useTableSort.ts create mode 100644 web-server/src/utils/adapt_deployments.ts diff --git a/web-server/pages/api/internal/team/[team_id]/deployment_analytics.ts b/web-server/pages/api/internal/team/[team_id]/deployment_analytics.ts index 258323be4..f62789f6b 100644 --- a/web-server/pages/api/internal/team/[team_id]/deployment_analytics.ts +++ b/web-server/pages/api/internal/team/[team_id]/deployment_analytics.ts @@ -1,11 +1,18 @@ -import { descend, isNil, mapObjIndexed, prop, reject, sort } from 'ramda'; +import { isNil, reject } from 'ramda'; import * as yup from 'yup'; import { handleRequest } from '@/api-helpers/axios'; import { Endpoint } from '@/api-helpers/global'; +import { Row } from '@/constants/db'; import { mockDeploymentFreq } from '@/mocks/deployment-freq'; -import { TeamDeploymentsApiResponse } from '@/types/resources'; +import { + RepoWorkflowExtended, + UpdatedTeamDeploymentsApiResponse +} from '@/types/resources'; +import { adaptedDeploymentsMap } from '@/utils/adapt_deployments'; import { isoDateString } from '@/utils/date'; +import { db } from '@/utils/db'; +import groupBy from '@/utils/objectArray'; const pathSchema = yup.object().shape({ team_id: yup.string().uuid().required() @@ -22,25 +29,62 @@ const endpoint = new Endpoint(pathSchema); endpoint.handle.GET(getSchema, async (req, res) => { if (req.meta?.features?.use_mock_data) return res.send(mockDeploymentFreq); - const { team_id, branches, from_date, to_date } = req.payload; - return res.send( - await handleRequest( - `/v2/teams/${team_id}/deployment_analytics`, + const { team_id, from_date, to_date } = req.payload; + + const [updatedResponse, workflows_map] = await Promise.all([ + handleRequest( + `/teams/${team_id}/deployment_analytics`, { params: reject(isNil, { - branches, from_time: isoDateString(new Date(from_date)), to_time: isoDateString(new Date(to_date)) }) } - ).then((r) => ({ - ...r, - deployments_map: mapObjIndexed( - sort(descend(prop('conducted_at'))), - r.deployments_map + ), + db('RepoWorkflow') + .select('*') + .leftJoin( + 'TeamRepos', + 'TeamRepos.org_repo_id', + 'RepoWorkflow.org_repo_id' + ) + .where('TeamRepos.team_id', team_id) + .then((rows) => + groupBy( + rows.map( + (row: Row<'RepoWorkflow'>) => + ({ + id: row.id, + created_at: row.created_at, + name: row.name, + provider: row.provider?.toLowerCase(), + repo_id: row.org_repo_id, + type: row.type, + updated_at: row.updated_at + }) as RepoWorkflowExtended + ) + ) ) - })) - ); + // handleRequest<{ workflows: RepoWorkflowExtended[] }>( + // `/teams/${team_id}/workflows` + // ).then((r) => + // r.workflows.reduce( + // (acc, w) => ({ + // ...acc, + // [w.id]: w + // }), + // {} as Record + // ) + // ) + ]); + + const adaptedUpdatedResponse = { + ...updatedResponse, + deployments_map: adaptedDeploymentsMap(updatedResponse.deployments_map), + workflows_map + }; + + res.send(adaptedUpdatedResponse); }); export default endpoint.serve(); diff --git a/web-server/src/components/InsightChip.tsx b/web-server/src/components/InsightChip.tsx new file mode 100644 index 000000000..3f34d6d04 --- /dev/null +++ b/web-server/src/components/InsightChip.tsx @@ -0,0 +1,44 @@ +import { ArrowForwardRounded, InfoOutlined } from '@mui/icons-material'; +import { useTheme } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +import { FlexBox, FlexBoxProps } from '@/components/FlexBox'; +import { deepMerge } from '@/utils/datatype'; + +export const InsightChip: FC< + { startIcon?: ReactNode; endIcon?: ReactNode; cta?: ReactNode } & FlexBoxProps +> = ({ + startIcon = startIconDefault, + endIcon = endIconDefault, + cta, + children, + ...props +}) => { + const theme = useTheme(); + + return ( + + {startIcon} + {children} + {cta} + {endIcon} + + ); +}; + +const startIconDefault = ; +const endIconDefault = ; diff --git a/web-server/src/components/LegendItem.tsx b/web-server/src/components/LegendItem.tsx new file mode 100644 index 000000000..0c5a3ffcf --- /dev/null +++ b/web-server/src/components/LegendItem.tsx @@ -0,0 +1,26 @@ +import { Box, useTheme } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +export const LegendItem: FC<{ + size?: 'default' | 'small'; + color: string; + label: ReactNode; +}> = ({ size: _size = 'default', color, label }) => { + const theme = useTheme(); + const size = _size === 'default' ? 1.5 : 1; + const fontSize = _size === 'default' ? '1em' : '0.8em'; + const gap = _size === 'default' ? 1 : 0.5; + return ( + + + + {label} + + + ); +}; diff --git a/web-server/src/components/LegendsMenu.tsx b/web-server/src/components/LegendsMenu.tsx new file mode 100644 index 000000000..d0905883a --- /dev/null +++ b/web-server/src/components/LegendsMenu.tsx @@ -0,0 +1,42 @@ +import { Box, useTheme } from '@mui/material'; +import { Serie } from '@nivo/line'; +import { FC } from 'react'; + +import { FlexBox } from './FlexBox'; + +export const LegendsMenu: FC<{ + series: Serie[]; +}> = ({ series }) => { + const theme = useTheme(); + + return ( + + {series?.map((dataset, index) => { + return ( + + + + {dataset.id} + + + + + ); + })} + + ); +}; diff --git a/web-server/src/components/PRTable/PrTableWithPrExclusionMenu.tsx b/web-server/src/components/PRTable/PrTableWithPrExclusionMenu.tsx new file mode 100644 index 000000000..be784d01e --- /dev/null +++ b/web-server/src/components/PRTable/PrTableWithPrExclusionMenu.tsx @@ -0,0 +1,172 @@ +import { Button, Divider } from '@mui/material'; +import { useRouter } from 'next/router'; +import { FC, useMemo, useCallback, useEffect } from 'react'; + +import { PullRequestsTableHeadProps } from '@/components/PRTable/PullRequestsTableHead'; +import { useModal } from '@/contexts/ModalContext'; +import { useAuth } from '@/hooks/useAuth'; +import { useEasyState } from '@/hooks/useEasyState'; +import { useFeature } from '@/hooks/useFeature'; +import { useSingleTeamConfig } from '@/hooks/useStateTeamConfig'; +import { updateExcludedPrs, fetchExcludedPrs } from '@/slices/team'; +import { useDispatch, useSelector } from '@/store'; +import { PR } from '@/types/resources'; + +import { PullRequestsTable } from './PullRequestsTable'; + +import { FlexBox } from '../FlexBox'; +import { Line } from '../Text'; + +export const PrTableWithPrExclusionMenu: FC< + { propPrs: PR[]; onUpdateCallback: () => void } & Omit< + PullRequestsTableHeadProps, + 'conf' | 'updateSortConf' | 'count' + > +> = ({ propPrs, onUpdateCallback }) => { + const dispatch = useDispatch(); + const router = useRouter(); + const { userId } = useAuth(); + const { addModal } = useModal(); + + const teamId = useSingleTeamConfig().singleTeamId; + const selectedPrIds = useEasyState([]); + + const excludedPrs = useSelector((s) => s.team.excludedPrs); + + const updateExcludedPrsHandler = useCallback(() => { + const selectedPrIdsSet = new Set(selectedPrIds.value); + const selectedPrs = propPrs.filter((pr) => selectedPrIdsSet.has(pr.id)); + + dispatch( + updateExcludedPrs({ + userId, + teamId, + excludedPrs: [...excludedPrs, ...selectedPrs] + }) + ).then(() => onUpdateCallback()); + }, [ + dispatch, + excludedPrs, + onUpdateCallback, + propPrs, + selectedPrIds.value, + teamId, + userId + ]); + + const isUserRoute = router.pathname.includes('/user'); + const isPrExclusionEnabled = useFeature('enable_pr_exclusion'); + const enablePrSelection = useMemo( + () => !isUserRoute && isPrExclusionEnabled, + [isPrExclusionEnabled, isUserRoute] + ); + + useEffect(() => { + dispatch(fetchExcludedPrs({ teamId })); + }, [dispatch, userId, teamId]); + + return ( + + {Boolean(selectedPrIds.value.length) && ( + + )} + {Boolean(excludedPrs?.length) && + !Boolean(selectedPrIds.value.length) && ( + + )} + + ) + } + selectedPrIds={selectedPrIds} + isPrSelectionEnabled={enablePrSelection} + /> + ); +}; + +export const ExcludedPrTable: FC<{ + onUpdateCallback: () => void; +}> = ({ onUpdateCallback }) => { + const dispatch = useDispatch(); + const { userId } = useAuth(); + const teamId = useSingleTeamConfig().singleTeamId; + + const excludedPrs = useSelector((s) => s.team.excludedPrs); + const selectedPrIds = useEasyState([]); + + const updateExcludedPrsHandler = useCallback(() => { + const selectedPrIdsSet = new Set(selectedPrIds.value); + const filteredPrs = excludedPrs.filter((p) => !selectedPrIdsSet.has(p.id)); + dispatch( + updateExcludedPrs({ + userId, + teamId, + excludedPrs: [...filteredPrs] + }) + ).then(() => onUpdateCallback()); + }, [ + dispatch, + excludedPrs, + onUpdateCallback, + selectedPrIds.value, + teamId, + userId + ]); + + return ( + + + {excludedPrs.length ? ( + + + + } + selectedPrIds={selectedPrIds} + isPrSelectionEnabled={true} + /> + ) : ( + + No excluded PRs + + )} + + ); +}; diff --git a/web-server/src/components/PRTable/PullRequestTableColumnSelector.tsx b/web-server/src/components/PRTable/PullRequestTableColumnSelector.tsx new file mode 100644 index 000000000..a9ab41afc --- /dev/null +++ b/web-server/src/components/PRTable/PullRequestTableColumnSelector.tsx @@ -0,0 +1,121 @@ +import CheckIcon from '@mui/icons-material/Check'; +import TuneIcon from '@mui/icons-material/Tune'; +import { Box, Button, Divider, Popover, useTheme } from '@mui/material'; +import { useCallback, useMemo, useRef } from 'react'; + +import { Line } from '@/components/Text'; +import { useBoolState } from '@/hooks/useEasyState'; +import { appSlice, DEFAULT_PR_TABLE_COLUMN_STATE_MAP } from '@/slices/app'; +import { useDispatch, useSelector } from '@/store'; +import { merge } from '@/utils/datatype'; + +import { FlexBox } from '../FlexBox'; + +const formatColumnName = ( + name: keyof typeof DEFAULT_PR_TABLE_COLUMN_STATE_MAP +) => { + if (name === 'lead_time_as_sum_of_parts') return 'Lead Time'; + return name.split('_').join(' '); +}; + +export const PullRequestTableColumnSelector = () => { + const theme = useTheme(); + const dispatch = useDispatch(); + const elRef = useRef(null); + const isPopoverOpen = useBoolState(false); + const prTableColumnStateConfig = useSelector( + (s) => s.app.prTableColumnsConfig || DEFAULT_PR_TABLE_COLUMN_STATE_MAP + ); + const prTableColumnConfig = useMemo( + () => merge(DEFAULT_PR_TABLE_COLUMN_STATE_MAP, prTableColumnStateConfig), + [prTableColumnStateConfig] + ); + + const handleColumnToggle = useCallback( + (columnName: keyof typeof prTableColumnConfig) => { + const updatedColumns = { ...prTableColumnConfig }; + updatedColumns[columnName] = !updatedColumns[columnName]; + dispatch(appSlice.actions.setPrTableColumnConfig(updatedColumns)); + }, + [dispatch, prTableColumnConfig] + ); + + return ( + <> + + + + + + Configure columns + + + + + {Object.entries(prTableColumnConfig)?.map( + ([columnName, isEnabled], idx) => { + return ( + + handleColumnToggle( + columnName as keyof typeof prTableColumnConfig + ) + } + > + + {isEnabled && } + + + {formatColumnName( + columnName as keyof typeof DEFAULT_PR_TABLE_COLUMN_STATE_MAP + )} + + + ); + } + )} + + + + + ); +}; diff --git a/web-server/src/components/PRTable/PullRequestsTable.tsx b/web-server/src/components/PRTable/PullRequestsTable.tsx new file mode 100644 index 000000000..d439deed7 --- /dev/null +++ b/web-server/src/components/PRTable/PullRequestsTable.tsx @@ -0,0 +1,1137 @@ +import { + ArrowForwardRounded, + VerticalAlignBottomRounded +} from '@mui/icons-material'; +import UndoRoundedIcon from '@mui/icons-material/UndoRounded'; +import { + alpha, + Avatar, + Box, + BoxProps, + Checkbox, + Divider, + lighten, + Link, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + useTheme, + Pagination, + Button +} from '@mui/material'; +import { format } from 'date-fns'; +import { secondsInDay } from 'date-fns/constants'; +import pluralize from 'pluralize'; +import { filter, keys } from 'ramda'; +import { FC, ReactNode, useMemo, useEffect } from 'react'; +import { GoCommentDiscussion } from 'react-icons/go'; +import { IoGitCommit } from 'react-icons/io5'; +import { VscRequestChanges } from 'react-icons/vsc'; + +import { FlexBox } from '@/components/FlexBox'; +import { PullRequestTableColumnSelector } from '@/components/PRTable/PullRequestTableColumnSelector'; +import { DarkTooltip, LightTooltip } from '@/components/Shared'; +import { Line } from '@/components/Text'; +import { SearchInput } from '@/components/TicketsTableAddons/SearchInput'; +import { Integration } from '@/constants/integrations'; +import { ROUTES } from '@/constants/routes'; +import { useAuth } from '@/hooks/useAuth'; +import { EasyState, useEasyState } from '@/hooks/useEasyState'; +import { useTableSort } from '@/hooks/useTableSort'; +import { DEFAULT_PR_TABLE_COLUMN_STATE_MAP } from '@/slices/app'; +import { useSelector } from '@/store'; +import { brandColors } from '@/theme/schemes/theme'; +import { PR } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; +import { staticArray } from '@/utils/mock'; +import { stringAvatar } from '@/utils/stringAvatar'; +import { getColorByStatus, getGHAvatar } from '@/utils/user'; + +import { + CELL_PAD, + PullRequestsTableHead, + PullRequestsTableHeadProps +} from './PullRequestsTableHead'; + +const PAGE_SIZE = 10; + +export const PullRequestsTable: FC< + { + propPrs: PR[]; + selectionMenu?: ReactNode; + selectedPrIds: EasyState; + } & Omit +> = ({ propPrs, selectionMenu, selectedPrIds, isPrSelectionEnabled }) => { + const theme = useTheme(); + const { integrationSet } = useAuth(); + const hasBitbucket = integrationSet.has(Integration.BITBUCKET); + const prTableColumnConfig = useSelector( + (s) => s.app.prTableColumnsConfig || DEFAULT_PR_TABLE_COLUMN_STATE_MAP + ); + const searchInput = useEasyState(''); + + const enabledColumnsSet = useMemo(() => { + const activeColumns = keys(filter(Boolean, prTableColumnConfig)); + return new Set(activeColumns); + }, [prTableColumnConfig]); + + const { + sortedList: sortedPrs, + updateSortConf, + conf, + getCSV + } = useTableSort(propPrs, { field: 'cycle_time', order: 'desc' }); + + const filteredPrs = useMemo(() => { + let prs = sortedPrs; + + if (searchInput.value.trim()) { + prs = prs.filter((pr) => + Object.values(pr).some( + (value) => + typeof value === 'string' && + value.toLowerCase().includes(searchInput.value.toLowerCase()) + ) + ); + } + return prs; + }, [sortedPrs, searchInput.value]); + + const page = useEasyState(1); + const pagedPrs = useMemo( + () => + filteredPrs.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE), + [page.value, filteredPrs] + ); + const pageCount = Math.ceil(filteredPrs.length / PAGE_SIZE); + + useEffect(() => { + if (pageCount < page.value) { + page.set(Math.max(pageCount, 1)); + } + }, [page, pageCount]); + + const { orgId } = useAuth(); + const enableCsv = true; + + return ( + + {pageCount > 1 && ( + page.set(p)} + /> + )} + + + + + + + {selectionMenu} + {!!propPrs.length && enableCsv && ( + + )} + + + + + + + + + {pagedPrs.map((pr) => { + return ( + + {isPrSelectionEnabled && ( + + + selectedPr === pr.id + ) + )} + onChange={(e) => { + if (e.target.checked) { + selectedPrIds.set([ + ...selectedPrIds.value, + pr.id + ]); + return; + } + selectedPrIds.set( + selectedPrIds.value.filter( + (selectedPr) => selectedPr !== pr.id + ) + ); + }} + /> + + + )} + + + + + + }> + + {enabledColumnsSet?.has('commits') && ( + + + {pr.commits} + + )} + {enabledColumnsSet?.has('lines_changed') && ( + + + + {pr.additions + pr.deletions} + + + )} + {enabledColumnsSet?.has('comments') && ( + + + {pr.comments} + + )} + + + + + {enabledColumnsSet.has('base_branch') && ( + {pr.base_branch} + )} + {enabledColumnsSet.has('head_branch') && ( + {pr.head_branch} + )} + {enabledColumnsSet.has('changed_files') && ( + + + {pr.changed_files} + {' '} + {pluralize('file', pr.changed_files)} + + )} + {enabledColumnsSet.has('rework_cycles') && ( + + {pr.rework_cycles ? ( + <> + + {pr.rework_cycles} + {' '} + + {pluralize('time', pr.rework_cycles)} + + + ) : ( + -- + )} + + )} + {enabledColumnsSet.has('author') && ( + + + + {hasBitbucket + ? pr.author.linked_user?.name || + pr.author.username + : `@${pr.author.username}`} + + {!pr.author.linked_user && ( + + User not added to Middleware + + )} + + } + > + + + + + + + + )} + {enabledColumnsSet.has('reviewers') && ( + + + + )} + {enabledColumnsSet.has('first_commit_to_open') && ( + + + {getDurationString( + Math.max(pr.first_commit_to_open, 0) + ) || '--'} + + + )} + {enabledColumnsSet.has('first_response_time') && ( + + + {pr.first_response_time + ? getDurationString(pr.first_response_time) + : '--'} + + + )} + {enabledColumnsSet.has('rework_time') && ( + + + {pr.rework_time + ? getDurationString(pr.rework_time) + : '--'} + + + )} + {enabledColumnsSet.has('merge_time') && ( + + + {pr.merge_time + ? getDurationString(pr.merge_time) + : '--'} + + + )} + {enabledColumnsSet.has('merge_to_deploy') && ( + + + {getDurationString(pr.merge_to_deploy) || '--'} + + + )} + {enabledColumnsSet.has('cycle_time') && ( + + + + )} + {enabledColumnsSet.has('lead_time_as_sum_of_parts') && ( + + + + )} + {enabledColumnsSet.has('created_at') && ( + + + {format(new Date(pr.created_at), 'do MMM')} + + + )} + {enabledColumnsSet.has('updated_at') && ( + + + {format(new Date(pr.updated_at), 'do MMM')} + + + )} + + ); + })} + +
+
+ + {pageCount > 1 && ( + page.set(p)} + /> + )} +
+ ); +}; + +const PrChangesTooltip: FC<{ pr: PR }> = ({ pr }) => { + return ( + + + Git changes + + + Commits + {pr.commits} + Lines + {pr.additions + pr.deletions} + Comments + {pr.comments} + Files + {pr.changed_files} + + ); +}; + +const PrReviewersCell: FC<{ pr: PR }> = ({ pr }) => { + const theme = useTheme(); + const { user } = useAuth(); + const hasBitbucket = user?.integrations?.bitbucket; + return ( + + {pr.reviewers.map((reviewer) => ( + + + {hasBitbucket + ? reviewer.linked_user?.name || reviewer.username + : `@${reviewer.username}`} + + {!reviewer.linked_user && ( + + User not added to Middleware + + )} +
+ } + key={reviewer.username} + component={hasBitbucket ? 'div' : Link} + href={ + reviewer.linked_user?.id + ? `${ + ROUTES.COLLABORATE.INSIGHTS.USER.add(reviewer.linked_user.id) + .PATH + }` + : hasBitbucket + ? undefined + : getGHAvatar(reviewer.username) + } + target="_blank" + fontWeight={500} + display="flex" + alignItems="center" + > + + + ))} + + ); +}; + +const PrMetaCell: FC<{ pr: PR }> = ({ pr }) => { + const theme = useTheme(); + + const linesChanged = pr.additions + pr.deletions; + const linedAddedChunks = Math.floor(((pr.additions / linesChanged) * 10) / 2); + const linedDeletedChunks = Math.floor( + ((pr.deletions / linesChanged) * 10) / 2 + ); + const changesBoxColors: string[] = [] + .concat( + ...staticArray(linedAddedChunks).fill( + lighten(theme.colors.success.main, 0.4) + ) + ) + .concat( + ...staticArray(linedDeletedChunks).fill( + lighten(theme.colors.error.main, 0.4) + ) + ) + .concat( + ...staticArray(5 - linedAddedChunks - linedDeletedChunks).fill( + lighten(theme.colors.secondary.main, 0.4) + ) + ); + + return ( + + + + #{pr.number} + + + + {pr.repo_name} + + + {pr.title ? ( + + {pr.title} + + ) : ( + + PR title couldn't be fetched + + )} + + + {format(new Date(pr.updated_at), 'do, MMM')} + + + + + {pr.state} + + + + + + + +{pr.additions} + + + -{pr.deletions} + + + + {changesBoxColors.map((color, i) => ( + + ))} + + + + {pr.original_reverted_pr && ( + + + {' '} + Reverts PR{' '} + + #{pr.original_reverted_pr.number} + + + )} + + + {pr.head_branch} →{' '} + + {pr.base_branch} + + + + + ); +}; + +export const MiniCycleTimeCore: FC< + { + cycle?: number; + response?: number; + rework?: number; + release?: number; + } & BoxProps +> = ({ cycle = 0, release = 0, response = 0, rework = 0, ...props }) => { + const theme = useTheme(); + + const calcCycleTime = cycle || response + rework + release; + + const defaultFlex = !calcCycleTime ? 1 : 0; + + return ( + + + {getDurationString(response || 0, { + segments: response > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(rework || 0, { + segments: rework > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(release || 0, { + segments: release > secondsInDay ? 2 : 1 + }) || '-'} + + + ); +}; + +export const MiniLeadTimeCore: FC< + { + lead?: number; + commit?: number; + response?: number; + rework?: number; + release?: number; + deploy?: number; + } & BoxProps +> = ({ + lead = 0, + commit = 0, + release = 0, + response = 0, + rework = 0, + deploy = 0, + ...props +}) => { + const theme = useTheme(); + + const defaultFlex = !lead ? 1 : 0; + + return ( + + + {getDurationString(commit || 0, { + segments: commit > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(response || 0, { + segments: response > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(rework || 0, { + segments: rework > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(release || 0, { + segments: release > secondsInDay ? 2 : 1 + }) || '-'} + + + {getDurationString(deploy || 0, { + segments: deploy > secondsInDay ? 2 : 1 + }) || '-'} + + + ); +}; + +export const CycleTimePill: FC<{ time: number } & BoxProps> = ({ + time, + ...props +}) => { + const theme = useTheme(); + return ( + + {getDurationString(time) || '-'} + + ); +}; + +export const MiniCycleTimeLabels = ({ + showLead = false +}: { + showLead?: boolean; +}) => { + const theme = useTheme(); + + return ( + + {showLead && ( + <> + Commit + + + + + )} + Response + + + + Rework + + + + Merge + {showLead && ( + <> + + + + Deploy + + )} + + ); +}; + +export const MiniCycleTimeStat: FC<{ + cycle?: number; + response?: number; + rework?: number; + release?: number; +}> = ({ cycle = 0, release = 0, response = 0, rework = 0 }) => { + const calcCycleTime = response + rework + release; + + return ( + + + + + } + tooltipPlacement="left" + > + + + ); +}; + +export const MiniLeadTimeStat: FC<{ + lead?: number; + commit?: number; + response?: number; + rework?: number; + release?: number; + deploy?: number; +}> = ({ + lead = 0, + commit = 0, + release = 0, + response = 0, + rework = 0, + deploy = 0 +}) => { + const missingState = + !commit && !deploy + ? 'TOTAL' + : Boolean(commit) !== Boolean(deploy) + ? 'PARTIAL' + : null; + + return ( + + {missingState && ( + + Could not show complete Lead Time due to commit or deploy time + being missing + + )} + + + + } + tooltipPlacement="left" + > + + + ); +}; diff --git a/web-server/src/components/PRTable/PullRequestsTableHead.tsx b/web-server/src/components/PRTable/PullRequestsTableHead.tsx new file mode 100644 index 000000000..c799c490f --- /dev/null +++ b/web-server/src/components/PRTable/PullRequestsTableHead.tsx @@ -0,0 +1,322 @@ +import { ScheduleRounded } from '@mui/icons-material'; +import { + Box, + Checkbox, + TableCell, + TableHead, + TableRow, + TableSortLabel +} from '@mui/material'; +import { FC } from 'react'; + +import { DarkTooltip } from '@/components/Shared'; +import { EasyState } from '@/hooks/useEasyState'; +import { TableSort } from '@/hooks/useTableSort'; +import { DEFAULT_PR_TABLE_COLUMN_STATE_MAP } from '@/slices/app'; +import { PR } from '@/types/resources'; + +import { GitBranchIcon } from '../RepoCard'; + +export const CELL_PAD = 1; + +export type PullRequestsTableHeadProps = Pick< + TableSort, + 'conf' | 'updateSortConf' +> & { + prs?: PR[]; + selectedPrIds?: EasyState; + enabledColumnsSet?: Set; + isPrSelectionEnabled?: boolean; + count: number; +}; + +export const PullRequestsTableHead: FC = ({ + prs, + selectedPrIds, + conf, + updateSortConf, + enabledColumnsSet, + isPrSelectionEnabled, + count +}) => { + const noPrsSelected = !Boolean(selectedPrIds.value.length); + const somePrsSelected = + selectedPrIds.value.length > 0 && selectedPrIds.value.length < prs.length; + const allPrsSelected = selectedPrIds.value.length === prs.length; + + return ( + + + {isPrSelectionEnabled && ( + + 1 ? `ALL ${count} PRs` : `the PR` + } in this table from all PR analysis via the button above the table`} + > + { + const isChecked = e.target.checked; + if (isChecked && noPrsSelected) { + selectedPrIds.set(prs.map((pr) => pr.id)); + return; + } + if (!isChecked && allPrsSelected) { + selectedPrIds.set([]); + return; + } + if (isChecked && somePrsSelected) { + selectedPrIds.set(prs.map((pr) => pr.id)); + } + }} + /> + + + )} + + updateSortConf('updated_at')} + > + Pull Request + + + + { + if (enabledColumnsSet?.has('commits')) { + return updateSortConf('commits'); + } + if (enabledColumnsSet?.has('lines_changed')) { + return updateSortConf('additions'); + } + if (enabledColumnsSet?.has('comments')) { + return updateSortConf('comments'); + } + }} + > + + {enabledColumnsSet?.has('commits') && ( + <> + Commits + / + + )} + {enabledColumnsSet?.has('lines_changed') && ( + <> + Lines + / + + )} + {enabledColumnsSet?.has('comments') && ( + Comments + )} + + + + {enabledColumnsSet.has('base_branch') && ( + + updateSortConf('base_branch')} + > + Base + + + )} + {enabledColumnsSet.has('head_branch') && ( + + updateSortConf('head_branch')} + > + Head + + + )} + {enabledColumnsSet.has('changed_files') && ( + + updateSortConf('changed_files')} + > + Files Changed + + + )} + {enabledColumnsSet.has('rework_cycles') && ( + + updateSortConf('rework_cycles')} + > + Rework + + + )} + {enabledColumnsSet.has('author') && ( + + updateSortConf('author')} + > + Author + + + )} + {enabledColumnsSet.has('reviewers') && ( + + updateSortConf('reviewers')} + > + Reviewer + + + )} + {enabledColumnsSet.has('first_response_time') && ( + + updateSortConf('first_response_time')} + > + Response + + + )} + {enabledColumnsSet.has('first_commit_to_open') && ( + + updateSortConf('first_commit_to_open')} + > + Commit to Open + + + )} + {enabledColumnsSet.has('rework_time') && ( + + updateSortConf('rework_time')} + > + Rework + + + )} + {enabledColumnsSet.has('merge_time') && ( + + updateSortConf('merge_time')} + > + Merge + + + )} + {enabledColumnsSet.has('merge_to_deploy') && ( + + updateSortConf('merge_to_deploy')} + > + Merge to Deploy + + + )} + {enabledColumnsSet.has('cycle_time') && ( + + updateSortConf('cycle_time')} + > + Cycle + + + )} + {enabledColumnsSet.has('lead_time_as_sum_of_parts') && ( + + updateSortConf('lead_time_as_sum_of_parts')} + > + Lead + + + )} + {enabledColumnsSet.has('created_at') && ( + + updateSortConf('created_at')} + > + Created + + + )} + {enabledColumnsSet.has('updated_at') && ( + + updateSortConf('updated_at')} + > + Updated + + + )} + + + ); +}; + +const ClockIcon = () => ( + +); diff --git a/web-server/src/components/PRTableMini/PullRequestsTableHeadMini.tsx b/web-server/src/components/PRTableMini/PullRequestsTableHeadMini.tsx new file mode 100644 index 000000000..e4bd90754 --- /dev/null +++ b/web-server/src/components/PRTableMini/PullRequestsTableHeadMini.tsx @@ -0,0 +1,97 @@ +import { TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; +import { FC } from 'react'; + +import { TableSort } from '@/hooks/useTableSort'; +import { PR } from '@/types/resources'; + +export const CELL_PAD = 1; + +export type PullRequestsTableHeadProps = Pick< + TableSort, + 'conf' | 'updateSortConf' +> & { hideColumns?: Set }; + +export const PullRequestsTableHeadMini: FC = ({ + conf, + updateSortConf, + hideColumns +}) => { + return ( + + + + updateSortConf('created_at')} + > + Pull Request + + + {!hideColumns?.has('commits') && ( + + updateSortConf('commits')} + > + Stats + + + )} + + {!hideColumns?.has('rework_cycles') && ( + + updateSortConf('rework_cycles')} + > + Rework + + + )} + {!hideColumns?.has('author') && ( + + updateSortConf('author')} + > + Author + + + )} + {!hideColumns?.has('reviewers') && ( + + updateSortConf('reviewers')} + > + Review + + + )} + {!hideColumns?.has('cycle_time') && ( + + updateSortConf('cycle_time')} + > + Cycle + + + )} + + + ); +}; diff --git a/web-server/src/components/PRTableMini/PullRequestsTableMini.tsx b/web-server/src/components/PRTableMini/PullRequestsTableMini.tsx new file mode 100644 index 000000000..14a397cbc --- /dev/null +++ b/web-server/src/components/PRTableMini/PullRequestsTableMini.tsx @@ -0,0 +1,561 @@ +import { ArrowForwardRounded } from '@mui/icons-material'; +import { + alpha, + Avatar, + Box, + BoxProps, + lighten, + Link, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + useTheme +} from '@mui/material'; +import { format } from 'date-fns'; +import pluralize from 'pluralize'; +import { FC } from 'react'; +import { GoCommentDiscussion } from 'react-icons/go'; +import { IoGitCommit } from 'react-icons/io5'; +import { VscRequestChanges } from 'react-icons/vsc'; + +import { FlexBox } from '@/components/FlexBox'; +import { LightTooltip } from '@/components/Shared'; +import { Line } from '@/components/Text'; +import { useAuth } from '@/hooks/useAuth'; +import { brandColors } from '@/theme/schemes/theme'; +import { PR } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; +import { staticArray } from '@/utils/mock'; +import { stringAvatar } from '@/utils/stringAvatar'; +import { getColorByStatus, getGHAvatar } from '@/utils/user'; + +import { + CELL_PAD, + PullRequestsTableHeadMini, + PullRequestsTableHeadProps +} from './PullRequestsTableHeadMini'; + +import { SimpleAvatar } from '../SimpleAvatar'; + +export const PullRequestsTableMini: FC< + { prs: PR[] } & PullRequestsTableHeadProps +> = ({ prs, conf, updateSortConf, hideColumns }) => { + const theme = useTheme(); + const { integrations } = useAuth(); + + const hasBitbucket = integrations?.bitbucket; + const hasGithub = integrations?.github; + + return ( + + + + + + {prs.map((pr) => { + const linesChanged = pr.additions + pr.deletions; + const linedAddedChunks = Math.floor( + ((pr.additions / linesChanged) * 10) / 2 + ); + const linedDeletedChunks = Math.floor( + ((pr.deletions / linesChanged) * 10) / 2 + ); + const changesBoxColors: string[] = [] + .concat( + ...staticArray(linedAddedChunks).fill( + lighten(theme.colors.success.main, 0.4) + ) + ) + .concat( + ...staticArray(linedDeletedChunks).fill( + lighten(theme.colors.error.main, 0.4) + ) + ) + .concat( + ...staticArray( + 5 - linedAddedChunks - linedDeletedChunks + ).fill(lighten(theme.colors.secondary.main, 0.4)) + ); + + return ( + + + + + #{pr.number} - {pr.repo_name} {pr.head_branch} + + + {pr.title ? ( + + {pr.title} + + ) : ( + + PR title couldn't be fetched + + )} + + + + {format(new Date(pr.created_at), 'do, MMM')} + + + {pr.state} {'->'} {pr.base_branch} + + + + + + + +{pr.additions} + + + -{pr.deletions} + + + {changesBoxColors.map((color, i) => ( + + ))} + + + + + + {!hideColumns?.has('commits') && ( + + + + + Git changes + + Commits + {pr.commits} + Lines + + {pr.additions + pr.deletions} + + Comments + {pr.comments} + Files + {pr.changed_files} + + } + > + + + {pr.commits} + + + + + {pr.additions + pr.deletions} + + + + + {pr.comments} + + + + + )} + + {!hideColumns?.has('rework_cycles') && ( + + {pr.rework_cycles ? ( + <> + + {pr.rework_cycles} + {' '} + + {pluralize('time', pr.rework_cycles)} + + + ) : ( + -- + )} + + )} + {!hideColumns?.has('author') && ( + + + + {hasBitbucket + ? pr.author.linked_user?.name || + pr.author.username + : `@${pr.author.username}`} + + {!pr.author.linked_user && ( + + User not added to Middleware + + )} + + } + > + + + + + + + + )} + {!hideColumns?.has('reviewers') && ( + + + {pr.reviewers.map((reviewer) => ( + + + {hasBitbucket + ? reviewer.linked_user?.name || + reviewer.username + : `@${reviewer.username}`} + + {!reviewer.linked_user && ( + + User not added to Middleware + + )} + + } + key={reviewer.username} + component={hasBitbucket ? 'div' : Link} + href={ + hasBitbucket + ? undefined + : `https://github.com/${reviewer.username}` + } + target="_blank" + fontWeight={500} + display="flex" + alignItems="center" + > + + + ))} + + + )} + {!hideColumns?.has('cycle_time') && ( + + + + )} + + ); + })} + +
+
+
+ ); +}; + +export const MiniCycleTimeCore: FC< + { + cycle?: number; + response?: number; + rework?: number; + release?: number; + } & BoxProps +> = ({ cycle = 0, release = 0, response = 0, rework = 0, ...props }) => { + const theme = useTheme(); + + const calcCycleTime = cycle || response + rework + release; + + const defaultFlex = !calcCycleTime ? 1 : 0; + + return ( + + + {getDurationString(response || 0) || '-'} + + + {getDurationString(rework || 0) || '-'} + + + {getDurationString(release || 0) || '-'} + + + ); +}; + +export const CycleTimePill: FC<{ time: number }> = ({ time }) => { + const theme = useTheme(); + return ( + + {getDurationString(time) || '-'} + + ); +}; + +export const MiniCycleTimeLabels = () => { + const theme = useTheme(); + + return ( + + Response + + + + Rework + + + + Merge + + ); +}; + +export const MiniCycleTimeStat: FC<{ + cycle?: number; + response?: number; + rework?: number; + release?: number; +}> = ({ cycle = 0, release = 0, response = 0, rework = 0 }) => { + const calcCycleTime = response + rework + release; + + return ( + + + + + } + tooltipPlacement="left" + > + + + ); +}; diff --git a/web-server/src/components/ProgressBar.tsx b/web-server/src/components/ProgressBar.tsx new file mode 100644 index 000000000..1ec710489 --- /dev/null +++ b/web-server/src/components/ProgressBar.tsx @@ -0,0 +1,81 @@ +import { FC, useEffect } from 'react'; + +import { FlexBox, FlexBoxProps } from '@/components/FlexBox'; +import { useEasyState } from '@/hooks/useEasyState'; +import { depFn } from '@/utils/fn'; + +export const ProgressBar: FC< + { + perc: number; + color?: 'primary' | 'warning'; + flip?: boolean; + remainingTitle?: string; + progressTitle?: string; + progressOnClick?: () => void; + remainingOnClick?: () => void; + } & FlexBoxProps +> = ({ + progressTitle, + remainingTitle, + progressOnClick, + remainingOnClick, + perc, + color = 'primary', + flip, + ...props +}) => { + const widthState = useEasyState(0); + useEffect(() => { + depFn(widthState.set, perc); + }, [perc, widthState.set]); + + return ( + + + + + ); +}; diff --git a/web-server/src/components/RepoCard.tsx b/web-server/src/components/RepoCard.tsx new file mode 100644 index 000000000..021860b79 --- /dev/null +++ b/web-server/src/components/RepoCard.tsx @@ -0,0 +1,43 @@ +import { DataObjectRounded } from '@mui/icons-material'; +import { Typography, styled } from '@mui/material'; + +import GitBranch from '@/assets/git-merge-line.svg'; +import { DB_OrgRepo } from '@/types/api/org_repo'; +import { Repo } from '@/types/github'; + +export const RepoTitle = styled(Typography)(() => ({ + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + display: 'block', + cursor: 'pointer' +})); + +export const RepoDescription = styled(Typography)(() => ({ + width: '100%', + textOverflow: 'ellipsis', + overflow: 'hidden', + display: 'block' +})); + +export const RepoLangIcon = styled(DataObjectRounded)(({ theme }) => ({ + marginLeft: theme.spacing(-1 / 4), + marginRight: theme.spacing(1 / 2), + opacity: 0.8, + height: '0.7em', + width: '0.7em' +})); + +export const GitBranchIcon = styled(GitBranch)(({ theme }) => ({ + marginRight: theme.spacing(0.5), + opacity: 0.8, + height: '1.5em', + width: '1.5em' +})); + +export const adaptDbRepo = (repo: DB_OrgRepo): Partial => ({ + name: repo.name, + html_url: `//${repo.provider}.com/${repo.org_name}/${repo.name}`, + language: repo.language, + default_branch: repo.default_branch +}); diff --git a/web-server/src/components/Shared.tsx b/web-server/src/components/Shared.tsx index 2bcc811aa..ac4f536bb 100644 --- a/web-server/src/components/Shared.tsx +++ b/web-server/src/components/Shared.tsx @@ -1,4 +1,6 @@ import { + Box, + CardActionArea, IconButton, IconButtonProps, MenuList, @@ -12,8 +14,44 @@ import { darken, Switch } from '@mui/material'; +import { BarTooltipProps } from '@nivo/bar'; +import { Point } from '@nivo/line'; +import { sum } from 'ramda'; import { FC, forwardRef } from 'react'; +import { getDurationString } from '@/utils/date'; + +import { FlexBox } from './FlexBox'; +import { Line } from './Text'; + +export const LabelWrapper = styled(Box)( + ({ theme }) => ` + font-size: ${theme.typography.pxToRem(10)}; + font-weight: bold; + text-transform: uppercase; + border-radius: ${theme.general.borderRadiusSm}; + padding: ${theme.spacing(0.5, 1, 0.4)}; +` +); + +export const CardActionAreaWrapper = styled(CardActionArea)( + ({ theme }) => ` + .MuiTouchRipple-root { + opacity: .2; + } + + .MuiCardActionArea-focusHighlight { + background: ${theme.colors.primary.main}; + } + + &:hover { + .MuiCardActionArea-focusHighlight { + opacity: .05; + } + } +` +); + export const MenuListWrapperSecondary = styled(MenuList)( ({ theme }) => ` padding: ${theme.spacing(3)}; @@ -42,6 +80,68 @@ export const MenuListWrapperSecondary = styled(MenuList)( ` ); +export const MenuListWrapperSuccess = styled(MenuList)( + ({ theme }) => ` + padding: ${theme.spacing(3)}; + + & .MuiMenuItem-root { + border-radius: 50px; + padding: ${theme.spacing(1, 1, 1, 2.5)}; + min-width: 200px; + margin-bottom: 2px; + position: relative; + color: ${theme.colors.success.main}; + + &.Mui-selected, + &:hover, + &.MuiButtonBase-root:active { + background: ${theme.colors.success.lighter}; + color: ${theme.colors.success.dark}; + } + + &:last-child { + margin-bottom: 0; + } + } +` +); + +export const MenuListWrapperError = styled(MenuList)( + ({ theme }) => ` + padding: ${theme.spacing(3)}; + + & .MuiMenuItem-root { + border-radius: 50px; + padding: ${theme.spacing(1, 1, 1, 2.5)}; + min-width: 200px; + margin-bottom: 2px; + position: relative; + color: ${theme.colors.error.main}; + + &.Mui-selected, + &:hover, + &.MuiButtonBase-root:active { + background: ${theme.colors.error.lighter}; + color: ${theme.colors.error.dark}; + } + + &:last-child { + margin-bottom: 0; + } + } +` +); + +export const DotLegend = styled('span')( + ({ theme }) => ` + border-radius: 22px; + width: ${theme.spacing(1.4)}; + height: ${theme.spacing(1.45)}; + display: inline-block; + border: ${theme.colors.alpha.white[100]} solid 2px; +` +); + export const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( = forwardRef( } ); +export const IOSSwitch = styled((props: SwitchProps) => ( + +))(({ theme }) => ({ + width: 42, + height: 26, + padding: 0, + '& .MuiSwitch-switchBase': { + padding: 0, + margin: 2, + transitionDuration: '200ms', + '&.Mui-checked': { + transform: 'translateX(16px)', + color: '#fff', + '& + .MuiSwitch-track': { + backgroundColor: theme.colors.success.dark, + opacity: 1, + border: 0 + }, + '&.Mui-disabled + .MuiSwitch-track': { + opacity: 0.5 + } + }, + '&.Mui-focusVisible .MuiSwitch-thumb': { + color: theme.colors.success.main, + border: '6px solid white' + }, + '&.Mui-disabled .MuiSwitch-thumb': { + color: theme.colors.secondary.light + }, + '&.Mui-disabled + .MuiSwitch-track': { + opacity: 0.3 + } + }, + '& .MuiSwitch-thumb': { + boxSizing: 'border-box', + width: 22, + height: 22 + }, + '& .MuiSwitch-track': { + borderRadius: 26 / 2, + backgroundColor: theme.colors.secondary.light, + opacity: 1, + transition: theme.transitions.create(['background-color'], { + duration: 300 + }) + } +})); + export const MiniSwitch = styled((props: SwitchProps) => ( ))(({ theme }) => ({ @@ -184,3 +332,86 @@ export const MiniSwitch = styled((props: SwitchProps) => ( }) } })); + +export const PointTooltip: FC<{ + datasetName: string | number; + value: string | number; + color: string; +}> = ({ datasetName, value, color }) => { + return ( + + + + {datasetName} : {value} + + + ); +}; + +export const SliceTooltip: FC<{ + points: Point[]; + showTotalTime?: boolean; +}> = ({ points, showTotalTime }) => { + const theme = useTheme(); + const totalTime = sum(points.map((p) => Number(p.data.y))); + return ( + + + {points[0].data.xFormatted} + {showTotalTime && ` | Total Time : ${getDurationString(totalTime)}`} + + + {points.map((point, idx) => ( + + ))} + + + ); +}; + +export const BarChartTooltip: FC<{ + point: BarTooltipProps; + datesetFormatter: (datasetName: string) => string; + valueFormatter: (value: number) => string; +}> = ({ point, datesetFormatter, valueFormatter }) => { + const theme = useTheme(); + return ( + + + {point.indexValue} + + + + + + ); +}; diff --git a/web-server/src/components/TicketsTableAddons/ColumnsSelector.tsx b/web-server/src/components/TicketsTableAddons/ColumnsSelector.tsx new file mode 100644 index 000000000..4ec1102db --- /dev/null +++ b/web-server/src/components/TicketsTableAddons/ColumnsSelector.tsx @@ -0,0 +1,129 @@ +import CheckIcon from '@mui/icons-material/Check'; +import TuneIcon from '@mui/icons-material/Tune'; +import { Box, Button, Divider, Popover, useTheme } from '@mui/material'; +import { omit } from 'ramda'; +import { FC, useCallback, useMemo, useRef } from 'react'; + +import { Line } from '@/components/Text'; +import { useBoolState } from '@/hooks/useEasyState'; +import { useFeature } from '@/hooks/useFeature'; +import { appSlice, DEFAULT_COLUMN_STATE_MAP } from '@/slices/app'; +import { useDispatch, useSelector } from '@/store'; + +import { FlexBox } from '../FlexBox'; + +const formatColumnName = (name: string) => name.split('_').join(' '); +const JIRA_SPRINT_COLUMNS = ['is_planned']; + +export const ColumnsSelector: FC<{ hideJiraSprintColumns: boolean }> = ({ + hideJiraSprintColumns +}) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const isSprintDetailsColumnEnable = useFeature( + 'enable_sprint_details_column' + ); + const elRef = useRef(null); + const isPopoverOpen = useBoolState(false); + const ticketsTableColumnConfig = useSelector( + (s) => s.app.ticketsTableColumnConfig || DEFAULT_COLUMN_STATE_MAP + ); + const filteredTicketsTableColumnConfig = useMemo(() => { + let columnsToShow = ticketsTableColumnConfig; + if (!isSprintDetailsColumnEnable) { + columnsToShow = omit(['sprint_history', 'current_sprint'], columnsToShow); + } + if (hideJiraSprintColumns) { + columnsToShow = omit(JIRA_SPRINT_COLUMNS, columnsToShow); + } + return columnsToShow; + }, [ + hideJiraSprintColumns, + isSprintDetailsColumnEnable, + ticketsTableColumnConfig + ]); + + const handleColumnToggle = useCallback( + (columnName: keyof typeof ticketsTableColumnConfig) => { + const updatedColumns = { ...ticketsTableColumnConfig }; + updatedColumns[columnName] = !updatedColumns[columnName]; + dispatch(appSlice.actions.setTicketsTableColumnConfig(updatedColumns)); + }, + [dispatch, ticketsTableColumnConfig] + ); + + return ( + <> + + + + + + Configure columns + + + + + {Object.entries(filteredTicketsTableColumnConfig)?.map( + ([columnName, isEnabled], idx) => { + return ( + + handleColumnToggle( + columnName as keyof typeof ticketsTableColumnConfig + ) + } + > + + {isEnabled && } + + {formatColumnName(columnName)} + + ); + } + )} + + + + + ); +}; diff --git a/web-server/src/components/TicketsTableAddons/SearchInput.tsx b/web-server/src/components/TicketsTableAddons/SearchInput.tsx new file mode 100644 index 000000000..108e04f72 --- /dev/null +++ b/web-server/src/components/TicketsTableAddons/SearchInput.tsx @@ -0,0 +1,35 @@ +import { SearchRounded } from '@mui/icons-material'; +import ClearRoundedIcon from '@mui/icons-material/ClearRounded'; +import { IconButton, InputAdornment, TextField } from '@mui/material'; +import { FC } from 'react'; + +export const SearchInput: FC<{ + inputHandler: (inputText: string) => void; + inputText: string; +}> = ({ inputHandler, inputText }) => { + return ( + inputHandler(e.target.value)} + InputProps={{ + startAdornment: , + endAdornment: ( + + {inputText && ( + inputHandler('')} + edge="end" + > + + + )} + + ) + }} + sx={{ width: '350px' }} + /> + ); +}; diff --git a/web-server/src/components/TrendsLineChart.tsx b/web-server/src/components/TrendsLineChart.tsx new file mode 100644 index 000000000..68d85dcf4 --- /dev/null +++ b/web-server/src/components/TrendsLineChart.tsx @@ -0,0 +1,169 @@ +import { DatumValue, ValueFormat } from '@nivo/core'; +import { ResponsiveLine, Serie, LineProps } from '@nivo/line'; +import { format } from 'date-fns'; +import { last } from 'ramda'; +import { FC, useMemo } from 'react'; + +import { createTickArray } from '@/utils/array'; +import { + getDurationString, + getDurationStringWithPlaceholderTxt +} from '@/utils/date'; + +import { SliceTooltip } from './Shared'; +import { Line } from './Text'; + +const chartTheme = { + axis: { + ticks: { + text: { + fill: 'white', + fontSize: 'small' + } + }, + domain: { + line: { + stroke: 'grey', + strokeWidth: 2 + } + } + }, + crosshair: { + line: { + stroke: 'white', + strokeWidth: 2, + strokeOpacity: 0.5 + } + }, + grid: { + line: { + stroke: '#d4d3f360', + strokeWidth: 1, + strokeDasharray: '5 10' + } + } +}; + +export const formatAsDate = (value: DatumValue) => { + if (!isDateString(String(value))) return value; + return format(new Date(value), 'do MMM'); +}; +function isDateString(str: string) { + const date = new Date(str); + return !isNaN(date.getDate()); +} +const WEEK_FORMATTING_THRESHOLD = 6; + +export const TrendsLineChart: FC< + { + series: Serie[]; + showTotalTime?: boolean; + isTimeBased?: boolean; + wholeNumbersOnly?: boolean; + } & Partial +> = ({ series, showTotalTime, isTimeBased, wholeNumbersOnly, ...props }) => { + const colorGradients = series.map((_, i) => ({ + colors: [ + { + color: 'inherit', + offset: 0 + }, + { + color: 'inherit', + offset: 100, + opacity: 0 + } + ], + id: `gradient-${i}`, + type: 'linearGradient' + })); + + const colorFillArray = series.map((_, i) => ({ + id: `gradient-${i}`, + match: '*' as '*' // Nivo needs this for some reason + })); + + const weeksCount = (series.length && series[0].data.length) || 0; + + const bottomAxisRotationFactor = useMemo(() => { + return Math.ceil(weeksCount / WEEK_FORMATTING_THRESHOLD); + }, [weeksCount]); + + if (!series.length) + return ( + + No Trends Data Available + + ); + + const array = useMemo( + () => + series + .map((item) => item.data.map((item) => ({ y: Number(item.y) }))) + .flat(), + [series] + ); + + const tickValues = useMemo( + () => + createTickArray(array, { + isTimeBased: isTimeBased, + wholeNumbers: wholeNumbersOnly + }), + [array, isTimeBased, wholeNumbersOnly] + ); + + return ( + d.color} + defs={colorGradients} + fill={colorFillArray} + areaOpacity={0.15} + enableCrosshair + enableArea + useMesh + enableSlices="x" + enableGridX={false} + axisLeft={{ + format: getDurationString, + tickSize: 4, + tickPadding: 15, + ...props?.axisLeft, + tickValues: tickValues + }} + axisBottom={{ + tickPadding: 15, + tickSize: 4, + format: formatAsDate, + tickRotation: + bottomAxisRotationFactor > 1 ? bottomAxisRotationFactor * -8 : 0, + ...props.axisBottom + }} + gridYValues={props?.gridYValues ?? tickValues} + theme={chartTheme} + margin={{ + bottom: 25 + 10 * bottomAxisRotationFactor, + left: 75, + right: 40, + top: 20 + }} + yScale={{ + type: 'linear', + max: last(tickValues) + }} + yFormat={ + (props.yFormat ?? + getDurationStringWithPlaceholderTxt) as ValueFormat + } + xFormat={formatAsDate as ValueFormat} + sliceTooltip={(x) => { + return ( + + ); + }} + /> + ); +}; diff --git a/web-server/src/constants/lang-colors.ts b/web-server/src/constants/lang-colors.ts new file mode 100644 index 000000000..ed09f75e8 --- /dev/null +++ b/web-server/src/constants/lang-colors.ts @@ -0,0 +1,2107 @@ +// 20220707022753 +// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json + +// NOTE: LANGUAGE NAMES ARE LOWERCASED + +export const langColors: Record = { + '1c enterprise': { + color: '#814CCC', + url: 'https://github.com/trending?l=1C-Enterprise' + }, + '2-dimensional array': { + color: '#38761D', + url: 'https://github.com/trending?l=2-Dimensional-Array' + }, + '4d': { + color: '#004289', + url: 'https://github.com/trending?l=4D' + }, + abap: { + color: '#E8274B', + url: 'https://github.com/trending?l=ABAP' + }, + 'abap cds': { + color: '#555e25', + url: 'https://github.com/trending?l=ABAP-CDS' + }, + actionscript: { + color: '#882B0F', + url: 'https://github.com/trending?l=ActionScript' + }, + ada: { + color: '#02f88c', + url: 'https://github.com/trending?l=Ada' + }, + 'adobe font metrics': { + color: '#fa0f00', + url: 'https://github.com/trending?l=Adobe-Font-Metrics' + }, + agda: { + color: '#315665', + url: 'https://github.com/trending?l=Agda' + }, + 'ags script': { + color: '#B9D9FF', + url: 'https://github.com/trending?l=AGS-Script' + }, + aidl: { + color: '#34EB6B', + url: 'https://github.com/trending?l=AIDL' + }, + al: { + color: '#3AA2B5', + url: 'https://github.com/trending?l=AL' + }, + alloy: { + color: '#64C800', + url: 'https://github.com/trending?l=Alloy' + }, + 'alpine abuild': { + color: '#0D597F', + url: 'https://github.com/trending?l=Alpine-Abuild' + }, + 'altium designer': { + color: '#A89663', + url: 'https://github.com/trending?l=Altium-Designer' + }, + ampl: { + color: '#E6EFBB', + url: 'https://github.com/trending?l=AMPL' + }, + angelscript: { + color: '#C7D7DC', + url: 'https://github.com/trending?l=AngelScript' + }, + 'ant build system': { + color: '#A9157E', + url: 'https://github.com/trending?l=Ant-Build-System' + }, + antlers: { + color: '#ff269e', + url: 'https://github.com/trending?l=Antlers' + }, + antlr: { + color: '#9DC3FF', + url: 'https://github.com/trending?l=ANTLR' + }, + apacheconf: { + color: '#d12127', + url: 'https://github.com/trending?l=ApacheConf' + }, + apex: { + color: '#1797c0', + url: 'https://github.com/trending?l=Apex' + }, + 'api blueprint': { + color: '#2ACCA8', + url: 'https://github.com/trending?l=API-Blueprint' + }, + apl: { + color: '#5A8164', + url: 'https://github.com/trending?l=APL' + }, + 'apollo guidance computer': { + color: '#0B3D91', + url: 'https://github.com/trending?l=Apollo-Guidance-Computer' + }, + applescript: { + color: '#101F1F', + url: 'https://github.com/trending?l=AppleScript' + }, + arc: { + color: '#aa2afe', + url: 'https://github.com/trending?l=Arc' + }, + asciidoc: { + color: '#73a0c5', + url: 'https://github.com/trending?l=AsciiDoc' + }, + 'asp.net': { + color: '#9400ff', + url: 'https://github.com/trending?l=ASP.NET' + }, + aspectj: { + color: '#a957b0', + url: 'https://github.com/trending?l=AspectJ' + }, + assembly: { + color: '#6E4C13', + url: 'https://github.com/trending?l=Assembly' + }, + astro: { + color: '#ff5a03', + url: 'https://github.com/trending?l=Astro' + }, + asymptote: { + color: '#ff0000', + url: 'https://github.com/trending?l=Asymptote' + }, + ats: { + color: '#1ac620', + url: 'https://github.com/trending?l=ATS' + }, + augeas: { + color: '#9CC134', + url: 'https://github.com/trending?l=Augeas' + }, + autohotkey: { + color: '#6594b9', + url: 'https://github.com/trending?l=AutoHotkey' + }, + autoit: { + color: '#1C3552', + url: 'https://github.com/trending?l=AutoIt' + }, + 'avro idl': { + color: '#0040FF', + url: 'https://github.com/trending?l=Avro-IDL' + }, + awk: { + color: '#c30e9b', + url: 'https://github.com/trending?l=Awk' + }, + ballerina: { + color: '#FF5000', + url: 'https://github.com/trending?l=Ballerina' + }, + basic: { + color: '#ff0000', + url: 'https://github.com/trending?l=BASIC' + }, + batchfile: { + color: '#C1F12E', + url: 'https://github.com/trending?l=Batchfile' + }, + beef: { + color: '#a52f4e', + url: 'https://github.com/trending?l=Beef' + }, + berry: { + color: '#15A13C', + url: 'https://github.com/trending?l=Berry' + }, + bibtex: { + color: '#778899', + url: 'https://github.com/trending?l=BibTeX' + }, + bicep: { + color: '#519aba', + url: 'https://github.com/trending?l=Bicep' + }, + bikeshed: { + color: '#5562ac', + url: 'https://github.com/trending?l=Bikeshed' + }, + bison: { + color: '#6A463F', + url: 'https://github.com/trending?l=Bison' + }, + bitbake: { + color: '#00bce4', + url: 'https://github.com/trending?l=BitBake' + }, + blade: { + color: '#f7523f', + url: 'https://github.com/trending?l=Blade' + }, + blitzbasic: { + color: '#00FFAE', + url: 'https://github.com/trending?l=BlitzBasic' + }, + blitzmax: { + color: '#cd6400', + url: 'https://github.com/trending?l=BlitzMax' + }, + bluespec: { + color: '#12223c', + url: 'https://github.com/trending?l=Bluespec' + }, + boo: { + color: '#d4bec1', + url: 'https://github.com/trending?l=Boo' + }, + boogie: { + color: '#c80fa0', + url: 'https://github.com/trending?l=Boogie' + }, + brainfuck: { + color: '#2F2530', + url: 'https://github.com/trending?l=Brainfuck' + }, + brighterscript: { + color: '#66AABB', + url: 'https://github.com/trending?l=BrighterScript' + }, + brightscript: { + color: '#662D91', + url: 'https://github.com/trending?l=Brightscript' + }, + browserslist: { + color: '#ffd539', + url: 'https://github.com/trending?l=Browserslist' + }, + c: { + color: '#555555', + url: 'https://github.com/trending?l=C' + }, + 'c#': { + color: '#178600', + url: 'https://github.com/trending?l=Csharp' + }, + 'c++': { + color: '#f34b7d', + url: 'https://github.com/trending?l=C++' + }, + 'cabal config': { + color: '#483465', + url: 'https://github.com/trending?l=Cabal-Config' + }, + cadence: { + color: '#00ef8b', + url: 'https://github.com/trending?l=Cadence' + }, + cairo: { + color: '#ff4a48', + url: 'https://github.com/trending?l=Cairo' + }, + cameligo: { + color: '#3be133', + url: 'https://github.com/trending?l=CameLIGO' + }, + 'cap cds': { + color: '#0092d1', + url: 'https://github.com/trending?l=CAP-CDS' + }, + "cap'n proto": { + color: '#c42727', + url: "https://github.com/trending?l=Cap'n-Proto" + }, + ceylon: { + color: '#dfa535', + url: 'https://github.com/trending?l=Ceylon' + }, + chapel: { + color: '#8dc63f', + url: 'https://github.com/trending?l=Chapel' + }, + chuck: { + color: '#3f8000', + url: 'https://github.com/trending?l=ChucK' + }, + cirru: { + color: '#ccccff', + url: 'https://github.com/trending?l=Cirru' + }, + clarion: { + color: '#db901e', + url: 'https://github.com/trending?l=Clarion' + }, + clarity: { + color: '#5546ff', + url: 'https://github.com/trending?l=Clarity' + }, + 'classic asp': { + color: '#6a40fd', + url: 'https://github.com/trending?l=Classic-ASP' + }, + clean: { + color: '#3F85AF', + url: 'https://github.com/trending?l=Clean' + }, + click: { + color: '#E4E6F3', + url: 'https://github.com/trending?l=Click' + }, + clips: { + color: '#00A300', + url: 'https://github.com/trending?l=CLIPS' + }, + clojure: { + color: '#db5855', + url: 'https://github.com/trending?l=Clojure' + }, + 'closure templates': { + color: '#0d948f', + url: 'https://github.com/trending?l=Closure-Templates' + }, + 'cloud firestore security rules': { + color: '#FFA000', + url: 'https://github.com/trending?l=Cloud-Firestore-Security-Rules' + }, + cmake: { + color: '#DA3434', + url: 'https://github.com/trending?l=CMake' + }, + codeql: { + color: '#140f46', + url: 'https://github.com/trending?l=CodeQL' + }, + coffeescript: { + color: '#244776', + url: 'https://github.com/trending?l=CoffeeScript' + }, + coldfusion: { + color: '#ed2cd6', + url: 'https://github.com/trending?l=ColdFusion' + }, + 'coldfusion cfc': { + color: '#ed2cd6', + url: 'https://github.com/trending?l=ColdFusion-CFC' + }, + collada: { + color: '#F1A42B', + url: 'https://github.com/trending?l=COLLADA' + }, + 'common lisp': { + color: '#3fb68b', + url: 'https://github.com/trending?l=Common-Lisp' + }, + 'common workflow language': { + color: '#B5314C', + url: 'https://github.com/trending?l=Common-Workflow-Language' + }, + 'component pascal': { + color: '#B0CE4E', + url: 'https://github.com/trending?l=Component-Pascal' + }, + coq: { + color: '#d0b68c', + url: 'https://github.com/trending?l=Coq' + }, + crystal: { + color: '#000100', + url: 'https://github.com/trending?l=Crystal' + }, + cson: { + color: '#244776', + url: 'https://github.com/trending?l=CSON' + }, + csound: { + color: '#1a1a1a', + url: 'https://github.com/trending?l=Csound' + }, + 'csound document': { + color: '#1a1a1a', + url: 'https://github.com/trending?l=Csound-Document' + }, + 'csound score': { + color: '#1a1a1a', + url: 'https://github.com/trending?l=Csound-Score' + }, + css: { + color: '#563d7c', + url: 'https://github.com/trending?l=CSS' + }, + csv: { + color: '#237346', + url: 'https://github.com/trending?l=CSV' + }, + cuda: { + color: '#3A4E3A', + url: 'https://github.com/trending?l=Cuda' + }, + cue: { + color: '#5886E1', + url: 'https://github.com/trending?l=CUE' + }, + curry: { + color: '#531242', + url: 'https://github.com/trending?l=Curry' + }, + cweb: { + color: '#00007a', + url: 'https://github.com/trending?l=CWeb' + }, + cython: { + color: '#fedf5b', + url: 'https://github.com/trending?l=Cython' + }, + d: { + color: '#ba595e', + url: 'https://github.com/trending?l=D' + }, + dafny: { + color: '#FFEC25', + url: 'https://github.com/trending?l=Dafny' + }, + 'darcs patch': { + color: '#8eff23', + url: 'https://github.com/trending?l=Darcs-Patch' + }, + dart: { + color: '#00B4AB', + url: 'https://github.com/trending?l=Dart' + }, + dataweave: { + color: '#003a52', + url: 'https://github.com/trending?l=DataWeave' + }, + 'debian package control file': { + color: '#D70751', + url: 'https://github.com/trending?l=Debian-Package-Control-File' + }, + denizenscript: { + color: '#FBEE96', + url: 'https://github.com/trending?l=DenizenScript' + }, + dhall: { + color: '#dfafff', + url: 'https://github.com/trending?l=Dhall' + }, + 'directx 3d file': { + color: '#aace60', + url: 'https://github.com/trending?l=DirectX-3D-File' + }, + dm: { + color: '#447265', + url: 'https://github.com/trending?l=DM' + }, + dockerfile: { + color: '#384d54', + url: 'https://github.com/trending?l=Dockerfile' + }, + dogescript: { + color: '#cca760', + url: 'https://github.com/trending?l=Dogescript' + }, + dylan: { + color: '#6c616e', + url: 'https://github.com/trending?l=Dylan' + }, + e: { + color: '#ccce35', + url: 'https://github.com/trending?l=E' + }, + earthly: { + color: '#2af0ff', + url: 'https://github.com/trending?l=Earthly' + }, + easybuild: { + color: '#069406', + url: 'https://github.com/trending?l=Easybuild' + }, + ec: { + color: '#913960', + url: 'https://github.com/trending?l=eC' + }, + 'ecere projects': { + color: '#913960', + url: 'https://github.com/trending?l=Ecere-Projects' + }, + ecl: { + color: '#8a1267', + url: 'https://github.com/trending?l=ECL' + }, + eclipse: { + color: '#001d9d', + url: 'https://github.com/trending?l=ECLiPSe' + }, + editorconfig: { + color: '#fff1f2', + url: 'https://github.com/trending?l=EditorConfig' + }, + eiffel: { + color: '#4d6977', + url: 'https://github.com/trending?l=Eiffel' + }, + ejs: { + color: '#a91e50', + url: 'https://github.com/trending?l=EJS' + }, + elixir: { + color: '#6e4a7e', + url: 'https://github.com/trending?l=Elixir' + }, + elm: { + color: '#60B5CC', + url: 'https://github.com/trending?l=Elm' + }, + 'emacs lisp': { + color: '#c065db', + url: 'https://github.com/trending?l=Emacs-Lisp' + }, + emberscript: { + color: '#FFF4F3', + url: 'https://github.com/trending?l=EmberScript' + }, + eq: { + color: '#a78649', + url: 'https://github.com/trending?l=EQ' + }, + erlang: { + color: '#B83998', + url: 'https://github.com/trending?l=Erlang' + }, + euphoria: { + color: '#FF790B', + url: 'https://github.com/trending?l=Euphoria' + }, + 'f#': { + color: '#b845fc', + url: 'https://github.com/trending?l=Fsharp' + }, + 'f*': { + color: '#572e30', + url: 'https://github.com/trending?l=F*' + }, + factor: { + color: '#636746', + url: 'https://github.com/trending?l=Factor' + }, + fancy: { + color: '#7b9db4', + url: 'https://github.com/trending?l=Fancy' + }, + fantom: { + color: '#14253c', + url: 'https://github.com/trending?l=Fantom' + }, + faust: { + color: '#c37240', + url: 'https://github.com/trending?l=Faust' + }, + fennel: { + color: '#fff3d7', + url: 'https://github.com/trending?l=Fennel' + }, + 'figlet font': { + color: '#FFDDBB', + url: 'https://github.com/trending?l=FIGlet-Font' + }, + 'filebench wml': { + color: '#F6B900', + url: 'https://github.com/trending?l=Filebench-WML' + }, + fish: { + color: '#4aae47', + url: 'https://github.com/trending?l=fish' + }, + fluent: { + color: '#ffcc33', + url: 'https://github.com/trending?l=Fluent' + }, + flux: { + color: '#88ccff', + url: 'https://github.com/trending?l=FLUX' + }, + forth: { + color: '#341708', + url: 'https://github.com/trending?l=Forth' + }, + fortran: { + color: '#4d41b1', + url: 'https://github.com/trending?l=Fortran' + }, + 'fortran free form': { + color: '#4d41b1', + url: 'https://github.com/trending?l=Fortran-Free-Form' + }, + freebasic: { + color: '#867db1', + url: 'https://github.com/trending?l=FreeBasic' + }, + freemarker: { + color: '#0050b2', + url: 'https://github.com/trending?l=FreeMarker' + }, + frege: { + color: '#00cafe', + url: 'https://github.com/trending?l=Frege' + }, + futhark: { + color: '#5f021f', + url: 'https://github.com/trending?l=Futhark' + }, + 'g-code': { + color: '#D08CF2', + url: 'https://github.com/trending?l=G-code' + }, + 'game maker language': { + color: '#71b417', + url: 'https://github.com/trending?l=Game-Maker-Language' + }, + gaml: { + color: '#FFC766', + url: 'https://github.com/trending?l=GAML' + }, + gams: { + color: '#f49a22', + url: 'https://github.com/trending?l=GAMS' + }, + gap: { + color: '#0000cc', + url: 'https://github.com/trending?l=GAP' + }, + 'gcc machine description': { + color: '#FFCFAB', + url: 'https://github.com/trending?l=GCC-Machine-Description' + }, + gdscript: { + color: '#355570', + url: 'https://github.com/trending?l=GDScript' + }, + gedcom: { + color: '#003058', + url: 'https://github.com/trending?l=GEDCOM' + }, + 'gemfile.lock': { + color: '#701516', + url: 'https://github.com/trending?l=Gemfile.lock' + }, + genero: { + color: '#63408e', + url: 'https://github.com/trending?l=Genero' + }, + 'genero forms': { + color: '#d8df39', + url: 'https://github.com/trending?l=Genero-Forms' + }, + genie: { + color: '#fb855d', + url: 'https://github.com/trending?l=Genie' + }, + genshi: { + color: '#951531', + url: 'https://github.com/trending?l=Genshi' + }, + 'gentoo ebuild': { + color: '#9400ff', + url: 'https://github.com/trending?l=Gentoo-Ebuild' + }, + 'gentoo eclass': { + color: '#9400ff', + url: 'https://github.com/trending?l=Gentoo-Eclass' + }, + 'gerber image': { + color: '#d20b00', + url: 'https://github.com/trending?l=Gerber-Image' + }, + gherkin: { + color: '#5B2063', + url: 'https://github.com/trending?l=Gherkin' + }, + 'git attributes': { + color: '#F44D27', + url: 'https://github.com/trending?l=Git-Attributes' + }, + 'git config': { + color: '#F44D27', + url: 'https://github.com/trending?l=Git-Config' + }, + 'git revision list': { + color: '#F44D27', + url: 'https://github.com/trending?l=Git-Revision-List' + }, + gleam: { + color: '#ffaff3', + url: 'https://github.com/trending?l=Gleam' + }, + glsl: { + color: '#5686a5', + url: 'https://github.com/trending?l=GLSL' + }, + glyph: { + color: '#c1ac7f', + url: 'https://github.com/trending?l=Glyph' + }, + gnuplot: { + color: '#f0a9f0', + url: 'https://github.com/trending?l=Gnuplot' + }, + go: { + color: '#00ADD8', + url: 'https://github.com/trending?l=Go' + }, + 'go checksums': { + color: '#00ADD8', + url: 'https://github.com/trending?l=Go-Checksums' + }, + 'go module': { + color: '#00ADD8', + url: 'https://github.com/trending?l=Go-Module' + }, + golo: { + color: '#88562A', + url: 'https://github.com/trending?l=Golo' + }, + gosu: { + color: '#82937f', + url: 'https://github.com/trending?l=Gosu' + }, + grace: { + color: '#615f8b', + url: 'https://github.com/trending?l=Grace' + }, + gradle: { + color: '#02303a', + url: 'https://github.com/trending?l=Gradle' + }, + 'grammatical framework': { + color: '#ff0000', + url: 'https://github.com/trending?l=Grammatical-Framework' + }, + graphql: { + color: '#e10098', + url: 'https://github.com/trending?l=GraphQL' + }, + 'graphviz (dot)': { + color: '#2596be', + url: 'https://github.com/trending?l=Graphviz-(DOT)' + }, + groovy: { + color: '#4298b8', + url: 'https://github.com/trending?l=Groovy' + }, + 'groovy server pages': { + color: '#4298b8', + url: 'https://github.com/trending?l=Groovy-Server-Pages' + }, + gsc: { + color: '#FF6800', + url: 'https://github.com/trending?l=GSC' + }, + hack: { + color: '#878787', + url: 'https://github.com/trending?l=Hack' + }, + haml: { + color: '#ece2a9', + url: 'https://github.com/trending?l=Haml' + }, + handlebars: { + color: '#f7931e', + url: 'https://github.com/trending?l=Handlebars' + }, + haproxy: { + color: '#106da9', + url: 'https://github.com/trending?l=HAProxy' + }, + harbour: { + color: '#0e60e3', + url: 'https://github.com/trending?l=Harbour' + }, + haskell: { + color: '#5e5086', + url: 'https://github.com/trending?l=Haskell' + }, + haxe: { + color: '#df7900', + url: 'https://github.com/trending?l=Haxe' + }, + hcl: { + color: '#0073b7', + url: 'https://github.com/trending?l=HCL' + }, + hiveql: { + color: '#dce200', + url: 'https://github.com/trending?l=HiveQL' + }, + hlsl: { + color: '#aace60', + url: 'https://github.com/trending?l=HLSL' + }, + holyc: { + color: '#ffefaf', + url: 'https://github.com/trending?l=HolyC' + }, + hoon: { + color: '#00b171', + url: 'https://github.com/trending?l=hoon' + }, + html: { + color: '#e34c26', + url: 'https://github.com/trending?l=HTML' + }, + 'html+ecr': { + color: '#2e1052', + url: 'https://github.com/trending?l=HTML+ECR' + }, + 'html+eex': { + color: '#6e4a7e', + url: 'https://github.com/trending?l=HTML+EEX' + }, + 'html+erb': { + color: '#701516', + url: 'https://github.com/trending?l=HTML+ERB' + }, + 'html+php': { + color: '#4f5d95', + url: 'https://github.com/trending?l=HTML+PHP' + }, + 'html+razor': { + color: '#512be4', + url: 'https://github.com/trending?l=HTML+Razor' + }, + http: { + color: '#005C9C', + url: 'https://github.com/trending?l=HTTP' + }, + hxml: { + color: '#f68712', + url: 'https://github.com/trending?l=HXML' + }, + hy: { + color: '#7790B2', + url: 'https://github.com/trending?l=Hy' + }, + idl: { + color: '#a3522f', + url: 'https://github.com/trending?l=IDL' + }, + idris: { + color: '#b30000', + url: 'https://github.com/trending?l=Idris' + }, + 'ignore list': { + color: '#000000', + url: 'https://github.com/trending?l=Ignore-List' + }, + 'igor pro': { + color: '#0000cc', + url: 'https://github.com/trending?l=IGOR-Pro' + }, + 'imagej macro': { + color: '#99AAFF', + url: 'https://github.com/trending?l=ImageJ-Macro' + }, + ini: { + color: '#d1dbe0', + url: 'https://github.com/trending?l=INI' + }, + 'inno setup': { + color: '#264b99', + url: 'https://github.com/trending?l=Inno-Setup' + }, + io: { + color: '#a9188d', + url: 'https://github.com/trending?l=Io' + }, + ioke: { + color: '#078193', + url: 'https://github.com/trending?l=Ioke' + }, + isabelle: { + color: '#FEFE00', + url: 'https://github.com/trending?l=Isabelle' + }, + 'isabelle root': { + color: '#FEFE00', + url: 'https://github.com/trending?l=Isabelle-ROOT' + }, + j: { + color: '#9EEDFF', + url: 'https://github.com/trending?l=J' + }, + janet: { + color: '#0886a5', + url: 'https://github.com/trending?l=Janet' + }, + 'jar manifest': { + color: '#b07219', + url: 'https://github.com/trending?l=JAR-Manifest' + }, + jasmin: { + color: '#d03600', + url: 'https://github.com/trending?l=Jasmin' + }, + java: { + color: '#b07219', + url: 'https://github.com/trending?l=Java' + }, + 'java properties': { + color: '#2A6277', + url: 'https://github.com/trending?l=Java-Properties' + }, + 'java server pages': { + color: '#2A6277', + url: 'https://github.com/trending?l=Java-Server-Pages' + }, + javascript: { + color: '#f1e05a', + url: 'https://github.com/trending?l=JavaScript' + }, + 'javascript+erb': { + color: '#f1e05a', + url: 'https://github.com/trending?l=JavaScript+ERB' + }, + 'jest snapshot': { + color: '#15c213', + url: 'https://github.com/trending?l=Jest-Snapshot' + }, + 'jetbrains mps': { + color: '#21D789', + url: 'https://github.com/trending?l=JetBrains-MPS' + }, + jflex: { + color: '#DBCA00', + url: 'https://github.com/trending?l=JFlex' + }, + jinja: { + color: '#a52a22', + url: 'https://github.com/trending?l=Jinja' + }, + jison: { + color: '#56b3cb', + url: 'https://github.com/trending?l=Jison' + }, + 'jison lex': { + color: '#56b3cb', + url: 'https://github.com/trending?l=Jison-Lex' + }, + jolie: { + color: '#843179', + url: 'https://github.com/trending?l=Jolie' + }, + jq: { + color: '#c7254e', + url: 'https://github.com/trending?l=jq' + }, + json: { + color: '#292929', + url: 'https://github.com/trending?l=JSON' + }, + 'json with comments': { + color: '#292929', + url: 'https://github.com/trending?l=JSON-with-Comments' + }, + json5: { + color: '#267CB9', + url: 'https://github.com/trending?l=JSON5' + }, + jsoniq: { + color: '#40d47e', + url: 'https://github.com/trending?l=JSONiq' + }, + jsonld: { + color: '#0c479c', + url: 'https://github.com/trending?l=JSONLD' + }, + jsonnet: { + color: '#0064bd', + url: 'https://github.com/trending?l=Jsonnet' + }, + julia: { + color: '#a270ba', + url: 'https://github.com/trending?l=Julia' + }, + 'jupyter notebook': { + color: '#DA5B0B', + url: 'https://github.com/trending?l=Jupyter-Notebook' + }, + 'kaitai struct': { + color: '#773b37', + url: 'https://github.com/trending?l=Kaitai-Struct' + }, + kakounescript: { + color: '#6f8042', + url: 'https://github.com/trending?l=KakouneScript' + }, + 'kicad layout': { + color: '#2f4aab', + url: 'https://github.com/trending?l=KiCad-Layout' + }, + 'kicad legacy layout': { + color: '#2f4aab', + url: 'https://github.com/trending?l=KiCad-Legacy-Layout' + }, + 'kicad schematic': { + color: '#2f4aab', + url: 'https://github.com/trending?l=KiCad-Schematic' + }, + kotlin: { + color: '#A97BFF', + url: 'https://github.com/trending?l=Kotlin' + }, + krl: { + color: '#28430A', + url: 'https://github.com/trending?l=KRL' + }, + kvlang: { + color: '#1da6e0', + url: 'https://github.com/trending?l=kvlang' + }, + labview: { + color: '#fede06', + url: 'https://github.com/trending?l=LabVIEW' + }, + lark: { + color: '#2980B9', + url: 'https://github.com/trending?l=Lark' + }, + lasso: { + color: '#999999', + url: 'https://github.com/trending?l=Lasso' + }, + latte: { + color: '#f2a542', + url: 'https://github.com/trending?l=Latte' + }, + less: { + color: '#1d365d', + url: 'https://github.com/trending?l=Less' + }, + lex: { + color: '#DBCA00', + url: 'https://github.com/trending?l=Lex' + }, + lfe: { + color: '#4C3023', + url: 'https://github.com/trending?l=LFE' + }, + ligolang: { + color: '#0e74ff', + url: 'https://github.com/trending?l=LigoLANG' + }, + lilypond: { + color: '#9ccc7c', + url: 'https://github.com/trending?l=LilyPond' + }, + liquid: { + color: '#67b8de', + url: 'https://github.com/trending?l=Liquid' + }, + 'literate agda': { + color: '#315665', + url: 'https://github.com/trending?l=Literate-Agda' + }, + 'literate coffeescript': { + color: '#244776', + url: 'https://github.com/trending?l=Literate-CoffeeScript' + }, + 'literate haskell': { + color: '#5e5086', + url: 'https://github.com/trending?l=Literate-Haskell' + }, + livescript: { + color: '#499886', + url: 'https://github.com/trending?l=LiveScript' + }, + llvm: { + color: '#185619', + url: 'https://github.com/trending?l=LLVM' + }, + logtalk: { + color: '#295b9a', + url: 'https://github.com/trending?l=Logtalk' + }, + lolcode: { + color: '#cc9900', + url: 'https://github.com/trending?l=LOLCODE' + }, + lookml: { + color: '#652B81', + url: 'https://github.com/trending?l=LookML' + }, + lsl: { + color: '#3d9970', + url: 'https://github.com/trending?l=LSL' + }, + lua: { + color: '#000080', + url: 'https://github.com/trending?l=Lua' + }, + macaulay2: { + color: '#d8ffff', + url: 'https://github.com/trending?l=Macaulay2' + }, + makefile: { + color: '#427819', + url: 'https://github.com/trending?l=Makefile' + }, + mako: { + color: '#7e858d', + url: 'https://github.com/trending?l=Mako' + }, + markdown: { + color: '#083fa1', + url: 'https://github.com/trending?l=Markdown' + }, + marko: { + color: '#42bff2', + url: 'https://github.com/trending?l=Marko' + }, + mask: { + color: '#f97732', + url: 'https://github.com/trending?l=Mask' + }, + mathematica: { + color: '#dd1100', + url: 'https://github.com/trending?l=Mathematica' + }, + matlab: { + color: '#e16737', + url: 'https://github.com/trending?l=MATLAB' + }, + max: { + color: '#c4a79c', + url: 'https://github.com/trending?l=Max' + }, + maxscript: { + color: '#00a6a6', + url: 'https://github.com/trending?l=MAXScript' + }, + mcfunction: { + color: '#E22837', + url: 'https://github.com/trending?l=mcfunction' + }, + mercury: { + color: '#ff2b2b', + url: 'https://github.com/trending?l=Mercury' + }, + meson: { + color: '#007800', + url: 'https://github.com/trending?l=Meson' + }, + metal: { + color: '#8f14e9', + url: 'https://github.com/trending?l=Metal' + }, + miniyaml: { + color: '#ff1111', + url: 'https://github.com/trending?l=MiniYAML' + }, + mint: { + color: '#02b046', + url: 'https://github.com/trending?l=Mint' + }, + mirah: { + color: '#c7a938', + url: 'https://github.com/trending?l=Mirah' + }, + 'mirc script': { + color: '#3d57c3', + url: 'https://github.com/trending?l=mIRC-Script' + }, + mlir: { + color: '#5EC8DB', + url: 'https://github.com/trending?l=MLIR' + }, + modelica: { + color: '#de1d31', + url: 'https://github.com/trending?l=Modelica' + }, + 'modula-2': { + color: '#10253f', + url: 'https://github.com/trending?l=Modula-2' + }, + 'modula-3': { + color: '#223388', + url: 'https://github.com/trending?l=Modula-3' + }, + 'monkey c': { + color: '#8D6747', + url: 'https://github.com/trending?l=Monkey-C' + }, + moonscript: { + color: '#ff4585', + url: 'https://github.com/trending?l=MoonScript' + }, + motoko: { + color: '#fbb03b', + url: 'https://github.com/trending?l=Motoko' + }, + 'motorola 68k assembly': { + color: '#005daa', + url: 'https://github.com/trending?l=Motorola-68K-Assembly' + }, + move: { + color: '#4a137a', + url: 'https://github.com/trending?l=Move' + }, + mql4: { + color: '#62A8D6', + url: 'https://github.com/trending?l=MQL4' + }, + mql5: { + color: '#4A76B8', + url: 'https://github.com/trending?l=MQL5' + }, + mtml: { + color: '#b7e1f4', + url: 'https://github.com/trending?l=MTML' + }, + mupad: { + color: '#244963', + url: 'https://github.com/trending?l=mupad' + }, + mustache: { + color: '#724b3b', + url: 'https://github.com/trending?l=Mustache' + }, + nanorc: { + color: '#2d004d', + url: 'https://github.com/trending?l=nanorc' + }, + nasal: { + color: '#1d2c4e', + url: 'https://github.com/trending?l=Nasal' + }, + ncl: { + color: '#28431f', + url: 'https://github.com/trending?l=NCL' + }, + nearley: { + color: '#990000', + url: 'https://github.com/trending?l=Nearley' + }, + nemerle: { + color: '#3d3c6e', + url: 'https://github.com/trending?l=Nemerle' + }, + nesc: { + color: '#94B0C7', + url: 'https://github.com/trending?l=nesC' + }, + netlinx: { + color: '#0aa0ff', + url: 'https://github.com/trending?l=NetLinx' + }, + 'netlinx+erb': { + color: '#747faa', + url: 'https://github.com/trending?l=NetLinx+ERB' + }, + netlogo: { + color: '#ff6375', + url: 'https://github.com/trending?l=NetLogo' + }, + newlisp: { + color: '#87AED7', + url: 'https://github.com/trending?l=NewLisp' + }, + nextflow: { + color: '#3ac486', + url: 'https://github.com/trending?l=Nextflow' + }, + nginx: { + color: '#009639', + url: 'https://github.com/trending?l=Nginx' + }, + nim: { + color: '#ffc200', + url: 'https://github.com/trending?l=Nim' + }, + nit: { + color: '#009917', + url: 'https://github.com/trending?l=Nit' + }, + nix: { + color: '#7e7eff', + url: 'https://github.com/trending?l=Nix' + }, + 'npm config': { + color: '#cb3837', + url: 'https://github.com/trending?l=NPM-Config' + }, + nu: { + color: '#c9df40', + url: 'https://github.com/trending?l=Nu' + }, + numpy: { + color: '#9C8AF9', + url: 'https://github.com/trending?l=NumPy' + }, + nunjucks: { + color: '#3d8137', + url: 'https://github.com/trending?l=Nunjucks' + }, + nwscript: { + color: '#111522', + url: 'https://github.com/trending?l=NWScript' + }, + 'objective-c': { + color: '#438eff', + url: 'https://github.com/trending?l=Objective-C' + }, + 'objective-c++': { + color: '#6866fb', + url: 'https://github.com/trending?l=Objective-C++' + }, + 'objective-j': { + color: '#ff0c5a', + url: 'https://github.com/trending?l=Objective-J' + }, + objectscript: { + color: '#424893', + url: 'https://github.com/trending?l=ObjectScript' + }, + ocaml: { + color: '#3be133', + url: 'https://github.com/trending?l=OCaml' + }, + odin: { + color: '#60AFFE', + url: 'https://github.com/trending?l=Odin' + }, + omgrofl: { + color: '#cabbff', + url: 'https://github.com/trending?l=Omgrofl' + }, + ooc: { + color: '#b0b77e', + url: 'https://github.com/trending?l=ooc' + }, + opal: { + color: '#f7ede0', + url: 'https://github.com/trending?l=Opal' + }, + 'open policy agent': { + color: '#7d9199', + url: 'https://github.com/trending?l=Open-Policy-Agent' + }, + opencl: { + color: '#ed2e2d', + url: 'https://github.com/trending?l=OpenCL' + }, + 'openedge abl': { + color: '#5ce600', + url: 'https://github.com/trending?l=OpenEdge-ABL' + }, + openqasm: { + color: '#AA70FF', + url: 'https://github.com/trending?l=OpenQASM' + }, + openscad: { + color: '#e5cd45', + url: 'https://github.com/trending?l=OpenSCAD' + }, + org: { + color: '#77aa99', + url: 'https://github.com/trending?l=Org' + }, + oxygene: { + color: '#cdd0e3', + url: 'https://github.com/trending?l=Oxygene' + }, + oz: { + color: '#fab738', + url: 'https://github.com/trending?l=Oz' + }, + p4: { + color: '#7055b5', + url: 'https://github.com/trending?l=P4' + }, + pan: { + color: '#cc0000', + url: 'https://github.com/trending?l=Pan' + }, + papyrus: { + color: '#6600cc', + url: 'https://github.com/trending?l=Papyrus' + }, + parrot: { + color: '#f3ca0a', + url: 'https://github.com/trending?l=Parrot' + }, + pascal: { + color: '#E3F171', + url: 'https://github.com/trending?l=Pascal' + }, + pawn: { + color: '#dbb284', + url: 'https://github.com/trending?l=Pawn' + }, + 'peg.js': { + color: '#234d6b', + url: 'https://github.com/trending?l=PEG.js' + }, + pep8: { + color: '#C76F5B', + url: 'https://github.com/trending?l=Pep8' + }, + perl: { + color: '#0298c3', + url: 'https://github.com/trending?l=Perl' + }, + php: { + color: '#4F5D95', + url: 'https://github.com/trending?l=PHP' + }, + picolisp: { + color: '#6067af', + url: 'https://github.com/trending?l=PicoLisp' + }, + piglatin: { + color: '#fcd7de', + url: 'https://github.com/trending?l=PigLatin' + }, + pike: { + color: '#005390', + url: 'https://github.com/trending?l=Pike' + }, + plpgsql: { + color: '#336790', + url: 'https://github.com/trending?l=PLpgSQL' + }, + plsql: { + color: '#dad8d8', + url: 'https://github.com/trending?l=PLSQL' + }, + pogoscript: { + color: '#d80074', + url: 'https://github.com/trending?l=PogoScript' + }, + portugol: { + color: '#f8bd00', + url: 'https://github.com/trending?l=Portugol' + }, + postcss: { + color: '#dc3a0c', + url: 'https://github.com/trending?l=PostCSS' + }, + postscript: { + color: '#da291c', + url: 'https://github.com/trending?l=PostScript' + }, + 'pov-ray sdl': { + color: '#6bac65', + url: 'https://github.com/trending?l=POV-Ray-SDL' + }, + powerbuilder: { + color: '#8f0f8d', + url: 'https://github.com/trending?l=PowerBuilder' + }, + powershell: { + color: '#012456', + url: 'https://github.com/trending?l=PowerShell' + }, + prisma: { + color: '#0c344b', + url: 'https://github.com/trending?l=Prisma' + }, + processing: { + color: '#0096D8', + url: 'https://github.com/trending?l=Processing' + }, + procfile: { + color: '#3B2F63', + url: 'https://github.com/trending?l=Procfile' + }, + prolog: { + color: '#74283c', + url: 'https://github.com/trending?l=Prolog' + }, + promela: { + color: '#de0000', + url: 'https://github.com/trending?l=Promela' + }, + 'propeller spin': { + color: '#7fa2a7', + url: 'https://github.com/trending?l=Propeller-Spin' + }, + pug: { + color: '#a86454', + url: 'https://github.com/trending?l=Pug' + }, + puppet: { + color: '#302B6D', + url: 'https://github.com/trending?l=Puppet' + }, + purebasic: { + color: '#5a6986', + url: 'https://github.com/trending?l=PureBasic' + }, + purescript: { + color: '#1D222D', + url: 'https://github.com/trending?l=PureScript' + }, + python: { + color: '#3572A5', + url: 'https://github.com/trending?l=Python' + }, + 'python console': { + color: '#3572A5', + url: 'https://github.com/trending?l=Python-console' + }, + 'python traceback': { + color: '#3572A5', + url: 'https://github.com/trending?l=Python-traceback' + }, + q: { + color: '#0040cd', + url: 'https://github.com/trending?l=q' + }, + 'q#': { + color: '#fed659', + url: 'https://github.com/trending?l=Qsharp' + }, + qml: { + color: '#44a51c', + url: 'https://github.com/trending?l=QML' + }, + 'qt script': { + color: '#00b841', + url: 'https://github.com/trending?l=Qt-Script' + }, + quake: { + color: '#882233', + url: 'https://github.com/trending?l=Quake' + }, + r: { + color: '#198CE7', + url: 'https://github.com/trending?l=R' + }, + racket: { + color: '#3c5caa', + url: 'https://github.com/trending?l=Racket' + }, + ragel: { + color: '#9d5200', + url: 'https://github.com/trending?l=Ragel' + }, + raku: { + color: '#0000fb', + url: 'https://github.com/trending?l=Raku' + }, + raml: { + color: '#77d9fb', + url: 'https://github.com/trending?l=RAML' + }, + rascal: { + color: '#fffaa0', + url: 'https://github.com/trending?l=Rascal' + }, + rdoc: { + color: '#701516', + url: 'https://github.com/trending?l=RDoc' + }, + reason: { + color: '#ff5847', + url: 'https://github.com/trending?l=Reason' + }, + reasonligo: { + color: '#ff5847', + url: 'https://github.com/trending?l=ReasonLIGO' + }, + rebol: { + color: '#358a5b', + url: 'https://github.com/trending?l=Rebol' + }, + 'record jar': { + color: '#0673ba', + url: 'https://github.com/trending?l=Record-Jar' + }, + red: { + color: '#f50000', + url: 'https://github.com/trending?l=Red' + }, + 'regular expression': { + color: '#009a00', + url: 'https://github.com/trending?l=Regular-Expression' + }, + "ren'py": { + color: '#ff7f7f', + url: "https://github.com/trending?l=Ren'Py" + }, + rescript: { + color: '#ed5051', + url: 'https://github.com/trending?l=ReScript' + }, + restructuredtext: { + color: '#141414', + url: 'https://github.com/trending?l=reStructuredText' + }, + rexx: { + color: '#d90e09', + url: 'https://github.com/trending?l=REXX' + }, + ring: { + color: '#2D54CB', + url: 'https://github.com/trending?l=Ring' + }, + riot: { + color: '#A71E49', + url: 'https://github.com/trending?l=Riot' + }, + rmarkdown: { + color: '#198ce7', + url: 'https://github.com/trending?l=RMarkdown' + }, + robotframework: { + color: '#00c0b5', + url: 'https://github.com/trending?l=RobotFramework' + }, + roff: { + color: '#ecdebe', + url: 'https://github.com/trending?l=Roff' + }, + 'roff manpage': { + color: '#ecdebe', + url: 'https://github.com/trending?l=Roff-Manpage' + }, + rouge: { + color: '#cc0088', + url: 'https://github.com/trending?l=Rouge' + }, + 'routeros script': { + color: '#DE3941', + url: 'https://github.com/trending?l=RouterOS-Script' + }, + rpgle: { + color: '#2BDE21', + url: 'https://github.com/trending?l=RPGLE' + }, + ruby: { + color: '#701516', + url: 'https://github.com/trending?l=Ruby' + }, + runoff: { + color: '#665a4e', + url: 'https://github.com/trending?l=RUNOFF' + }, + rust: { + color: '#dea584', + url: 'https://github.com/trending?l=Rust' + }, + saltstack: { + color: '#646464', + url: 'https://github.com/trending?l=SaltStack' + }, + sas: { + color: '#B34936', + url: 'https://github.com/trending?l=SAS' + }, + sass: { + color: '#a53b70', + url: 'https://github.com/trending?l=Sass' + }, + scala: { + color: '#c22d40', + url: 'https://github.com/trending?l=Scala' + }, + scaml: { + color: '#bd181a', + url: 'https://github.com/trending?l=Scaml' + }, + scheme: { + color: '#1e4aec', + url: 'https://github.com/trending?l=Scheme' + }, + scilab: { + color: '#ca0f21', + url: 'https://github.com/trending?l=Scilab' + }, + scss: { + color: '#c6538c', + url: 'https://github.com/trending?l=SCSS' + }, + sed: { + color: '#64b970', + url: 'https://github.com/trending?l=sed' + }, + self: { + color: '#0579aa', + url: 'https://github.com/trending?l=Self' + }, + shaderlab: { + color: '#222c37', + url: 'https://github.com/trending?l=ShaderLab' + }, + shell: { + color: '#89e051', + url: 'https://github.com/trending?l=Shell' + }, + 'shellcheck config': { + color: '#cecfcb', + url: 'https://github.com/trending?l=ShellCheck-Config' + }, + shen: { + color: '#120F14', + url: 'https://github.com/trending?l=Shen' + }, + singularity: { + color: '#64E6AD', + url: 'https://github.com/trending?l=Singularity' + }, + slash: { + color: '#007eff', + url: 'https://github.com/trending?l=Slash' + }, + slice: { + color: '#003fa2', + url: 'https://github.com/trending?l=Slice' + }, + slim: { + color: '#2b2b2b', + url: 'https://github.com/trending?l=Slim' + }, + smalltalk: { + color: '#596706', + url: 'https://github.com/trending?l=Smalltalk' + }, + smarty: { + color: '#f0c040', + url: 'https://github.com/trending?l=Smarty' + }, + smpl: { + color: '#c94949', + url: 'https://github.com/trending?l=SmPL' + }, + solidity: { + color: '#AA6746', + url: 'https://github.com/trending?l=Solidity' + }, + sourcepawn: { + color: '#f69e1d', + url: 'https://github.com/trending?l=SourcePawn' + }, + sparql: { + color: '#0C4597', + url: 'https://github.com/trending?l=SPARQL' + }, + sqf: { + color: '#3F3F3F', + url: 'https://github.com/trending?l=SQF' + }, + sql: { + color: '#e38c00', + url: 'https://github.com/trending?l=SQL' + }, + sqlpl: { + color: '#e38c00', + url: 'https://github.com/trending?l=SQLPL' + }, + squirrel: { + color: '#800000', + url: 'https://github.com/trending?l=Squirrel' + }, + 'srecode template': { + color: '#348a34', + url: 'https://github.com/trending?l=SRecode-Template' + }, + stan: { + color: '#b2011d', + url: 'https://github.com/trending?l=Stan' + }, + 'standard ml': { + color: '#dc566d', + url: 'https://github.com/trending?l=Standard-ML' + }, + starlark: { + color: '#76d275', + url: 'https://github.com/trending?l=Starlark' + }, + stata: { + color: '#1a5f91', + url: 'https://github.com/trending?l=Stata' + }, + stl: { + color: '#373b5e', + url: 'https://github.com/trending?l=STL' + }, + stringtemplate: { + color: '#3fb34f', + url: 'https://github.com/trending?l=StringTemplate' + }, + stylus: { + color: '#ff6347', + url: 'https://github.com/trending?l=Stylus' + }, + 'subrip text': { + color: '#9e0101', + url: 'https://github.com/trending?l=SubRip-Text' + }, + sugarss: { + color: '#2fcc9f', + url: 'https://github.com/trending?l=SugarSS' + }, + supercollider: { + color: '#46390b', + url: 'https://github.com/trending?l=SuperCollider' + }, + svelte: { + color: '#ff3e00', + url: 'https://github.com/trending?l=Svelte' + }, + svg: { + color: '#ff9900', + url: 'https://github.com/trending?l=SVG' + }, + swift: { + color: '#F05138', + url: 'https://github.com/trending?l=Swift' + }, + systemverilog: { + color: '#DAE1C2', + url: 'https://github.com/trending?l=SystemVerilog' + }, + talon: { + color: '#333333', + url: 'https://github.com/trending?l=Talon' + }, + tcl: { + color: '#e4cc98', + url: 'https://github.com/trending?l=Tcl' + }, + terra: { + color: '#00004c', + url: 'https://github.com/trending?l=Terra' + }, + tex: { + color: '#3D6117', + url: 'https://github.com/trending?l=TeX' + }, + textile: { + color: '#ffe7ac', + url: 'https://github.com/trending?l=Textile' + }, + 'textmate properties': { + color: '#df66e4', + url: 'https://github.com/trending?l=TextMate-Properties' + }, + thrift: { + color: '#D12127', + url: 'https://github.com/trending?l=Thrift' + }, + 'ti program': { + color: '#A0AA87', + url: 'https://github.com/trending?l=TI-Program' + }, + tla: { + color: '#4b0079', + url: 'https://github.com/trending?l=TLA' + }, + toml: { + color: '#9c4221', + url: 'https://github.com/trending?l=TOML' + }, + tsql: { + color: '#e38c00', + url: 'https://github.com/trending?l=TSQL' + }, + tsv: { + color: '#237346', + url: 'https://github.com/trending?l=TSV' + }, + tsx: { + color: '#3178c6', + url: 'https://github.com/trending?l=TSX' + }, + turing: { + color: '#cf142b', + url: 'https://github.com/trending?l=Turing' + }, + twig: { + color: '#c1d026', + url: 'https://github.com/trending?l=Twig' + }, + txl: { + color: '#0178b8', + url: 'https://github.com/trending?l=TXL' + }, + typescript: { + color: '#3178c6', + url: 'https://github.com/trending?l=TypeScript' + }, + 'unified parallel c': { + color: '#4e3617', + url: 'https://github.com/trending?l=Unified-Parallel-C' + }, + 'unity3d asset': { + color: '#222c37', + url: 'https://github.com/trending?l=Unity3D-Asset' + }, + uno: { + color: '#9933cc', + url: 'https://github.com/trending?l=Uno' + }, + unrealscript: { + color: '#a54c4d', + url: 'https://github.com/trending?l=UnrealScript' + }, + urweb: { + color: '#ccccee', + url: 'https://github.com/trending?l=UrWeb' + }, + v: { + color: '#4f87c4', + url: 'https://github.com/trending?l=V' + }, + vala: { + color: '#a56de2', + url: 'https://github.com/trending?l=Vala' + }, + 'valve data format': { + color: '#f26025', + url: 'https://github.com/trending?l=Valve-Data-Format' + }, + vba: { + color: '#867db1', + url: 'https://github.com/trending?l=VBA' + }, + vbscript: { + color: '#15dcdc', + url: 'https://github.com/trending?l=VBScript' + }, + vcl: { + color: '#148AA8', + url: 'https://github.com/trending?l=VCL' + }, + 'velocity template language': { + color: '#507cff', + url: 'https://github.com/trending?l=Velocity-Template-Language' + }, + verilog: { + color: '#b2b7f8', + url: 'https://github.com/trending?l=Verilog' + }, + vhdl: { + color: '#adb2cb', + url: 'https://github.com/trending?l=VHDL' + }, + 'vim help file': { + color: '#199f4b', + url: 'https://github.com/trending?l=Vim-Help-File' + }, + 'vim script': { + color: '#199f4b', + url: 'https://github.com/trending?l=Vim-Script' + }, + 'vim snippet': { + color: '#199f4b', + url: 'https://github.com/trending?l=Vim-Snippet' + }, + 'visual basic .net': { + color: '#945db7', + url: 'https://github.com/trending?l=Visual-Basic-.NET' + }, + volt: { + color: '#1F1F1F', + url: 'https://github.com/trending?l=Volt' + }, + vue: { + color: '#41b883', + url: 'https://github.com/trending?l=Vue' + }, + vyper: { + color: '#2980b9', + url: 'https://github.com/trending?l=Vyper' + }, + wdl: { + color: '#42f1f4', + url: 'https://github.com/trending?l=wdl' + }, + 'web ontology language': { + color: '#5b70bd', + url: 'https://github.com/trending?l=Web-Ontology-Language' + }, + webassembly: { + color: '#04133b', + url: 'https://github.com/trending?l=WebAssembly' + }, + whiley: { + color: '#d5c397', + url: 'https://github.com/trending?l=Whiley' + }, + wikitext: { + color: '#fc5757', + url: 'https://github.com/trending?l=Wikitext' + }, + 'windows registry entries': { + color: '#52d5ff', + url: 'https://github.com/trending?l=Windows-Registry-Entries' + }, + wisp: { + color: '#7582D1', + url: 'https://github.com/trending?l=wisp' + }, + 'witcher script': { + color: '#ff0000', + url: 'https://github.com/trending?l=Witcher-Script' + }, + wollok: { + color: '#a23738', + url: 'https://github.com/trending?l=Wollok' + }, + 'world of warcraft addon data': { + color: '#f7e43f', + url: 'https://github.com/trending?l=World-of-Warcraft-Addon-Data' + }, + wren: { + color: '#383838', + url: 'https://github.com/trending?l=Wren' + }, + x10: { + color: '#4B6BEF', + url: 'https://github.com/trending?l=X10' + }, + xbase: { + color: '#403a40', + url: 'https://github.com/trending?l=xBase' + }, + xc: { + color: '#99DA07', + url: 'https://github.com/trending?l=XC' + }, + xml: { + color: '#0060ac', + url: 'https://github.com/trending?l=XML' + }, + 'xml property list': { + color: '#0060ac', + url: 'https://github.com/trending?l=XML-Property-List' + }, + xojo: { + color: '#81bd41', + url: 'https://github.com/trending?l=Xojo' + }, + xonsh: { + color: '#285EEF', + url: 'https://github.com/trending?l=Xonsh' + }, + xquery: { + color: '#5232e7', + url: 'https://github.com/trending?l=XQuery' + }, + xslt: { + color: '#EB8CEB', + url: 'https://github.com/trending?l=XSLT' + }, + xtend: { + color: '#24255d', + url: 'https://github.com/trending?l=Xtend' + }, + yacc: { + color: '#4B6C4B', + url: 'https://github.com/trending?l=Yacc' + }, + yaml: { + color: '#cb171e', + url: 'https://github.com/trending?l=YAML' + }, + yara: { + color: '#220000', + url: 'https://github.com/trending?l=YARA' + }, + yasnippet: { + color: '#32AB90', + url: 'https://github.com/trending?l=YASnippet' + }, + yul: { + color: '#794932', + url: 'https://github.com/trending?l=Yul' + }, + zap: { + color: '#0d665e', + url: 'https://github.com/trending?l=ZAP' + }, + zenscript: { + color: '#00BCD1', + url: 'https://github.com/trending?l=ZenScript' + }, + zephir: { + color: '#118f9e', + url: 'https://github.com/trending?l=Zephir' + }, + zig: { + color: '#ec915c', + url: 'https://github.com/trending?l=Zig' + }, + zil: { + color: '#dc75e5', + url: 'https://github.com/trending?l=ZIL' + }, + zimpl: { + color: '#d67711', + url: 'https://github.com/trending?l=Zimpl' + } +}; diff --git a/web-server/src/constants/overlays.ts b/web-server/src/constants/overlays.ts index 6c6aeb76c..164844a1f 100644 --- a/web-server/src/constants/overlays.ts +++ b/web-server/src/constants/overlays.ts @@ -7,13 +7,18 @@ export const overlaysImportMap = { })) ), team_prs: lazy(() => - import('@/components/OverlayComponents/Dummy').then((c) => ({ - default: c.Dummy + import('@/content/PullRequests/TeamInsightsBody').then((c) => ({ + default: c.TeamInsightsBodyRouterless })) ), team_edit: lazy(() => import('@/components/OverlayComponents/TeamEdit').then((c) => ({ default: c.TeamEdit })) + ), + deployment_freq: lazy(() => + import('@/content/PullRequests/DeploymentInsightsOverlay').then((c) => ({ + default: c.DeploymentInsightsOverlay + })) ) }; diff --git a/web-server/src/content/DoraMetrics/DoraCards/ChangeTimeCard.tsx b/web-server/src/content/DoraMetrics/DoraCards/ChangeTimeCard.tsx index 919b7b270..24bca3a20 100644 --- a/web-server/src/content/DoraMetrics/DoraCards/ChangeTimeCard.tsx +++ b/web-server/src/content/DoraMetrics/DoraCards/ChangeTimeCard.tsx @@ -33,10 +33,6 @@ import { merge } from '@/utils/datatype'; import { getDurationString, getSortedDatesAsArrayFromMap } from '@/utils/date'; import { getDoraLink } from '../../PullRequests/DeploymentFrequencyGraph'; -import { - CorelationInsightCardFooter, - UnavailableCorrelation -} from '../CorelationInsightCardFooter'; import { DoraMetricsComparisonPill } from '../DoraMetricsComparisonPill'; import { MetricExternalRead } from '../MetricExternalRead'; import { MissingDORAProviderLink } from '../MissingDORAProviderLink'; @@ -92,17 +88,6 @@ export const ChangeTimeCard = () => { const showClassificationBadge = isSufficientDataAvailable && isCodeProviderIntegrationEnabled; - // TODO: Implement this using feature.ts - const isCorrelationInsightsEnabled = true; - - const computedFooter = !isCodeProviderIntegrationEnabled ? ( - - ) : !isSufficientDataAvailable ? ( - - ) : ( - - ); - const mergedLeadTimeTrends = merge( currentLeadTimeTrendsData, prevLeadTimeTrendsData @@ -269,18 +254,7 @@ export const ChangeTimeCard = () => { )} - + {isSufficientDataAvailable ? ( { - - {isCorrelationInsightsEnabled && computedFooter} ); }; diff --git a/web-server/src/content/DoraMetrics/DoraCards/WeeklyDeliveryVolumeCard.tsx b/web-server/src/content/DoraMetrics/DoraCards/WeeklyDeliveryVolumeCard.tsx index 7836f7d4d..376c8836f 100644 --- a/web-server/src/content/DoraMetrics/DoraCards/WeeklyDeliveryVolumeCard.tsx +++ b/web-server/src/content/DoraMetrics/DoraCards/WeeklyDeliveryVolumeCard.tsx @@ -1,13 +1,12 @@ import { alpha, Chip } from '@mui/material'; -import { useRouter } from 'next/router'; import pluralize from 'pluralize'; import { useMemo } from 'react'; import { Chart2, ChartOptions } from '@/components/Chart2'; import { FlexBox } from '@/components/FlexBox'; +import { useOverlayPage } from '@/components/OverlayPageContext'; import { Line } from '@/components/Text'; import { track } from '@/constants/events'; -import { ROUTES } from '@/constants/routes'; import { CardRoot, NoDataImg @@ -53,10 +52,11 @@ const chartOptions = { } as ChartOptions; export const WeeklyDeliveryVolumeCard = () => { - const router = useRouter(); const { integrationSet } = useAuth(); const dateRangeLabel = useCurrentDateRangeLabel(); const deploymentFrequencyProps = useAvgWeeklyDeploymentFrequency(); + + const { addPage } = useOverlayPage(); const deploymentsConfigured = true; const isCodeProviderIntegrationEnabled = integrationSet.has( IntegrationGroup.CODE @@ -212,16 +212,17 @@ export const WeeklyDeliveryVolumeCard = () => { totalDeployments )} onClick={() => { - if (!deploymentsConfigured) { - return router.push(ROUTES.INTEGRATIONS.PATH); - } if (!deploymentFrequencyProps.count && !totalDeployments) return; - track('DORA_METRICS_SEE_DETAILS_CLICKED', { viewed: 'DF' }); - return console.error('OVERLAY PENDING'); + addPage({ + page: { + title: 'Deployments insights', + ui: 'deployment_freq' + } + }); }} color={deploymentFrequencyProps.color} > diff --git a/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx b/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx new file mode 100644 index 000000000..646258316 --- /dev/null +++ b/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx @@ -0,0 +1,832 @@ +import { + ArrowDownwardRounded, + TrendingDown, + TrendingUp +} from '@mui/icons-material'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import { + Box, + Card, + Divider, + Tab, + useTheme, + alpha, + Collapse +} from '@mui/material'; +import { format, startOfDay } from 'date-fns'; +import { secondsInMinute } from 'date-fns/constants'; +import pluralize from 'pluralize'; +import { + ascend, + descend, + groupBy, + head, + mapObjIndexed, + mean, + path, + prop, + sort +} from 'ramda'; +import { FC, useCallback, useEffect, useMemo } from 'react'; + +import { Chart2, ChartOptions } from '@/components/Chart2'; +import { FlexBox } from '@/components/FlexBox'; +import { MiniButton } from '@/components/MiniButton'; +import { MiniCircularLoader } from '@/components/MiniLoader'; +import { ProgressBar } from '@/components/ProgressBar'; +import { PullRequestsTableMini } from '@/components/PRTableMini/PullRequestsTableMini'; +import Scrollbar from '@/components/Scrollbar'; +import { Line } from '@/components/Text'; +import { FetchState } from '@/constants/ui-states'; +import { useBoolState, useEasyState } from '@/hooks/useEasyState'; +import { + useStateBranchConfig, + useSingleTeamConfig, + useCurrentDateRangeReactNode +} from '@/hooks/useStateTeamConfig'; +import { useTableSort } from '@/hooks/useTableSort'; +import { + doraMetricsSlice, + fetchDeploymentPRs, + fetchTeamDeployments +} from '@/slices/dora_metrics'; +import { useDispatch, useSelector } from '@/store'; +import { brandColors } from '@/theme/schemes/theme'; +import { + Deployment, + PR, + RepoWorkflowExtended, + DeploymentSources +} from '@/types/resources'; +import { percent } from '@/utils/datatype'; +import { getDurationString } from '@/utils/date'; +import { depFn } from '@/utils/fn'; +import { trend } from '@/utils/trend'; + +import { DeploymentItem } from './DeploymentItem'; + +enum InsightView { + PrView = 'pr', + AnalyticsView = 'analytics' +} + +enum DepStatusFilter { + All, + Pass, + Fail +} + +const hideTableColumns = new Set(['reviewers', 'rework_cycles']); + +export const DeploymentInsightsOverlay = () => { + const { singleTeamId, team, dates } = useSingleTeamConfig(); + const branches = useStateBranchConfig(); + const dispatch = useDispatch(); + const depFilter = useEasyState(DepStatusFilter.All); + + useEffect(() => { + if (!singleTeamId) return; + + dispatch( + fetchTeamDeployments({ + team_id: singleTeamId, + from_date: dates.start, + to_date: dates.end + }) + ); + }, [branches, dates.end, dates.start, dispatch, singleTeamId]); + + const teamDeployments = useSelector((s) => s.doraMetrics.team_deployments); + const workflowsMap = useSelector( + (s) => s.doraMetrics.team_deployments.workflows_map + ); + + const deploymentsListByRepo = useMemo(() => { + return sort( + descend((r) => r.deps.length), + Object.entries(teamDeployments.deployments_map).map( + ([repo_id, deps]) => ({ + repo: teamDeployments.repos_map[repo_id], + deps + }) + ) + ); + }, [teamDeployments.deployments_map, teamDeployments.repos_map]); + + const loadingRepos = useSelector( + (s) => s.doraMetrics.requests?.team_deployments === FetchState.REQUEST + ); + + const selectedRepo = useEasyState(null); + + useEffect(() => { + if (selectedRepo.value || !deploymentsListByRepo.length) return; + + depFn(selectedRepo.set, deploymentsListByRepo[0].repo.id as ID); + }, [deploymentsListByRepo, selectedRepo.set, selectedRepo.value]); + + const deployments = useMemo(() => { + if (!selectedRepo.value) return []; + return teamDeployments.deployments_map?.[selectedRepo.value] || []; + }, [selectedRepo.value, teamDeployments.deployments_map]); + + const selectedDepID = useEasyState(null); + const selectedDep = useMemo(() => { + return deployments.find((dep) => dep.id === selectedDepID.value); + }, [deployments, selectedDepID.value]); + + const statePrs = useSelector((s) => { + return []; + }); + const loadingPrs = useSelector( + (s) => s.doraMetrics.requests?.summary_prs === FetchState.REQUEST + ); + + const theme = useTheme(); + + useEffect(() => { + if (process.env.NEXT_PUBLIC_APP_ENVIRONMENT === 'development') return; + dispatch(doraMetricsSlice.actions.resetDeployments()); + }, [dispatch]); + + const { + sortedList: prs, + updateSortConf, + conf + } = useTableSort(statePrs, { field: 'cycle_time', order: 'desc' }); + + const selectedTab = useEasyState(InsightView.AnalyticsView); + + const { longestDeployment, shortestDeployment } = useMemo(() => { + const run_durations = deployments + .filter((d) => d?.status === 'SUCCESS') + .map((d) => d.run_duration); + const min_duration = Math.min(...run_durations); + const max_duration = Math.max(...run_durations); + + const longestDeployment = deployments.find( + (dep) => dep.run_duration === max_duration + ); + const shortestDeployment = deployments.find( + (dep) => dep.run_duration === min_duration + ); + + return { longestDeployment, shortestDeployment }; + }, [deployments]); + + const selectDeployment = useCallback( + (dep: Deployment) => { + selectedDepID.set(dep.id); + dispatch(fetchDeploymentPRs({ deployment_id: dep.id })); + }, + [dispatch, selectedDepID] + ); + + const successfulDeps = useMemo( + () => deployments.filter((dep) => dep.status === 'SUCCESS'), + [deployments] + ); + + const failedDeps = useMemo( + () => deployments.filter((dep) => dep.status === 'FAILURE'), + [deployments] + ); + + const filteredDeployments = useMemo(() => { + if (depFilter.value === DepStatusFilter.Fail) return failedDeps; + if (depFilter.value === DepStatusFilter.Pass) return successfulDeps; + return deployments; + }, [depFilter.value, deployments, failedDeps, successfulDeps]); + + type GroupedDeployments = [RepoWorkflowExtended, Deployment[]]; + const groupedDeployments: GroupedDeployments[] = useMemo(() => { + const deploymentsGroupedByWorkflowIds = groupBy( + prop('repo_workflow_id'), + filteredDeployments + ); + + const groupedDeploymentsEntries = Object.entries( + deploymentsGroupedByWorkflowIds + ); + + const workflowDeploymentTuples = groupedDeploymentsEntries.map( + ([workflowId, deployments]) => + [workflowsMap[workflowId] || {}, deployments] as GroupedDeployments + ); + + return sort( + ascend(path([0, 'name'])), + workflowDeploymentTuples + ) as GroupedDeployments[]; + }, [filteredDeployments, workflowsMap]); + + const [depDates, depRuntimes, depPrs] = useMemo(() => { + const grouped = groupBy( + (a) => String(startOfDay(new Date(a.conducted_at)).getTime()), + successfulDeps + ); + + const averaged = mapObjIndexed( + (deps) => ({ + run: mean(deps.map((dep) => dep.run_duration)), + prs: Math.round(mean(deps.map((dep) => dep.pr_count))) + }), + grouped + ); + + const entries: [Date, number, number][] = Object.entries(averaged) + .reverse() + .map(([ts_string, params]) => [ + new Date(Number(ts_string)), + params.run, + Math.max(params.prs, 0) + ]); + + return [ + entries.map((e) => format(e[0], 'do MMM')), + entries.map((e) => e[1]), + entries.map((e) => e[2]) + ]; + }, [successfulDeps]); + + const trends = useMemo(() => { + return { + run: trend(depRuntimes).change / 100, + prs: trend(depPrs).change / 100 + }; + }, [depPrs, depRuntimes]); + + const dateRangeLabel = useCurrentDateRangeReactNode(); + + const showDeploymentTrends = useMemo(() => { + return head(deployments)?.id.includes(DeploymentSources.WORKFLOW); + }, [deployments]); + + if (!team) return Please select a team first...; + + return ( + + + + Select a repo for team: {team.name} + + + Deployments and stats will be shown between {dateRangeLabel} + + + + {loadingRepos ? ( + + ) : ( + deploymentsListByRepo.map(({ repo, deps }) => ( + { + selectedRepo.set(repo.id as ID); + }} + > + {repo.name}{' '} + {Boolean(deps.length) && ( + + + {deps.length} + + + )} + + )) + )} + + + {selectedRepo.value && !loadingRepos ? ( + !deployments?.length ? ( + + There are no deployments for this repo from {dateRangeLabel} + + ) : ( + + + + + selectedTab.set(v)} color="info"> + + Deployment Analytics + + } + value={InsightView.AnalyticsView} + color="info" + /> + + Deployment Events + + } + value={InsightView.PrView} + /> + + + {Boolean(longestDeployment && shortestDeployment) && ( + + + + + + No. of deployments {'->'} + {' '} + { + selectedTab.set(InsightView.PrView); + depFilter.set(DepStatusFilter.All); + }} + pointer + > + {deployments.length} + {' '} + {Boolean(failedDeps.length) ? ( + { + selectedTab.set(InsightView.PrView); + depFilter.set(DepStatusFilter.Fail); + }} + pointer + > + ({failedDeps.length} failed) + + ) : ( + { + selectedTab.set(InsightView.PrView); + depFilter.set(DepStatusFilter.Pass); + }} + pointer + > + (All passed) + + )} + + { + selectedTab.set(InsightView.PrView); + depFilter.set(DepStatusFilter.Pass); + }} + remainingOnClick={() => { + selectedTab.set(InsightView.PrView); + depFilter.set(DepStatusFilter.Fail); + }} + /> + + {longestDeployment.id !== shortestDeployment.id && ( + + + + Longest Deployment + + { + selectDeployment(longestDeployment); + selectedTab.set(InsightView.PrView); + }} + /> + + + + Shortest Deployment + + { + selectDeployment(shortestDeployment); + selectedTab.set(InsightView.PrView); + }} + /> + + + )} + {showDeploymentTrends ? ( + + + Deployment trends + + div': { + display: 'flex', + alignItems: 'center', + gap: 1 / 2, + px: 1, + py: 1 / 2, + borderRadius: 1 / 2, + border: `1px solid #FFF5` + } + }} + > + {Boolean( + !trends?.run || Math.abs(trends?.run) < 0.02 + ) ? null : trends?.run > 0 ? ( + + + Increasing deployment duration + + + + {Math.round(trends.run * 100)}% + + + + + ) : ( + + + Decreasing deployment duration + + + + {Math.round(trends.run * 100)}% + + + + + )} + {Boolean( + !trends?.prs || + !Number.isFinite(trends?.prs) || + Math.abs(trends?.prs) < 0.05 + ) ? null : trends?.prs > 0 ? ( + + + Increasing PR count per deployment + + + + {Math.round(trends.prs * 100)}% + + + + + ) : ( + + + Decreasing PR count per deployment + + + + {Math.round(trends.prs * 100)}% + + + + + )} + + + 1 + ? { + lineStyle: 'dotted', + width: 2 + } + : undefined + }, + { + data: depPrs, + label: 'PR Count per deployment', + yAxisID: 'prcount', + backgroundColor: alpha( + brandColors.branch.all, + 0.5 + ), + barThickness: 5, + trendlineLinear: + depPrs.length > 1 + ? { + lineStyle: 'dotted', + width: 2, + colorMin: brandColors.branch.all, + colorMax: brandColors.branch.all + } + : undefined + } + ]} + labels={depDates} + options={deploymentRuntimeChartOptions} + /> + + + ) : ( + + + Deployment trends are only available for repos with + workflows as source. + + + )} + + + )} + + + + + depFilter.set(DepStatusFilter.All)} + variant={ + depFilter.value === DepStatusFilter.All + ? 'contained' + : 'outlined' + } + > + All + + depFilter.set(DepStatusFilter.Pass)} + variant={ + depFilter.value === DepStatusFilter.Pass + ? 'contained' + : 'outlined' + } + > + Successful + + depFilter.set(DepStatusFilter.Fail)} + variant={ + depFilter.value === DepStatusFilter.Fail + ? 'contained' + : 'outlined' + } + > + Failed + + + + + + + {filteredDeployments.length ? ( + groupedDeployments.map( + ([workflow, deployments]) => ( + + ) + ) + ) : ( + + + No deployments matching the current filter. + + + depFilter.set(DepStatusFilter.All) + } + pointer + > + See all deployments? + + + )} + + + + + + {selectedDep ? ( + + + Selected Deployment + + + + + {loadingPrs ? ( + + ) : prs.length ? ( + + ) : ( + + No new PRs linked to this deployment. + + )} + + + ) : ( + + + Select a deployment on the left + + + to view PRs included in that deployment + + + )} + + + + + + ) + ) : ( + Select a repo to begin... + )} + + ); +}; + +const CollapsibleWorkflowList: FC<{ + workflow: RepoWorkflowExtended; + deployments: Deployment[]; + selectedDeploymentId?: ID; + onSelect?: (dep: Deployment) => any; +}> = ({ workflow, deployments, onSelect, selectedDeploymentId }) => { + const isExpanded = useBoolState(true); + + return ( + + + +   + + Workflow: {workflow.name} + {' '} + ({deployments.length || 'None'}) + + + + {deployments.map((dep) => ( + + ))} + + + + ); +}; + +const deploymentRuntimeChartOptions: ChartOptions = { + options: { + scales: { + y: { + ticks: { + stepSize: secondsInMinute * 2, + callback: (value) => getDurationString(Number(value)) + } + }, + prcount: { + type: 'linear', + position: 'right', + ticks: { + stepSize: 1, + color: brandColors.branch.all + }, + beginAtZero: true, + grace: '10%', + grid: { + color: alpha(brandColors.branch.all, 0.2), + borderColor: alpha(brandColors.branch.all, 0.2), + tickColor: brandColors.branch.all, + borderDash: [5, 2] + } + } + }, + plugins: { + zoom: { + pan: { + enabled: false + }, + zoom: { + drag: { + enabled: false + } + } + }, + legend: { + display: true, + labels: { + color: 'white' + } + }, + tooltip: { + callbacks: { + label(tooltipItem) { + if (tooltipItem.dataset.yAxisID === 'prcount') + return `${tooltipItem.formattedValue} PRs`; + + return getDurationString(Number(tooltipItem.formattedValue)); + } + } + } + } + } +}; diff --git a/web-server/src/content/PullRequests/DeploymentItem.tsx b/web-server/src/content/PullRequests/DeploymentItem.tsx new file mode 100644 index 000000000..0144fbe10 --- /dev/null +++ b/web-server/src/content/PullRequests/DeploymentItem.tsx @@ -0,0 +1,136 @@ +import { + AccessTimeRounded, + ArrowForwardRounded, + CheckCircleRounded, + CloseRounded, + CodeRounded +} from '@mui/icons-material'; +import { Card, useTheme } from '@mui/material'; +import { format } from 'date-fns'; +import Link from 'next/link'; +import { FC } from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { Deployment } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; +import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; + +export const DeploymentItem: FC<{ + dep: Deployment; + selected?: boolean; + onSelect?: (dep: Deployment) => any; +}> = ({ dep, selected, onSelect }) => { + const theme = useTheme(); + const isWorkflowDeployment = dep.id.startsWith('WORKFLOW'); + + return ( + onSelect?.(dep)} + > + + + + {dep.status === 'SUCCESS' ? ( + + ) : ( + + )} + + Run on {format(new Date(dep.conducted_at), 'do, MMM - hh:mmaaa')} + + {dep.html_url && ( + + + + + + )} + + + = 0 + ? `This deployment included ${dep.pr_count || 'no'} new ${ + dep.pr_count === 1 ? 'PR' : 'PRs' + }` + : 'This deployment may contain PRs merged outside the selected date range' + } + tooltipPlacement="left" + > + {isWorkflowDeployment && ( + <> + + + {dep.pr_count >= 0 ? dep.pr_count || 'No' : '--'} + {' new '} + {dep.pr_count >= 0 && (dep.pr_count === 1 ? 'PR' : 'PRs')} + + + )} + + {dep.head_branch} + + + {isWorkflowDeployment && ( + + + + {getDurationString(dep.run_duration)} + + + )} + + + {onSelect && ( + + )} + + + ); +}; diff --git a/web-server/src/content/PullRequests/LeadTimeStatsCore.tsx b/web-server/src/content/PullRequests/LeadTimeStatsCore.tsx new file mode 100644 index 000000000..831d40082 --- /dev/null +++ b/web-server/src/content/PullRequests/LeadTimeStatsCore.tsx @@ -0,0 +1,300 @@ +import { InfoOutlined, ArrowForwardRounded } from '@mui/icons-material'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; +import { + Box, + BoxProps, + darken, + SxProps, + Button, + List, + ListItem, + useTheme +} from '@mui/material'; +import { secondsInDay } from 'date-fns/constants'; +import Link from 'next/link'; +import pluralize from 'pluralize'; +import { FC, useCallback } from 'react'; + +import { FlexBox } from '@/components/FlexBox'; +import { useOverlayPage } from '@/components/OverlayPageContext'; +import { DarkTooltip } from '@/components/Shared'; +import { Line } from '@/components/Text'; +import { track } from '@/constants/events'; +import { ROUTES } from '@/constants/routes'; +import { isRoleLessThanEM } from '@/constants/useRoute'; +import { usePrChangeTimePipeline } from '@/content/PullRequests/useChangeTimePipeline'; +import { useAuth } from '@/hooks/useAuth'; +import { useSelector } from '@/store'; +import { ChangeTimeSegment } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; + +const commonSegmentProps: BoxProps = { + ml: -1, + py: 1 / 2, + px: 3, + flexShrink: 0 +}; + +export const LeadTimeStatsCore: FC< + { + cycle?: number; + changeTimeSegments: ChangeTimeSegment[]; + showTotal?: boolean; + } & BoxProps +> = ({ cycle = 0, changeTimeSegments, showTotal, ...props }) => { + const theme = useTheme(); + const { role } = useAuth(); + const isEng = isRoleLessThanEM(role); + const { addPage } = useOverlayPage(); + const [initiation, response, rework, merge, deployment] = changeTimeSegments; + + const allAssignedRepos = useSelector( + (s) => s.doraMetrics.allReposAssignedToTeam + ); + const allReposDeploymentsAreConfigured = allAssignedRepos; + + const { reposWithNoDeploymentsConfigured, reposCountWithWorkflowConfigured } = + usePrChangeTimePipeline(); + + const calcCycleTime = + cycle || response.duration + rework.duration + merge.duration; + const calcLeadTime = + calcCycleTime + initiation.duration + deployment.duration; + const calcTimeToDisplay = calcLeadTime; + const timeDisplayed = + getDurationString(calcTimeToDisplay, { segments: 2 }) || 'Unavailable'; + + const defaultFlex = 1; + + const triggerPrPageOverlay = useCallback(() => { + addPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs' + } + }); + }, [addPage]); + + const triggerDeploymentFreqPageOverlay = useCallback(() => { + addPage({ + page: { + title: 'Process overview -> Deployments insights', + ui: 'deployment_freq' + } + }); + }, [addPage]); + + return ( + + track('HOVER_ON_CHANGE_TIME_QUICK_STATS_CHART')} + > + + {initiation.title} + + {getDurationString(initiation.duration, { + segments: 1 + }) || '-'}{' '} + + + + + + + + {response.title} + + {getDurationString(response.duration) || '-'}{' '} + + + + + + + {rework.title} + + {getDurationString(rework.duration, { + segments: 1 + }) || '-'}{' '} + + + + + + + {merge.title} + + {getDurationString(merge.duration, { + segments: 1 + }) || '-'}{' '} + + + + + + + + {deployment.title} + {reposCountWithWorkflowConfigured !== allAssignedRepos.length && ( + + + Lead time insight based on data from{' '} + {reposCountWithWorkflowConfigured} out of{' '} + {allAssignedRepos.length}{' '} + {pluralize('repo', reposCountWithWorkflowConfigured)}{' '} + which have workflow configured. + + + Following{' '} + {pluralize( + 'repo', + reposWithNoDeploymentsConfigured.length + )}{' '} + don't have any workflow assigned : + + {reposWithNoDeploymentsConfigured.map((r) => ( + + {r.name} + + ))} + + + {!isEng && ( + + + + )} + + } + darkTip + > + + + )} + + + + {getDurationString(deployment.duration, { + segments: secondsInDay < deployment.duration ? 2 : 1 + }) || '-'}{' '} + + + + + + + + {showTotal && ( + + + Total: {timeDisplayed} + + + )} + + ); +}; + +const ChangeTypeStatBoxStyles: SxProps = { + fontWeight: 700, + minWidth: '100px', + height: '80px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + whiteSpace: 'nowrap', + flexDirection: 'column', + cursor: 'pointer' +}; diff --git a/web-server/src/content/PullRequests/LegendAndStats.tsx b/web-server/src/content/PullRequests/LegendAndStats.tsx new file mode 100644 index 000000000..3c83be706 --- /dev/null +++ b/web-server/src/content/PullRequests/LegendAndStats.tsx @@ -0,0 +1,129 @@ +import { + alpha, + Box, + Button, + darken, + Divider, + Typography, + useTheme +} from '@mui/material'; +import React from 'react'; +import { FC, Fragment, ReactNode } from 'react'; + +import { LegendItem } from '@/components/LegendItem'; + +export const LegendAndStats: FC<{ + legendOutside?: boolean; + onChartReset: () => any; + legends: { + color: string; + title: ReactNode; + }[]; + sections: { + title: ReactNode; + stats: { + label: ReactNode; + value: ReactNode; + }[]; + toggle: (sectionIndex: number) => any; + collapse: boolean; + }[]; +}> = ({ onChartReset, legends, sections, children, legendOutside }) => { + const theme = useTheme(); + const hasChildren = Boolean(React.Children.count(children)); + + return ( + + + Legend + {legends.map((legend, i) => ( + + ))} + {sections.map((section, i) => ( + + + + {section.title} + + + {!section.collapse && + section.stats.map((stat, i) => ( + + {stat.value} + {stat.label} + + } + size="small" + /> + ))} + + ))} + {hasChildren && } + {children} + + ); +}; diff --git a/web-server/src/content/PullRequests/ProcessChart.tsx b/web-server/src/content/PullRequests/ProcessChart.tsx new file mode 100644 index 000000000..bdabff859 --- /dev/null +++ b/web-server/src/content/PullRequests/ProcessChart.tsx @@ -0,0 +1,356 @@ +import { + ArrowForwardRounded, + CloseRounded, + LoupeRounded, + WarningAmberRounded +} from '@mui/icons-material'; +import { Box, useTheme, darken, IconButton } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { head, sum } from 'ramda'; +import { FC, ReactNode, useCallback, useMemo, useRef } from 'react'; + +import { getExtremePrsFromDistribution } from '@/api-helpers/pr'; +import { + Chart2, + ChartOnClick, + ChartOnZoom, + resetChartById +} from '@/components/Chart2'; +import { FlexBox, FlexBoxProps } from '@/components/FlexBox'; +import { InsightChip } from '@/components/InsightChip'; +import { useOverlayPage } from '@/components/OverlayPageContext'; +import { Line } from '@/components/Text'; +import { MAX_INT } from '@/constants/generic'; +import { LegendAndStats } from '@/content/PullRequests/LegendAndStats'; +import { useEasyState, useBoolState } from '@/hooks/useEasyState'; +import { useFeature } from '@/hooks/useFeature'; +import { useSelector } from '@/store'; +import { brandColors } from '@/theme/schemes/theme'; +import { percent } from '@/utils/datatype'; +import { depFn } from '@/utils/fn'; + +export type ProcessChartProps = FlexBoxProps & { + legendOutside?: boolean; + chartId?: string; +}; + +export const ProcessChart: FC = ({ + legendOutside, + chartId = 'process-chart', + ...props +}) => { + const theme = useTheme(); + const enablePrCycleTimeComparison = useFeature( + 'enable_pr_cycle_time_comparison' + ); + const cycleTimeBuckets = useSelector( + (state) => state.collab.cycle_time_distribution + ); + + const statRange = useEasyState<[number, number] | null>(null); + const series = useMemo( + () => [ + { + label: 'PR Count', + data: cycleTimeBuckets.map((b) => b.prCount), + backgroundColor: brandColors.pr.firstResponseTime, + borderColor: brandColors.pr.firstResponseTime + } + ], + [cycleTimeBuckets] + ); + + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const lastToastKey = useRef(''); + + const sliceStat = useCallback( + (stats: number[]) => { + if (!statRange.value) return stats; + const [min, max] = statRange.value; + return stats.slice(Math.max(min - 1, 0), Math.min(max, stats.length)); + }, + [statRange.value] + ); + const showStats = useBoolState(false); + + const { upsertPage } = useOverlayPage(); + const onZoom = useCallback( + (start, end) => { + depFn(statRange.set, [start, end]); + + const key = `process-chart-toast-range-${start}-${end}`; + closeSnackbar(lastToastKey.current); + lastToastKey.current = key; + + enqueueSnackbar(, { + key, + persist: true, + transitionDuration: { enter: 150, exit: 80 }, + preventDuplicate: true, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center' + }, + action: toastAction( + () => + upsertPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs', + props: { + min: cycleTimeBuckets[start].minTime, + max: cycleTimeBuckets[end]?.maxTime + } + } + }), + () => closeSnackbar(key) + ) + }); + }, + [ + statRange.set, + closeSnackbar, + enqueueSnackbar, + upsertPage, + cycleTimeBuckets + ] + ); + + const onClick = useCallback( + (_1, elements, _2) => { + const element = head(elements); + if (!element) return; + + const { index } = element; + const key = `process-chart-toast-${index}`; + + closeSnackbar(lastToastKey.current); + lastToastKey.current = key; + + enqueueSnackbar( + , + { + key, + persist: true, + transitionDuration: { enter: 150, exit: 80 }, + preventDuplicate: true, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center' + }, + action: toastAction( + () => + upsertPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs', + props: { + min: cycleTimeBuckets[index].minTime, + max: cycleTimeBuckets[index].maxTime + } + } + }), + () => closeSnackbar(key) + ) + } + ); + }, + [closeSnackbar, enqueueSnackbar, cycleTimeBuckets, upsertPage] + ); + + const { + longPrCount, + quickPrCount, + longLimitLabel, + quickLimitLabel, + longPrTime, + quickPrTime, + totalPrCount + } = getExtremePrsFromDistribution(cycleTimeBuckets); + + return ( + + + {Boolean(quickPrCount) && ( + + upsertPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs', + props: { min: 0, max: quickPrTime } + } + }) + } + > + + + {quickPrCount} {quickPrCount > 1 ? 'PRs' : 'PR'} + {' '} + ({percent(quickPrCount, totalPrCount)}%) were merged under{' '} + + {quickLimitLabel} + + + + )} + {Boolean(longPrCount) && ( + } + endIcon={} + onClick={() => + upsertPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs', + props: { min: longPrTime, max: MAX_INT } + } + }) + } + > + + + {longPrCount} {longPrCount > 1 ? 'PRs' : 'PR'} + {' '} + ({percent(longPrCount, totalPrCount)}%) took a long time to be + merged{' '} + + (over {longLimitLabel}) + + + + )} + + + + b.label)} + onZoom={onZoom} + onClick={onClick} + /> + + { + resetChartById(chartId); + statRange.reset(); + }} + > + + upsertPage({ + page: { + title: 'Process overview -> Pull request insights', + ui: 'team_prs', + props: statRange.value + ? { + min: cycleTimeBuckets[statRange.value[0]].minTime, + max: cycleTimeBuckets[statRange.value[1]]?.maxTime + } + : undefined + } + }) + } + > + + + View insights + {statRange.value && 'within range'} + + + + + + ); +}; + +const toastAction = (onClick: AnyFunction, onClose: AnyFunction) => () => { + return ( + <> + { + onClick(); + onClose(); + }} + > + + + + + + + ); +}; + +const ToastTitle: FC<{ label: ReactNode }> = ({ label }) => ( + + + View PR insights + + + For PRs open{' '} + + {label} + + + +); diff --git a/web-server/src/content/PullRequests/ProcessChartWithContainer.tsx b/web-server/src/content/PullRequests/ProcessChartWithContainer.tsx new file mode 100644 index 000000000..ef179c4c5 --- /dev/null +++ b/web-server/src/content/PullRequests/ProcessChartWithContainer.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { + ProcessChart, + ProcessChartProps +} from '@/content/PullRequests/ProcessChart'; + +export const ProcessChartWithContainer: FC< + ProcessChartProps & { hideTitle?: boolean } +> = ({ hideTitle, ...props }) => { + // const hasCycleTimeData = useCycleTimeDataCheck(); + + // if (!hasCycleTimeData) return null; + + return ( + <> + {!hideTitle && } + + + ); +}; + +export const ProcessChartTitle = ({ title }: { title?: string }) => ( + + + {title || 'Pull Request cycle time distribution'} + + Click or drag over the chart to see PR details + +); diff --git a/web-server/src/content/PullRequests/TeamInsightsBody.tsx b/web-server/src/content/PullRequests/TeamInsightsBody.tsx new file mode 100644 index 000000000..8fe9d2326 --- /dev/null +++ b/web-server/src/content/PullRequests/TeamInsightsBody.tsx @@ -0,0 +1,478 @@ +import { ExpandMoreRounded } from '@mui/icons-material'; +import { + Box, + useTheme, + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Button, + darken, + lighten +} from '@mui/material'; +import { Serie } from '@nivo/line'; +import { millisecondsInSecond, secondsInWeek } from 'date-fns/constants'; +import pluralize from 'pluralize'; +import { mean } from 'ramda'; +import { FC, useMemo, useEffect, useCallback } from 'react'; + +import { EmptyState } from '@/components/EmptyState'; +import { FlexBox } from '@/components/FlexBox'; +import { LegendsMenu } from '@/components/LegendsMenu'; +import { useOverlayPage } from '@/components/OverlayPageContext'; +import { PrTableWithPrExclusionMenu } from '@/components/PRTable/PrTableWithPrExclusionMenu'; +import Scrollbar from '@/components/Scrollbar'; +import { MiniSwitch } from '@/components/Shared'; +import { Line } from '@/components/Text'; +import { TrendsLineChart } from '@/components/TrendsLineChart'; +import { track } from '@/constants/events'; +import { ProcessChartWithContainer } from '@/content/PullRequests/ProcessChartWithContainer'; +import { ClipPathEnum } from '@/content/PullRequests/useChangeTimePipeline'; +import { useDoraMetricsGraph } from '@/hooks/useDoraMetricsGraph'; +import { useBoolState } from '@/hooks/useEasyState'; +import { usePageRefreshCallback } from '@/hooks/usePageRefreshCallback'; +import { useCurrentDateRangeLabel } from '@/hooks/useStateTeamConfig'; +// import { fetchCycleTimeDetails, fetchTeamInsights } from '@/slices/collab'; +import { useSelector } from '@/store'; +import { brandColors } from '@/theme/schemes/theme'; +import { PR } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; + +import { LeadTimeStatsCore } from './LeadTimeStatsCore'; + +// export const TeamInsightsBody: FC = () => { +// const router = useRouter(); + +// const cycleTimeArgs = useMemo(() => { +// const [min, max] = router.query?.params || []; + +// if (min || max) +// return { +// cycle_time: { min: Number(min), max: Number(max) } +// }; + +// return null; +// }, [router.query?.params]); +// const pageRefreshCallback = usePageRefreshCallback(); +// const { isLoading, refreshDataCallback } = usePageData( +// fetchTeamInsights, +// 'teamInsights', +// cycleTimeArgs +// ); +// const prUpdateCallback = useCallback(() => { +// refreshDataCallback(); +// pageRefreshCallback(); +// }, [pageRefreshCallback, refreshDataCallback]); +// const prs = useSelector((state) => state.collab.teamInsights.curr.data); + +// const isErrored = useSelector( +// (state) => state.collab.requests.teamInsights === FetchState.FAILURE +// ); + +// if (isLoading) return ; + +// if (isErrored) return ; + +// if (!prs.length) +// return ( +// <> +// +// <Typography variant="subtitle1" fontSize="large"> +// No pull requests were found in this date range +// </Typography> +// </> +// ); + +// return ( +// <> +// <Title cycleTime={cycleTimeArgs?.cycle_time} /> +// <Typography variant="h4" mt={2} fontSize="large"> +// List of PRs in this time range +// </Typography> +// <PrTableWithPrExclusionMenu +// propPrs={prs} +// onUpdateCallback={prUpdateCallback} +// /> +// </> +// ); +// }; + +export const TeamInsightsBodyRouterless: FC<{ + min?: number; + max?: number; + referrer?: 'dora_metrics'; +}> = ({ min, max, referrer }) => { + const theme = useTheme(); + const isLeadTimeActive = true; + + const cycleTimeArgs = useMemo(() => { + if (min || max) + return { + cycle_time: { min: Number(min), max: Number(max) } + }; + + return null; + }, [max, min]); + const pageRefreshCallback = usePageRefreshCallback(); + const prUpdateCallback = useCallback(() => { + pageRefreshCallback(); + }, [pageRefreshCallback]); + + const prs = useSelector((state) => state.doraMetrics.summary_prs); + const referredByDoraMetrics = referrer === 'dora_metrics'; + const showChart = useBoolState(referredByDoraMetrics); + const dateRangeLabel = useCurrentDateRangeLabel(); + const { upsertPage } = useOverlayPage(); + + const { trendsSeriesMap } = useDoraMetricsGraph(); + + const showBreakdownStatsInGraph = useBoolState(false); + const series: Serie[] = useMemo(() => { + if (!trendsSeriesMap) return []; + const commonBreakdownSegments = [ + trendsSeriesMap.firstResponseTimeTrends, + trendsSeriesMap.reworkTimeTrends, + trendsSeriesMap.mergeTimeTrends + ]; + const leadTimeBreakdownSegments = [ + trendsSeriesMap.firstCommitToPrTrends, + ...commonBreakdownSegments, + trendsSeriesMap.deployTimeTrends + ]; + if (showBreakdownStatsInGraph.value) return leadTimeBreakdownSegments; + return [trendsSeriesMap.totalLeadTimeTrends]; + }, [showBreakdownStatsInGraph.value, trendsSeriesMap]); + + // if (isLoading) return <MiniLoader label="Loading PRs..." />; + + // if (isErrored) return <SomethingWentWrong />; + + if (!prs.length) + return ( + <FlexBox col gap={2}> + <Title cycleTime={cycleTimeArgs?.cycle_time} /> + <EmptyState + desc={`No pull requests were found between ${dateRangeLabel}`} + type="CODE_NO_PRS" + > + <Button + size="small" + variant="outlined" + onClick={() => + upsertPage({ + page: { + ui: 'team_prs', + props: null, + title: 'Team PRs' + } + }) + } + > + See insights of all PRs + </Button> + </EmptyState> + </FlexBox> + ); + + return ( + <FlexBox col gap={2}> + <FlexBox mx={-2} width={`calc(100% + ${theme.spacing(4)})`}> + <Accordion + sx={{ + border: `1px solid ${theme.colors.secondary.light}`, + borderRadius: 1, + width: '100%' + }} + expanded={showChart.value} + onChange={showChart.toggle} + > + <AccordionSummary expandIcon={<ExpandMoreRounded />}> + <FlexBox fullWidth alignCenter justifyBetween> + <Line bigish medium> + Lead Time Trends + </Line> + </FlexBox> + </AccordionSummary> + <AccordionDetails> + {referredByDoraMetrics ? ( + <FlexBox col> + <FlexBox alignCenter gap1> + Show Breakdown + <MiniSwitch + onChange={showBreakdownStatsInGraph.toggle} + defaultChecked={false} + /> + </FlexBox> + <FlexBox + fullWidth + height={'300px'} + alignCenter + justifyCenter + p={1} + > + <TrendsLineChart series={series} /> + </FlexBox> + <LegendsMenu series={series} /> + </FlexBox> + ) : ( + <FlexBox col fullWidth relative gap={2}> + <ProcessChartWithContainer + chartId="process-chart-overlaid" + minHeight="25vh" + maxHeight="300px" + borderRadius={1} + legendOutside + hideTitle + /> + </FlexBox> + )} + </AccordionDetails> + </Accordion> + </FlexBox> + <Divider /> + <Title cycleTime={cycleTimeArgs?.cycle_time} /> + <PrBreakdownAndInsights prs={prs} prUpdateCallback={prUpdateCallback} /> + <Line white bold mt={2}> + {prs.length} Pull {pluralize('request', prs.length)} submitted by the + team + </Line> + <PrTableWithPrExclusionMenu + propPrs={prs} + onUpdateCallback={prUpdateCallback} + /> + </FlexBox> + ); +}; + +export const PrBreakdownAndInsights: FC<{ + prs: PR[]; + prevPrs?: PR[]; + prUpdateCallback: () => void; +}> = ({ prs, prevPrs, prUpdateCallback }) => { + const { changeTimeDetailsArray } = useComputedPrChangeTime(prs); + + if (!prs.length) return null; + + return ( + <FlexBox gap={3}> + <FlexBox col gap1> + <FlexBox justifyBetween alignCenter mb={1} gap={2}> + <FlexBox col> + <Line white bold> + Average LT breakdown + </Line> + <Line small>Commit to deploy</Line> + </FlexBox> + </FlexBox> + + <Box sx={{ borderRadius: 1 }}> + <LeadTimeStatsCore + changeTimeSegments={changeTimeDetailsArray} + showTotal + /> + </Box> + </FlexBox> + </FlexBox> + ); +}; + +export const PrListTooltipTitle: FC<{ prs: PR[] }> = ({ prs }) => { + useEffect(() => { + track('PR_NON_REVIEWED_LIST_VIEWED'); + }, []); + + return ( + <Scrollbar autoHeight> + <FlexBox col gap1> + {prs.map((pr, i) => ( + <FlexBox key={i} col> + <Line white bold> + <a href={pr.pr_link} target="_blank" rel="noreferrer"> + #{pr.number} + </a>{' '} + {pr.title} + </Line> + <Line white> + {pr.repo_name} / +{pr.additions} -{pr.deletions} + </Line> + </FlexBox> + ))} + </FlexBox> + </Scrollbar> + ); +}; + +const Title: FC<{ cycleTime: { min: number; max: number } | null }> = ({ + cycleTime +}) => { + const dateRangeLabel = useCurrentDateRangeLabel(); + const maxTimeUnder2Wks = + cycleTime?.max <= millisecondsInSecond * secondsInWeek * 2; + + return ( + <FlexBox col> + <Line big white bold> + Pull request insights for team + </Line> + <Line> + {cycleTime?.min || cycleTime?.max ? ( + <Box component="span"> + Showing insights for PRs from{' '} + <Line color="info">{dateRangeLabel}</Line>, with cycle times{' '} + </Box> + ) : ( + <Box component="span"> + Showing insights for all PRs from{' '} + <Line color="info">{dateRangeLabel}</Line> + </Box> + )} + {cycleTime?.min && cycleTime?.max && maxTimeUnder2Wks ? ( + <Line component="span" color="info"> + between {getDurationString(cycleTime?.min)} and{' '} + {getDurationString(cycleTime?.max)} + </Line> + ) : !cycleTime?.min && cycleTime?.max ? ( + <Line component="span" color="info"> + under {getDurationString(cycleTime?.max)} + </Line> + ) : cycleTime?.min && (!cycleTime?.max || !maxTimeUnder2Wks) ? ( + <Line component="span" color="info"> + over {getDurationString(cycleTime?.min)} + </Line> + ) : null} + </Line> + </FlexBox> + ); +}; + +const useComputedPrChangeTime = (prs: PR[]) => { + const showingLeadTime = true; + + const avgFirstCommitToPrOpenTime = useMemo( + () => + mean( + prs + // This will include only those PRs that are included in overall LT calculations + ?.filter((pr) => Number.isFinite(pr.first_commit_to_open)) + ?.map((pr) => Math.max(pr.first_commit_to_open, 0) || 0) || [] + ), + [prs] + ); + + const avgFirstResponseTime = useMemo( + () => + mean( + prs + ?.filter((pr) => + showingLeadTime ? Number.isFinite(pr.first_response_time) : true + ) + ?.map((pr) => pr.first_response_time || 0) || [] + ), + [prs, showingLeadTime] + ); + const avgReworkTime = useMemo( + () => + mean( + prs + ?.filter((pr) => + showingLeadTime ? Number.isFinite(pr.rework_time) : true + ) + ?.map((pr) => pr.rework_time || 0) || [] + ), + [prs, showingLeadTime] + ); + const avgMergeTime = useMemo( + () => + mean( + prs + ?.filter((pr) => + showingLeadTime ? Number.isFinite(pr.merge_time) : true + ) + ?.map((pr) => pr.merge_time || 0) || [] + ), + [prs, showingLeadTime] + ); + + const avgMergeToDeployTime = useMemo( + () => + mean( + prs + ?.filter((pr) => Number.isFinite(pr.merge_to_deploy)) + ?.map((pr) => pr.merge_to_deploy || 0) || [] + ), + [prs] + ); + + const firstCommitToPrDetails = useMemo( + () => ({ + duration: avgFirstCommitToPrOpenTime || 0, + bgColor: lighten(brandColors.ticketState.todo, 0.1), + color: darken(brandColors.ticketState.todo, 0.9), + clipPath: ClipPathEnum.FIRST, + title: 'Commit', + description: 'Time taken to create PR since the first commit' + }), + [avgFirstCommitToPrOpenTime] + ); + + const firstResponseDetails = useMemo( + () => ({ + duration: avgFirstResponseTime || 0, + bgColor: lighten(brandColors.pr.firstResponseTime, 0.5), + color: darken(brandColors.pr.firstResponseTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Response', + description: 'Time taken to submit the first review on a PR' + }), + [avgFirstResponseTime] + ); + + const reworkDetails = useMemo( + () => ({ + duration: avgReworkTime || 0, + bgColor: lighten(brandColors.pr.reworkTime, 0.5), + color: darken(brandColors.pr.reworkTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Rework', + description: 'Time spent in reviewing the PR, and making changes (if any)' + }), + [avgReworkTime] + ); + + const mergeDetails = useMemo( + () => ({ + duration: avgMergeTime || 0, + bgColor: lighten(brandColors.pr.mergeTime, 0.5), + color: darken(brandColors.pr.mergeTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Merge', + description: + 'Time waited to finally merge the PR after approval was provided' + }), + [avgMergeTime] + ); + + const prToDeploymentDetails = useMemo( + () => ({ + duration: avgMergeToDeployTime || 0, + bgColor: lighten(brandColors.ticketState.done, 0.4), + color: darken(brandColors.ticketState.done, 0.9), + clipPath: ClipPathEnum.LAST, + title: 'Deploy', + description: 'Time taken to deploy the PR once its merged' + }), + [avgMergeToDeployTime] + ); + + const changeTimeDetailsArray = [ + firstCommitToPrDetails, + firstResponseDetails, + reworkDetails, + mergeDetails, + prToDeploymentDetails + ]; + + return { + changeTimeDetailsArray + }; +}; diff --git a/web-server/src/content/PullRequests/useChangeTimePipeline.ts b/web-server/src/content/PullRequests/useChangeTimePipeline.ts new file mode 100644 index 000000000..85e9ae216 --- /dev/null +++ b/web-server/src/content/PullRequests/useChangeTimePipeline.ts @@ -0,0 +1,146 @@ +import { darken, lighten } from '@mui/material'; +import { useCallback, useMemo } from 'react'; + +import { track } from '@/constants/events'; +import { useEasyState } from '@/hooks/useEasyState'; +import { useSelector } from '@/store'; +import { brandColors } from '@/theme/schemes/theme'; +import { ChangeTimeModes } from '@/types/resources'; + +export enum ClipPathEnum { + 'FIRST' = 'polygon(0% 0%, calc(100% - 15px) 0%, 100% 50%, calc(100% - 15px) 100%, 0% 100%)', + 'LAST' = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 15px 50%)', + 'DEFAULT' = 'polygon(0% 0%, calc(100% - 15px) 0%, 100% 50%, calc(100% - 15px) 100%, 0% 100%, 15px 50%)' +} + +export enum StatMode { + Average = 'Average', + P90 = 'P90' +} + +export const useLeadTimePipeline = () => { + const averageSummary = useSelector( + (s) => s.doraMetrics.metrics_summary.lead_time_stats.current + ); + + const firstCommitToPrDetails = useMemo( + () => ({ + duration: averageSummary.first_commit_to_open || 0, + bgColor: lighten(brandColors.ticketState.todo, 0.1), + color: darken(brandColors.ticketState.todo, 0.9), + clipPath: ClipPathEnum.FIRST, + title: 'Commit', + description: 'Time taken to create PR since the first commit' + }), + [averageSummary.first_commit_to_open] + ); + + const firstResponseDetails = useMemo( + () => ({ + duration: averageSummary.first_response_time || 0, + bgColor: lighten(brandColors.pr.firstResponseTime, 0.5), + color: darken(brandColors.pr.firstResponseTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Response', + description: 'Time taken to submit the first review on a PR' + }), + [averageSummary.first_response_time] + ); + + const reworkDetails = useMemo( + () => ({ + duration: averageSummary.rework_time || 0, + bgColor: lighten(brandColors.pr.reworkTime, 0.5), + color: darken(brandColors.pr.reworkTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Rework', + description: 'Time spent in reviewing the PR, and making changes (if any)' + }), + [averageSummary.rework_time] + ); + + const mergeDetails = useMemo( + () => ({ + duration: averageSummary.merge_time || 0, + bgColor: lighten(brandColors.pr.mergeTime, 0.5), + color: darken(brandColors.pr.mergeTime, 0.9), + clipPath: ClipPathEnum.DEFAULT, + title: 'Merge', + description: + 'Time waited to finally merge the PR after approval was provided' + }), + [averageSummary.merge_time] + ); + + const prToDeploymentDetails = useMemo( + () => ({ + duration: averageSummary.merge_to_deploy || 0, + bgColor: lighten(brandColors.ticketState.done, 0.4), + color: darken(brandColors.ticketState.done, 0.9), + clipPath: ClipPathEnum.LAST, + title: 'Deploy', + description: 'Time taken to deploy the PR once its merged' + }), + [averageSummary.merge_to_deploy] + ); + + const leadTimeDetailsArray = [ + firstCommitToPrDetails, + firstResponseDetails, + reworkDetails, + mergeDetails, + prToDeploymentDetails + ]; + + const totalLeadTime = leadTimeDetailsArray.reduce( + (prevValue, currentSegment) => currentSegment.duration + prevValue, + 0 + ); + + return { + leadTimeDetailsArray, + totalLeadTime + }; +}; + +export const usePrChangeTimePipeline = () => { + const changeTimeActiveStatMode = useEasyState<StatMode>(StatMode.Average); + const handleActiveStatModeUpdate = useCallback( + (_, i) => { + const newActiveMode = i === 0 ? StatMode.Average : StatMode.P90; + track('CHANGE_TIME_STATS_MODE_SWITCHED', { + tab_switched_to: newActiveMode + }); + changeTimeActiveStatMode.set(newActiveMode); + }, + [changeTimeActiveStatMode] + ); + + const { leadTimeDetailsArray, totalLeadTime } = useLeadTimePipeline(); + + const allAssignedRepos = useSelector( + (s) => s.doraMetrics.allReposAssignedToTeam + ); + const reposWithWorkflowConfigured = allAssignedRepos; + const reposWithNoDeploymentsConfigured = useMemo(() => { + const workflowConfiguredRepoIdsSet = new Set( + reposWithWorkflowConfigured.map((r) => r.id) + ); + return allAssignedRepos.filter( + (r) => !workflowConfiguredRepoIdsSet.has(r.id) + ); + }, [allAssignedRepos, reposWithWorkflowConfigured]); + + const reposCountWithWorkflowConfigured = + allAssignedRepos.length - reposWithNoDeploymentsConfigured.length; + + return { + handleActiveStatModeUpdate, + activeChangeTimeMode: ChangeTimeModes.LEAD_TIME, + changeTimeDetailsArray: leadTimeDetailsArray, + totalChangeTime: totalLeadTime, + totalLeadTime, + reposWithNoDeploymentsConfigured, + reposCountWithWorkflowConfigured + }; +}; diff --git a/web-server/src/content/PullRequests/usePageData.tsx b/web-server/src/content/PullRequests/usePageData.tsx new file mode 100644 index 000000000..b3adb6700 --- /dev/null +++ b/web-server/src/content/PullRequests/usePageData.tsx @@ -0,0 +1,96 @@ +import { AsyncThunk } from '@reduxjs/toolkit'; +import { useCallback, useEffect, useMemo } from 'react'; + +import { FetchState } from '@/constants/ui-states'; +import { useAuth } from '@/hooks/useAuth'; +import { + useSingleTeamConfig, + useStateBranchConfig +} from '@/hooks/useStateTeamConfig'; +import { useDispatch, useSelector } from '@/store'; +import { IntegrationGroup } from '@/types/resources'; + +export const usePageData = ( + thunk: AsyncThunk<any, CollabTeamInsightsApiParams, {}>, + pageKey: keyof State['requests'], + paramOverrides?: Partial< + CollabTeamInsightsApiParams & CollabRepoInsightsApiParams + > +) => { + const dispatch = useDispatch(); + const { + dates, + singleTeamId, + partiallyUnselected, + memberFilter, + singleTeamProdBranchesConfig, + ...props + } = useSingleTeamConfig(); + const branches = useStateBranchConfig(); + const { integrationSet } = useAuth(); + const hasCodeProvider = integrationSet.has(IntegrationGroup.CODE); + + const refreshDataCallback = useCallback(() => { + if (!singleTeamId || partiallyUnselected || !hasCodeProvider) return; + dispatch( + thunk({ + team_id: singleTeamId, + from_date: dates.start, + to_date: dates.end, + branches, + repo_filters: singleTeamProdBranchesConfig, + ...(paramOverrides || {}) + }) + ); + }, [ + branches, + dates.end, + dates.start, + dispatch, + hasCodeProvider, + paramOverrides, + partiallyUnselected, + singleTeamId, + singleTeamProdBranchesConfig, + thunk + ]); + + useEffect(() => { + refreshDataCallback(); + }, [ + branches, + dates.end, + dates.start, + dispatch, + partiallyUnselected, + singleTeamId, + thunk, + paramOverrides, + hasCodeProvider, + memberFilter, + singleTeamProdBranchesConfig, + refreshDataCallback + ]); + + const isLoading = useSelector( + (state) => + state.doraMetrics?.requests[pageKey] === FetchState.DORMANT || + state.doraMetrics?.requests[pageKey] === FetchState.REQUEST + ); + + const isErrored = useSelector( + (state) => state.doraMetrics.requests[pageKey] === FetchState.FAILURE + ); + + return useMemo( + () => ({ + singleTeamId, + dates, + isLoading, + isErrored, + refreshDataCallback, + ...props + }), + [dates, isErrored, isLoading, props, refreshDataCallback, singleTeamId] + ); +}; diff --git a/web-server/src/hooks/useDoraMetricsGraph/index.tsx b/web-server/src/hooks/useDoraMetricsGraph/index.tsx index 91243900e..d24e648ea 100644 --- a/web-server/src/hooks/useDoraMetricsGraph/index.tsx +++ b/web-server/src/hooks/useDoraMetricsGraph/index.tsx @@ -108,7 +108,7 @@ export const useDoraMetricsGraph = () => { color: rgbToHex(lighten(brandColors.ticketState.todo, 0.5)), data: firstCommitToOpenTrendsData.map((point, index) => ({ x: yAxisLabels[index], - y: point || 0 + y: point.y || 0 })) }, firstResponseTimeTrends: { @@ -116,7 +116,7 @@ export const useDoraMetricsGraph = () => { color: rgbToHex(lighten(brandColors.pr.firstResponseTime, 0.5)), data: firstResponseTimeTrendsData.map((point, index) => ({ x: yAxisLabels[index], - y: point || 0 + y: point.y || 0 })) }, reworkTimeTrends: { @@ -124,7 +124,7 @@ export const useDoraMetricsGraph = () => { color: rgbToHex(lighten(brandColors.pr.reworkTime, 0.5)), data: reworkTimeTrendsData.map((point, index) => ({ x: yAxisLabels[index], - y: point || 0 + y: point.y || 0 })) }, mergeTimeTrends: { @@ -132,7 +132,7 @@ export const useDoraMetricsGraph = () => { color: rgbToHex(lighten(brandColors.pr.mergeTime, 0.5)), data: mergeTimeTrendsData.map((point, index) => ({ x: yAxisLabels[index], - y: point || 0 + y: point.y || 0 })) }, deployTimeTrends: { @@ -140,7 +140,7 @@ export const useDoraMetricsGraph = () => { color: rgbToHex(lighten(brandColors.ticketState.done, 0.5)), data: deployTimeTrendsData.map((point, index) => ({ x: yAxisLabels[index], - y: point || 0 + y: point.y || 0 })) }, totalLeadTimeTrends: { diff --git a/web-server/src/hooks/usePageRefreshCallback.ts b/web-server/src/hooks/usePageRefreshCallback.ts new file mode 100644 index 000000000..122cf32e2 --- /dev/null +++ b/web-server/src/hooks/usePageRefreshCallback.ts @@ -0,0 +1,46 @@ +import { useRouter } from 'next/router'; + +import { ROUTES } from '@/constants/routes'; +import { useAuth } from '@/hooks/useAuth'; +import { + useSingleTeamConfig, + useStateBranchConfig +} from '@/hooks/useStateTeamConfig'; +import { fetchTeamDoraMetrics } from '@/slices/dora_metrics'; +import { useDispatch, useSelector } from '@/store'; +import { ActiveBranchMode } from '@/types/resources'; + +export const usePageRefreshCallback = () => { + const router = useRouter(); + const dispatch = useDispatch(); + const { orgId } = useAuth(); + const { dates, singleTeamId } = useSingleTeamConfig(); + const activeBranchMode = useSelector((s) => s.app.branchMode); + + const branches = useStateBranchConfig(); + switch (router.pathname) { + case ROUTES.DORA_METRICS.PATH: + return () => + dispatch( + fetchTeamDoraMetrics({ + orgId, + teamId: singleTeamId, + fromDate: dates.start, + toDate: dates.end, + branches: + activeBranchMode === ActiveBranchMode.PROD + ? null + : activeBranchMode === ActiveBranchMode.ALL + ? '^' + : branches + }) + ); + default: + return () => {}; + } + // TODO: Pending routes to implement + // ROUTES.PROJECT_MANAGEMENT.PATH + // ROUTES.COLLABORATE.METRICS.PATH + // ROUTES.COLLABORATE.METRICS.USER.PATH + // ROUTES.COLLABORATE.METRICS.CODEBASE.PATH +}; diff --git a/web-server/src/hooks/useStateTeamConfig.tsx b/web-server/src/hooks/useStateTeamConfig.tsx index fd87fcc2e..1834df0ee 100644 --- a/web-server/src/hooks/useStateTeamConfig.tsx +++ b/web-server/src/hooks/useStateTeamConfig.tsx @@ -15,6 +15,7 @@ import { QuickRangeOptions, DateRangeLogic } from '@/components/DateRangePicker/utils'; +import { Line } from '@/components/Text'; import { appSlice, SerializableDateRange } from '@/slices/app'; import { useDispatch, useSelector } from '@/store'; import { ActiveBranchMode } from '@/types/resources'; @@ -192,3 +193,20 @@ export const useStateSingleTeamMembers = (includeManager: boolean = false) => { return [...mgr, ...others].filter(Boolean); }, [includeManager, team?.manager_id, team?.member_ids, usersMap]); }; + +export const useCurrentDateRangeReactNode = () => { + const { start, end, partiallyUnselected } = useStateDateConfig(); + return !partiallyUnselected ? ( + <Line> + <Line color="info" medium> + {format(start, 'do MMM')} + </Line>{' '} + to{' '} + <Line color="info" medium> + {format(end, 'do MMM')} + </Line> + </Line> + ) : ( + <Line>Select Date Range</Line> + ); +}; diff --git a/web-server/src/hooks/useTableSort.ts b/web-server/src/hooks/useTableSort.ts new file mode 100644 index 000000000..2b513b999 --- /dev/null +++ b/web-server/src/hooks/useTableSort.ts @@ -0,0 +1,153 @@ +import { ascend, descend, partition, propOr, sort } from 'ramda'; +import { useCallback, useMemo } from 'react'; +import { snakeCase } from 'voca'; + +import { track } from '@/constants/events'; +import { useEasyState } from '@/hooks/useEasyState'; +import { isObj } from '@/utils/datatype'; +import { depFn } from '@/utils/fn'; +import { isUuid } from '@/utils/unistring'; + +export const useTableSort = <T = Record<string, any>>( + list: T[], + defaultSortConfig: { + field: keyof (typeof list)[number]; + order: 'asc' | 'desc'; + } = { field: Object.keys(list[0] || {})[0] as keyof T, order: 'desc' } +) => { + const sortConfig = useEasyState(defaultSortConfig); + + const conf = sortConfig.value; + + const updateSortConf = useCallback( + (field: keyof T) => + depFn(sortConfig.set, { + field, + order: + conf.field !== field ? 'desc' : conf.order === 'asc' ? 'desc' : 'asc' + }), + [conf.field, conf.order, sortConfig.set] + ); + + const sortedList: T[] = useMemo( + () => + sort( + // @ts-ignore + conf.order === 'asc' + ? // @ts-ignore + ascend(propOr('', conf.field)) + : // @ts-ignore + descend(propOr('', conf.field)), + list + ), + [conf.field, conf.order, list] + ); + + const getCSV = useCallback(() => { + createCsvFromList(list); + }, [list]); + + return { sortedList, updateSortConf, conf, getCSV }; +}; + +type TableSortHook<T = Record<string, any>> = ( + list: T[], + defaultSortConfig: { + field: keyof (typeof list)[number]; + order: 'asc' | 'desc'; + } +) => { + sortedList: typeof list; + conf: typeof defaultSortConfig; + updateSortConf: (f: typeof defaultSortConfig.field) => any; +}; + +export type TableSort<T = Record<string, any>> = ReturnType<TableSortHook<T>>; + +function downloadCSVFromString(content: string, filename: string): void { + const csvContent = + 'data:text/csv;charset=utf-8,' + encodeURIComponent(content); + const link = document.createElement('a'); + link.setAttribute('href', csvContent); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +const getNestedValueFromCellItem = (cell: any) => + cell?.linked_user?.name || cell?.username; + +const EXCLUDED_HEADERS = new Set([ + 'next_sprint_id', + 'previous_sprint_id', + 'priority_order' +]); + +const headerNameOverrides: Record<string, string> = { + lead_time_as_sum_of_parts: 'lead_time_for_changes' +}; + +const getNestedValueFromCell = (cell: any) => { + if (Array.isArray(cell) && getNestedValueFromCellItem(cell[0])) { + const values = cell.map(getNestedValueFromCellItem).filter(Boolean); + if (values.length) return values.join(', '); + return null; + } + + return getNestedValueFromCellItem(cell); +}; + +const checkIfColumnShouldBeRemoved = (name: string, cell: any) => { + if ( + EXCLUDED_HEADERS.has(name) || + isObj(cell) || + Array.isArray(cell) || + isUuid(String(cell)) === true + ) + return true; + + return false; +}; + +const renameColumns = (headers: string[]) => { + return headers.map((name) => snakeCase(headerNameOverrides[name] || name)); +}; + +const createCsvFromList = (list: Record<string, any>[]) => { + if (!list.length) return; + + track('CSV_EXPORT_TRIGGERED', { rows: list.length }); + + const [includedCols, _excludedCols] = partition(([k, v]: [string, any]) => { + if (!checkIfColumnShouldBeRemoved(k, v)) return true; + + return false; + }, Object.entries(list[0])).map((sublist) => sublist.map((t) => t[0])); + + const includedSet = new Set(includedCols); + + // const headers = Object.keys(list[0]); + const rows = list.map((row) => + Object.entries(row) + .filter(([k]) => includedSet.has(k)) + .map(([, v]) => v) + .map((cell) => { + if (cell === null || cell === undefined) return ''; + + const nestedPickedValue = getNestedValueFromCell(cell); + + return nestedPickedValue + ? nestedPickedValue + : JSON.stringify(cell)?.replaceAll(',', ','); // the weird comma is intentional + }) + .join(',') + ); + + const headers = renameColumns([...includedSet.keys()]); + + downloadCSVFromString( + [headers.join(','), ...rows].join('\n'), + 'middleware-export.csv' + ); +}; diff --git a/web-server/src/slices/dora_metrics.ts b/web-server/src/slices/dora_metrics.ts index e81170878..a16c55f88 100644 --- a/web-server/src/slices/dora_metrics.ts +++ b/web-server/src/slices/dora_metrics.ts @@ -22,6 +22,7 @@ import { TeamDoraMetricsApiResponseType } from '../types/resources'; export type State = StateFetchConfig<{ firstLoadDone: boolean; activeChangeTimeMode: ChangeTimeModes; + deploymentPrs: PR[]; metrics_summary: Omit< TeamDoraMetricsApiResponseType, | 'allReposAssignedToTeam' @@ -42,6 +43,7 @@ export type State = StateFetchConfig<{ const initialState: State = { firstLoadDone: false, activeChangeTimeMode: ChangeTimeModes.CYCLE_TIME, + deploymentPrs: [], metrics_summary: null, allReposAssignedToTeam: [], all_deployments: [], @@ -61,6 +63,10 @@ export const doraMetricsSlice = createSlice({ name: 'dora_metrics', initialState, reducers: { + resetDeployments(state: State) { + state.deploymentPrs = []; + state.team_deployments = initialState.team_deployments; + }, toggleActiveModeValue( state: State, action: PayloadAction<ChangeTimeModes> @@ -89,6 +95,7 @@ export const doraMetricsSlice = createSlice({ action.payload ); state.allReposAssignedToTeam = action.payload.assigned_repos; + state.summary_prs = action.payload.lead_time_prs; } ); addFetchCasesToReducer( @@ -113,6 +120,12 @@ export const doraMetricsSlice = createSlice({ 'team_deployments', (state, action) => (state.team_deployments = action.payload) ); + addFetchCasesToReducer( + builder, + fetchDeploymentPRs, + 'deploymentPrs', + (state, action) => (state.deploymentPrs = action.payload) + ); } }); @@ -179,3 +192,10 @@ export const fetchTeamDeployments = createAsyncThunk( ); } ); + +export const fetchDeploymentPRs = createAsyncThunk( + 'collab/fetchDeploymentPRs', + async (params: { deployment_id: ID }) => { + return await handleApi<PR[]>(`/internal/deployments/prs`, { params }); + } +); diff --git a/web-server/src/types/resources.ts b/web-server/src/types/resources.ts index 27ce0d44e..37ab1e454 100644 --- a/web-server/src/types/resources.ts +++ b/web-server/src/types/resources.ts @@ -971,3 +971,26 @@ export type DoraPropsType = { backgroundColor: string; interval: string; }; + +export type LeadTimeSummaryApiResponseType = { + data: { + status_counts: UserStat; + pipeline_duration: LeadTimePipelineDuration; + }; + repos_included: RepoWithSingleWorkflow[]; + all_team_repos: RepoWithSingleWorkflow[]; +}; + +export type CycleTimeSummaryApiResponseType = { + status_counts: UserStat; + pipeline_duration: LeadTimePipelineDuration; + cycle_time_stats: Record<string, number>; + prev_cycle_time_stats: Record<string, number>; +}; + +export interface UserStat { + OPEN: number; + CLOSED: number; + MERGED: number; + REVIEWED: number; +} diff --git a/web-server/src/utils/adapt_deployments.ts b/web-server/src/utils/adapt_deployments.ts new file mode 100644 index 000000000..b13e526da --- /dev/null +++ b/web-server/src/utils/adapt_deployments.ts @@ -0,0 +1,38 @@ +import { descend, mapObjIndexed, prop, sort } from 'ramda'; + +import { + Deployment, + UpdatedDeployment, + UpdatedTeamDeploymentsApiResponse +} from '@/types/resources'; + +export function adaptDeploymentsMap(curr: UpdatedDeployment): Deployment { + return { + id: curr.id, + status: curr.status, + head_branch: curr.head_branch, + event_actor: { + username: curr.event_actor.username, + linked_user: curr.event_actor.linked_user + }, + created_at: '', + updated_at: '', + conducted_at: curr.conducted_at, + pr_count: curr.pr_count, + html_url: curr.html_url, + repo_workflow_id: curr.meta.repo_workflow_id, + run_duration: curr.duration + }; +} + +export const adaptedDeploymentsMap = ( + deploymentsMap: UpdatedTeamDeploymentsApiResponse['deployments_map'] +) => { + const x = Object.entries(deploymentsMap).map(([key, value]) => { + return [key, value.map(adaptDeploymentsMap)]; + }); + const adaptedDeployments: Record<string, Deployment[]> = + Object.fromEntries(x); + + return mapObjIndexed(sort(descend(prop('conducted_at'))), adaptedDeployments); +}; diff --git a/web-server/src/utils/user.ts b/web-server/src/utils/user.ts index 3ba0af7f8..5954c8e5b 100644 --- a/web-server/src/utils/user.ts +++ b/web-server/src/utils/user.ts @@ -1,4 +1,5 @@ import { Row } from '@/constants/db'; +import { brandColors } from '@/theme/schemes/theme'; import { BaseUser } from '@/types/resources'; export type UserProfile = User; @@ -26,3 +27,15 @@ export const getBaseUserFromRowUser = (user: Row<'Users'>): BaseUser => ({ name: user.name, avatar_url: null }); + +export const getColorByStatus = (status: 'MERGED' | 'CLOSED' | 'OPEN') => { + switch (status) { + case 'OPEN': + return brandColors.pr.open; + case 'CLOSED': + return brandColors.pr.close; + case 'MERGED': + default: + return brandColors.pr.merge; + } +};