diff --git a/web-server/pages/api/internal/team/[team_id]/dora_metrics.ts b/web-server/pages/api/internal/team/[team_id]/dora_metrics.ts index 94acae411..d6006892c 100644 --- a/web-server/pages/api/internal/team/[team_id]/dora_metrics.ts +++ b/web-server/pages/api/internal/team/[team_id]/dora_metrics.ts @@ -2,6 +2,7 @@ import { endOfDay, startOfDay } from 'date-fns'; import * as yup from 'yup'; import { getTeamRepos } from '@/api/resources/team_repos'; +import { getBookmarkedRepos } from '@/api/resources/teams/[team_id]/bookmarked_repos'; import { Endpoint } from '@/api-helpers/global'; import { repoFiltersFromTeamProdBranches, @@ -48,8 +49,10 @@ endpoint.handle.GET(getSchema, async (req, res) => { branches } = req.payload; - const teamProdBranchesMap = - await getAllTeamsReposProdBranchesForOrgAsMap(org_id); + const [teamProdBranchesMap, bookmarkedRepos] = await Promise.all([ + getAllTeamsReposProdBranchesForOrgAsMap(org_id), + getBookmarkedRepos(teamId) + ]); const teamRepoFiltersMap = repoFiltersFromTeamProdBranches(teamProdBranchesMap); @@ -169,7 +172,8 @@ endpoint.handle.GET(getSchema, async (req, res) => { deployment_frequency_trends: deploymentFrequencyResponse.deployment_frequency_trends, lead_time_prs: leadtimePrs, - assigned_repos: teamRepos + assigned_repos: teamRepos, + bookmarked_repos: bookmarkedRepos } as TeamDoraMetricsApiResponseType); }); diff --git a/web-server/pages/api/resources/teams/[team_id]/bookmarked_repos.ts b/web-server/pages/api/resources/teams/[team_id]/bookmarked_repos.ts new file mode 100644 index 000000000..d0d2e18ef --- /dev/null +++ b/web-server/pages/api/resources/teams/[team_id]/bookmarked_repos.ts @@ -0,0 +1,40 @@ +import * as yup from 'yup'; + +import { getTeamRepos } from '@/api/resources/team_repos'; +import { Endpoint, nullSchema } from '@/api-helpers/global'; +import { Table } from '@/constants/db'; +import { uuid } from '@/utils/datatype'; +import { db } from '@/utils/db'; + +const pathSchema = yup.object().shape({ + team_id: yup.string().uuid().required() +}); + +const endpoint = new Endpoint(pathSchema); + +endpoint.handle.GET(nullSchema, async (req, res) => { + if (req.meta?.features?.use_mock_data) { + return res.send([uuid(), uuid()]); + } + + res.send(await getBookmarkedRepos(req.payload.team_id)); +}); + +export const getBookmarkedRepos = async (teamId?: ID) => { + const query = db(Table.Bookmark).select('repo_id'); + + if (!teamId) + return (await query.then((res) => + res.map((item) => item?.repo_id) + )) as ID[]; + + const teamRepoIds = await getTeamRepos(teamId).then((res) => + res.map((repo) => repo.id) + ); + + return (await query + .whereIn('repo_id', teamRepoIds) + .then((res) => res.map((item) => item?.repo_id))) as ID[]; +}; + +export default endpoint.serve(); diff --git a/web-server/pages/dora-metrics/index.tsx b/web-server/pages/dora-metrics/index.tsx index 33c37a7a0..6df4cca31 100644 --- a/web-server/pages/dora-metrics/index.tsx +++ b/web-server/pages/dora-metrics/index.tsx @@ -1,4 +1,3 @@ -import { Chip } from '@mui/material'; import ExtendedSidebarLayout from 'src/layouts/ExtendedSidebarLayout'; import { Authenticated } from '@/components/Authenticated'; @@ -11,7 +10,6 @@ import { PageWrapper } from '@/content/PullRequests/PageWrapper'; import { useAuth } from '@/hooks/useAuth'; import { useSelector } from '@/store'; import { PageLayout } from '@/types/resources'; - function Page() { useRedirectWithSession(); const isLoading = useSelector( diff --git a/web-server/src/components/Teams/CreateTeams.tsx b/web-server/src/components/Teams/CreateTeams.tsx index 5e8b6072e..d72bc3ee1 100644 --- a/web-server/src/components/Teams/CreateTeams.tsx +++ b/web-server/src/components/Teams/CreateTeams.tsx @@ -83,11 +83,11 @@ const TeamsCRUD: FC = ({ ); }; -export const Loader = () => { +export const Loader: FC<{ label?: string }> = ({ label = 'Loading...' }) => { return ( - Loading... + {label} ); }; diff --git a/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx b/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx index 6e1a00124..c652668c3 100644 --- a/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx +++ b/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx @@ -1,6 +1,6 @@ import { Grid, Divider, Button } from '@mui/material'; import Link from 'next/link'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { DoraMetricsConfigurationSettings } from '@/components/DoraMetricsConfigurationSettings'; import { DoraScore } from '@/components/DoraScore'; @@ -14,6 +14,8 @@ import { ROUTES } from '@/constants/routes'; import { FetchState } from '@/constants/ui-states'; import { useDoraStats } from '@/content/DoraMetrics/DoraCards/sharedHooks'; import { useAuth } from '@/hooks/useAuth'; +import { useBoolState, useEasyState } from '@/hooks/useEasyState'; +import { usePageRefreshCallback } from '@/hooks/usePageRefreshCallback'; import { useSingleTeamConfig, useStateBranchConfig @@ -21,6 +23,7 @@ import { import { fetchTeamDoraMetrics } from '@/slices/dora_metrics'; import { useDispatch, useSelector } from '@/store'; import { ActiveBranchMode } from '@/types/resources'; +import { depFn } from '@/utils/fn'; import { getRandomLoadMsg } from '@/utils/loading-messages'; import { ClassificationPills } from './ClassificationPills'; @@ -89,7 +92,7 @@ export const DoraMetricsBody = () => { const stats = useDoraStats(); - const { isFreshOrg } = useFreshOrgCalculator(); + const { isSyncing } = useSyncedRepos(); if (isErrored) return ( @@ -100,12 +103,18 @@ export const DoraMetricsBody = () => { ); if (!firstLoadDone) return ; if (isTeamInsightsEmpty) - if (isFreshOrg) + if (isSyncing) return ( + + + } > @@ -135,7 +144,8 @@ export const DoraMetricsBody = () => { - + + @@ -184,3 +194,122 @@ export const calculateIsFreshOrg = (createdAt: string | Date): boolean => { return timeDiffMs <= FRESH_ORG_THRESHOLD * 60 * 1000; }; + +export const useSyncedRepos = () => { + const pageRefreshCallback = usePageRefreshCallback(); + + const reposMap = useSelector((s) => s.team.teamReposMaps); + const { singleTeamId } = useSingleTeamConfig(); + const syncedRepos = useSelector((s) => s.doraMetrics.bookmarkedRepos); + + const isSyncing = useMemo(() => { + const teamRepos = reposMap[singleTeamId] || []; + return !teamRepos.every((repo) => syncedRepos.includes(repo.id)); + }, [reposMap, singleTeamId, syncedRepos]); + + useEffect(() => { + if (!isSyncing) return; + + const interval = setInterval(() => { + pageRefreshCallback(); + }, 10_000); + + return () => clearInterval(interval); + }, [isSyncing, pageRefreshCallback]); + + return { + isSyncing, + syncedRepos, + teamRepos: reposMap[singleTeamId] || [] + }; +}; + +const ANIMATON_DURATION = 700; + +const Syncing = () => { + const flickerAnimation = useBoolState(false); + const { isSyncing } = useSyncedRepos(); + + useEffect(() => { + if (!isSyncing) return; + const interval = setInterval(() => { + depFn(flickerAnimation.toggle); + }, ANIMATON_DURATION); + return () => clearInterval(interval); + }, [isSyncing, flickerAnimation.toggle]); + + return ( + + + + + {isSyncing && } + + + Calculating Dora + + We’re processing your data, it usually takes ~ 5 mins + + + + ); +}; + +const LoadingWrapper = () => { + const rotation = useEasyState(0); + useEffect(() => { + const interval = setInterval(() => { + rotation.set((r) => r + 1); + }, 10); + return () => clearInterval(interval); + }, [rotation]); + return ( + + + + + + ); +}; diff --git a/web-server/src/content/PullRequests/PageWrapper.tsx b/web-server/src/content/PullRequests/PageWrapper.tsx index c2f0966a6..a679e8ff7 100644 --- a/web-server/src/content/PullRequests/PageWrapper.tsx +++ b/web-server/src/content/PullRequests/PageWrapper.tsx @@ -18,6 +18,7 @@ export const PageWrapper: FC<{ headerChildren?: ReactNode; isLoading?: boolean; showEvenIfNoTeamSelected?: boolean; + additionalFilters?: ReactNode[]; }> = ({ title = 'Collaborate', hideAllSelectors, @@ -27,7 +28,8 @@ export const PageWrapper: FC<{ teamDateSelectorMode, headerChildren, isLoading, - showEvenIfNoTeamSelected = false + showEvenIfNoTeamSelected = false, + additionalFilters = [] }) => { const { noTeamSelected } = useSingleTeamConfig(); // TODO: use fetchState @@ -45,6 +47,7 @@ export const PageWrapper: FC<{ teamDateSelectorMode={ teamDateSelectorMode || (showDate ? 'single' : 'single-only') } + additionalFilters={additionalFilters} selectBranch > {headerChildren} diff --git a/web-server/src/slices/dora_metrics.ts b/web-server/src/slices/dora_metrics.ts index 32ac70381..4956aa69a 100644 --- a/web-server/src/slices/dora_metrics.ts +++ b/web-server/src/slices/dora_metrics.ts @@ -38,6 +38,7 @@ export type State = StateFetchConfig<{ deployments_map: Record; revert_prs: PR[]; summary_prs: PR[]; + bookmarkedRepos: ID[]; }>; const initialState: State = { @@ -56,7 +57,8 @@ const initialState: State = { prs_map: {}, deployments_map: {}, revert_prs: [], - summary_prs: [] + summary_prs: [], + bookmarkedRepos: [] }; export const doraMetricsSlice = createSlice({ @@ -99,6 +101,7 @@ export const doraMetricsSlice = createSlice({ ); state.allReposAssignedToTeam = action.payload.assigned_repos; state.summary_prs = action.payload.lead_time_prs; + state.bookmarkedRepos = action.payload.bookmarked_repos; } ); addFetchCasesToReducer( diff --git a/web-server/src/slices/team.ts b/web-server/src/slices/team.ts index 53557e4da..c0364e81f 100644 --- a/web-server/src/slices/team.ts +++ b/web-server/src/slices/team.ts @@ -48,7 +48,7 @@ const initialState: State = { teamReposProductionBranches: [], teamIncidentFilters: null, excludedPrs: [], - teamReposMaps: null + teamReposMaps: {} }; export const teamSlice = createSlice({ diff --git a/web-server/src/types/resources.ts b/web-server/src/types/resources.ts index 08c41d6e6..aef09fa14 100644 --- a/web-server/src/types/resources.ts +++ b/web-server/src/types/resources.ts @@ -577,6 +577,7 @@ export type TeamDoraMetricsApiResponseType = { }; lead_time_prs: PR[]; assigned_repos: (Row<'TeamRepos'> & Row<'OrgRepo'>)[]; + bookmarked_repos: ID[]; }; export enum ActiveBranchMode {