diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index 79d83140e4..98cb316fba 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -4,12 +4,10 @@ import { PageContent } from 'component/common/PageContent/PageContent'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; -import type { IProject } from 'interfaces/project'; import { PaginatedTable } from 'component/common/Table'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; -import type { ProjectEnvironmentType } from '../ProjectFeatureToggles/hooks/useEnvironmentsRef'; import { ActionsCell } from '../ProjectFeatureToggles/ActionsCell/ActionsCell'; import { ExperimentalColumnsMenu as ColumnsMenu } from './ExperimentalColumnsMenu/ExperimentalColumnsMenu'; import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi'; @@ -53,7 +51,7 @@ import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/Feat import { useSelectedData } from './hooks/useSelectedData'; interface IPaginatedProjectFeatureTogglesProps { - environments: IProject['environments']; + environments: string[]; refreshInterval?: number; storageKey?: string; } @@ -226,63 +224,59 @@ export const ProjectFeatureToggles = ({ header: 'Created', cell: DateCell, }), - ...environments.map( - (projectEnvironment: ProjectEnvironmentType) => { - const name = projectEnvironment.environment; - const isChangeRequestEnabled = - isChangeRequestConfigured(name); + ...environments.map((name: string) => { + const isChangeRequestEnabled = isChangeRequestConfigured(name); - return columnHelper.accessor( - (row) => ({ - featureId: row.name, - environment: row.environments?.find( + return columnHelper.accessor( + (row) => ({ + featureId: row.name, + environment: row.environments?.find( + (featureEnvironment) => + featureEnvironment.name === name, + ), + someEnabledEnvironmentHasVariants: + row.environments?.some( (featureEnvironment) => - featureEnvironment.name === name, - ), - someEnabledEnvironmentHasVariants: - row.environments?.some( - (featureEnvironment) => - featureEnvironment.variantCount && - featureEnvironment.variantCount > 0 && - featureEnvironment.enabled, - ) || false, - }), - { - id: formatEnvironmentColumnId(name), - header: name, - meta: { - align: 'center', - width: 90, - }, - cell: ({ getValue }) => { - const { - featureId, - environment, - someEnabledEnvironmentHasVariants, - } = getValue(); - - return ( - - ); - }, + featureEnvironment.variantCount && + featureEnvironment.variantCount > 0 && + featureEnvironment.enabled, + ) || false, + }), + { + id: formatEnvironmentColumnId(name), + header: name, + meta: { + align: 'center', + width: 90, }, - ); - }, - ), + cell: ({ getValue }) => { + const { + featureId, + environment, + someEnabledEnvironmentHasVariants, + } = getValue(); + + return ( + + ); + }, + }, + ); + }), columnHelper.display({ id: 'actions', header: '', @@ -391,9 +385,7 @@ export const ProjectFeatureToggles = ({ setTableState({ query }); }} dataToExport={data} - environmentsToExport={environments.map( - ({ environment }) => environment, - )} + environmentsToExport={environments} actions={ ({ + ...environments.map((environment) => ({ header: environment, id: formatEnvironmentColumnId( environment, @@ -478,9 +470,7 @@ export const ProjectFeatureToggles = ({ showExportDialog={showExportDialog} data={data} onClose={() => setShowExportDialog(false)} - environments={environments.map( - ({ environment }) => environment, - )} + environments={environments} /> } /> diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index bea86baf84..0fb14ce56d 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -10,7 +10,7 @@ import useProjectOverview, { import { usePageTitle } from 'hooks/usePageTitle'; import { useLastViewedProject } from 'hooks/useLastViewedProject'; import { useUiFlag } from 'hooks/useUiFlag'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ProjectOverviewChangeRequests } from './ProjectOverviewChangeRequests'; const refreshInterval = 15 * 1000; @@ -39,6 +39,17 @@ const ProjectOverview: FC<{ storageKey?: string; }> = ({ storageKey = 'project-overview-v2' }) => { const projectOverviewRefactor = useUiFlag('projectOverviewRefactor'); + + if (projectOverviewRefactor) { + return ; + } else { + return ; + } +}; + +const OldProjectOverview: FC<{ + storageKey?: string; +}> = ({ storageKey = 'project-overview-v2' }) => { const projectId = useRequiredPathParam('projectId'); const projectName = useProjectOverviewNameOrId(projectId); const { project } = useProjectOverview(projectId, { @@ -61,29 +72,58 @@ const ProjectOverview: FC<{ return ( - - } + - } - /> + environment.environment, + )} + refreshInterval={refreshInterval} + storageKey={storageKey} + /> + + + + ); +}; + +const NewProjectOverview: FC<{ + storageKey?: string; +}> = ({ storageKey = 'project-overview-v2' }) => { + const projectId = useRequiredPathParam('projectId'); + const projectName = useProjectOverviewNameOrId(projectId); + + const { project } = useProjectOverview(projectId, { + refreshInterval, + }); + + usePageTitle(`Project overview – ${projectName}`); + const { setLastViewed } = useLastViewedProject(); + useEffect(() => { + setLastViewed(projectId); + }, [projectId, setLastViewed]); + + return ( + + + + + + environment.environment, + )} refreshInterval={refreshInterval} storageKey={storageKey} /> diff --git a/frontend/src/component/project/Project/ProjectOverviewChangeRequests.test.tsx b/frontend/src/component/project/Project/ProjectOverviewChangeRequests.test.tsx new file mode 100644 index 0000000000..93e3d3df2f --- /dev/null +++ b/frontend/src/component/project/Project/ProjectOverviewChangeRequests.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react'; +import { render } from 'utils/testRenderer'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { ProjectOverviewChangeRequests } from './ProjectOverviewChangeRequests'; + +const server = testServerSetup(); + +const setupEnterpriseApi = () => { + testServerRoute(server, '/api/admin/ui-config', { + versionInfo: { + current: { enterprise: 'present' }, + }, + }); + testServerRoute( + server, + '/api/admin/projects/default/change-requests/config', + [ + { + environment: 'default', + changeRequestEnabled: true, + }, + ], + ); + testServerRoute( + server, + '/api/admin/projects/default/change-requests/count', + { + total: 14, + approved: 2, + applied: 0, + rejected: 0, + reviewRequired: 10, + scheduled: 2, + }, + ); +}; + +test('Show change requests count', async () => { + setupEnterpriseApi(); + render(); + + await screen.findByText('4'); + await screen.findByText('10'); + await screen.findByText('View change requests'); +}); diff --git a/frontend/src/component/project/Project/ProjectOverviewChangeRequests.tsx b/frontend/src/component/project/Project/ProjectOverviewChangeRequests.tsx index fa6182d6d5..4c97bd3c4d 100644 --- a/frontend/src/component/project/Project/ProjectOverviewChangeRequests.tsx +++ b/frontend/src/component/project/Project/ProjectOverviewChangeRequests.tsx @@ -1,6 +1,8 @@ import { Box, styled, Typography } from '@mui/material'; import { Link } from 'react-router-dom'; import type { FC } from 'react'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { useChangeRequestsCount } from 'hooks/api/getters/useChangeRequestsCount/useChangeRequestsCount'; export const ChangeRequestContainer = styled(Box)(({ theme }) => ({ margin: '0', @@ -42,16 +44,27 @@ const ChangeRequestCount = styled(Typography)(({ theme }) => ({ export const ProjectOverviewChangeRequests: FC<{ project: string }> = ({ project, }) => { + const { isChangeRequestConfiguredInAnyEnv } = + useChangeRequestsEnabled(project); + const { data } = useChangeRequestsCount(project); + + if (!isChangeRequestConfiguredInAnyEnv) { + return null; + } + + const toBeApplied = data.scheduled + data.approved; + const toBeReviewed = data.reviewRequired; + return ( Open change requests To be applied - 10 + {toBeApplied} To be reviewed - 20 + {toBeReviewed} View change requests diff --git a/frontend/src/hooks/api/getters/useChangeRequestsCount/useChangeRequestsCount.ts b/frontend/src/hooks/api/getters/useChangeRequestsCount/useChangeRequestsCount.ts new file mode 100644 index 0000000000..525fde6f7a --- /dev/null +++ b/frontend/src/hooks/api/getters/useChangeRequestsCount/useChangeRequestsCount.ts @@ -0,0 +1,39 @@ +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import type { ChangeRequestsCountSchema } from '../../../../openapi'; + +const fallback: ChangeRequestsCountSchema = { + applied: 0, + approved: 0, + rejected: 0, + scheduled: 0, + reviewRequired: 0, + total: 0, +}; + +export const useChangeRequestsCount = (projectId: string) => { + const { isEnterprise } = useUiConfig(); + const { data, error, mutate } = + useConditionalSWR( + Boolean(projectId) && isEnterprise(), + fallback, + formatApiPath( + `api/admin/projects/${projectId}/change-requests/count`, + ), + fetcher, + ); + return { + data: data || fallback, + loading: !error && !data, + refetchChangeRequestConfig: mutate, + error, + }; +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Request changes')) + .then((res) => res.json()); +}; diff --git a/frontend/src/openapi/models/changeRequestsCountSchema.ts b/frontend/src/openapi/models/changeRequestsCountSchema.ts new file mode 100644 index 0000000000..51c99477d8 --- /dev/null +++ b/frontend/src/openapi/models/changeRequestsCountSchema.ts @@ -0,0 +1,23 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +/** + * Count of change requests in different stages of the [process](https://docs.getunleash.io/reference/change-requests#change-request-flow). + */ +export interface ChangeRequestsCountSchema { + /** The number of applied change requests */ + applied: number; + /** The number of approved change requests */ + approved: number; + /** The number of rejected change requests */ + rejected: number; + /** The number of change requests awaiting the review */ + reviewRequired: number; + /** The number of scheduled change requests */ + scheduled: number; + /** The number of total change requests in this project */ + total: number; +} diff --git a/frontend/src/openapi/models/featureSearchEnvironmentSchema.ts b/frontend/src/openapi/models/featureSearchEnvironmentSchema.ts new file mode 100644 index 0000000000..4a085a0be4 --- /dev/null +++ b/frontend/src/openapi/models/featureSearchEnvironmentSchema.ts @@ -0,0 +1,41 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ +import type { FeatureStrategySchema } from './featureStrategySchema'; +import type { VariantSchema } from './variantSchema'; + +/** + * A detailed description of the feature environment + */ +export interface FeatureSearchEnvironmentSchema { + /** `true` if the feature is enabled for the environment, otherwise `false`. */ + enabled: boolean; + /** The name of the environment */ + environment?: string; + /** The name of the feature */ + featureName?: string; + /** Whether the feature has any enabled strategies defined. */ + hasEnabledStrategies?: boolean; + /** Whether the feature has any strategies defined. */ + hasStrategies?: boolean; + /** The date when metrics where last collected for the feature environment */ + lastSeenAt?: string | null; + /** The name of the environment */ + name: string; + /** How many times the toggle evaluated to false in last hour bucket */ + no?: number; + /** The sort order of the feature environment in the feature environments list */ + sortOrder?: number; + /** A list of activation strategies for the feature environment */ + strategies?: FeatureStrategySchema[]; + /** The type of the environment */ + type?: string; + /** The number of defined variants */ + variantCount?: number; + /** A list of variants for the feature environment */ + variants?: VariantSchema[]; + /** How many times the toggle evaluated to true in last hour bucket */ + yes?: number; +} diff --git a/frontend/src/openapi/models/featureSearchResponseSchema.ts b/frontend/src/openapi/models/featureSearchResponseSchema.ts index d11d5ac826..9b45f9e132 100644 --- a/frontend/src/openapi/models/featureSearchResponseSchema.ts +++ b/frontend/src/openapi/models/featureSearchResponseSchema.ts @@ -4,7 +4,7 @@ * See `gen:api` script in package.json */ import type { FeatureSearchResponseSchemaDependenciesItem } from './featureSearchResponseSchemaDependenciesItem'; -import type { FeatureEnvironmentSchema } from './featureEnvironmentSchema'; +import type { FeatureSearchEnvironmentSchema } from './featureSearchEnvironmentSchema'; import type { FeatureSearchResponseSchemaStrategiesItem } from './featureSearchResponseSchemaStrategiesItem'; import type { TagSchema } from './tagSchema'; import type { VariantSchema } from './variantSchema'; @@ -28,7 +28,7 @@ export interface FeatureSearchResponseSchema { /** `true` if the feature is enabled, otherwise `false`. */ enabled?: boolean; /** The list of environments where the feature can be used */ - environments?: FeatureEnvironmentSchema[]; + environments?: FeatureSearchEnvironmentSchema[]; /** `true` if the feature was favorited, otherwise `false`. */ favorite?: boolean; /** `true` if the impression data collection is enabled for the feature, otherwise `false`. */ diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 98adb64b0f..0e91fcaff1 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -271,6 +271,7 @@ export * from './changeRequestStateSchemaOneOfState'; export * from './changeRequestStateSchemaOneOfThree'; export * from './changeRequestStateSchemaOneOfThreeState'; export * from './changeRequestUpdateTitleSchema'; +export * from './changeRequestsCountSchema'; export * from './changeRequestsSchema'; export * from './changeRoleForGroup401'; export * from './changeRoleForGroup403'; @@ -538,6 +539,7 @@ export * from './featureMetricsSchema'; export * from './featureSchema'; export * from './featureSchemaDependenciesItem'; export * from './featureSchemaStrategiesItem'; +export * from './featureSearchEnvironmentSchema'; export * from './featureSearchResponseSchema'; export * from './featureSearchResponseSchemaDependenciesItem'; export * from './featureSearchResponseSchemaStrategiesItem';