From 03929e30313b9ee7ed752a5a8544b8b930d9d74f Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 19 Feb 2024 09:50:53 +0200 Subject: [PATCH] feat: project applications UI (#6260) ![image](https://github.com/Unleash/unleash/assets/964450/a1129857-820c-4e93-ac59-ef5f4743d774) --- .../ChangeOverwriteWarning.tsx | 2 - .../Table/cells/LinkCell/LinkCell.test.tsx | 1 - .../common/Table/cells/StringArrayCell.tsx | 60 ++++++ .../src/component/project/Project/Project.tsx | 8 + .../ProjectApplications.tsx | 202 ++++++++++++++++++ .../project/ProjectApplications/SdkCell.tsx | 65 ++++++ .../useProjectApplications.ts | 82 +++++++ src/lib/features/project/project-store.ts | 22 +- 8 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 frontend/src/component/common/Table/cells/StringArrayCell.tsx create mode 100644 frontend/src/component/project/ProjectApplications/ProjectApplications.tsx create mode 100644 frontend/src/component/project/ProjectApplications/SdkCell.tsx create mode 100644 frontend/src/hooks/api/getters/useProjectApplications/useProjectApplications.ts diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/ChangeOverwriteWarning.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/ChangeOverwriteWarning.tsx index f75d4b6bd9..463befa0c7 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/ChangeOverwriteWarning.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/ChangeOverwriteWarning.tsx @@ -4,12 +4,10 @@ import { IChangeRequestUpdateSegment, IChangeRequestUpdateStrategy, } from 'component/changeRequest/changeRequest.types'; -import { useChangeRequestPlausibleContext } from 'component/changeRequest/ChangeRequestContext'; import { useUiFlag } from 'hooks/useUiFlag'; import { IFeatureVariant } from 'interfaces/featureToggle'; import { ISegment } from 'interfaces/segment'; import { IFeatureStrategy } from 'interfaces/strategy'; -import { useEffect } from 'react'; import { OverwriteWarning } from './OverwriteWarning'; import { getEnvVariantChangesThatWouldBeOverwritten, diff --git a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.test.tsx b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.test.tsx index 424e5f2f86..a8efc406b4 100644 --- a/frontend/src/component/common/Table/cells/LinkCell/LinkCell.test.tsx +++ b/frontend/src/component/common/Table/cells/LinkCell/LinkCell.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { screen } from '@testing-library/react'; import { render } from 'utils/testRenderer'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/component/common/Table/cells/StringArrayCell.tsx b/frontend/src/component/common/Table/cells/StringArrayCell.tsx new file mode 100644 index 0000000000..3ef4d70080 --- /dev/null +++ b/frontend/src/component/common/Table/cells/StringArrayCell.tsx @@ -0,0 +1,60 @@ +import { VFC } from 'react'; +import { styled, Typography } from '@mui/material'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; + +const StyledTag = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, +})); + +interface IArrayFieldCellProps { + row: T; + field: keyof T; + singularLabel: string; + pluralLabel?: string; +} + +export const StringArrayCell: VFC> = ({ + row, + field, + singularLabel, + pluralLabel, +}) => { + const { searchQuery } = useSearchHighlightContext(); + const fieldValue = row[field]; + + if (!Array.isArray(fieldValue) || fieldValue.length === 0) + return ; + + const labelForMultiple = pluralLabel || `${singularLabel}s`; + + return ( + + 0 && + fieldValue.some((item: string) => + item.toLowerCase().includes(searchQuery.toLowerCase()), + ) + } + tooltip={ + <> + {fieldValue.map((item: string) => ( + + + {item} + + + ))} + + } + > + {fieldValue.length === 1 + ? `1 ${singularLabel}` + : `${fieldValue.length} ${labelForMultiple}`} + + + ); +}; diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index d7bea11e1a..83d114891e 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -42,6 +42,7 @@ import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics'; import { UiFlags } from 'interfaces/uiConfig'; import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip'; import { ChangeRequestPlausibleProvider } from 'component/changeRequest/ChangeRequestContext'; +import { ProjectApplications } from '../ProjectApplications/ProjectApplications'; const StyledBadge = styled(Badge)(({ theme }) => ({ position: 'absolute', @@ -110,6 +111,12 @@ export const Project = () => { name: 'dora', isEnterprise: true, }, + { + title: 'Applications', + path: `${basePath}/applications`, + name: 'applications', + flag: 'sdkReporting', + }, { title: 'Event log', path: `${basePath}/logs`, @@ -314,6 +321,7 @@ export const Project = () => { /> } /> } /> + } /> } /> (); + +export const ProjectApplications = () => { + const projectId = useRequiredPathParam('projectId'); + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const stateConfig = { + offset: withDefault(NumberParam, 0), + limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), + query: StringParam, + sortBy: withDefault(StringParam, 'createdAt'), + sortOrder: withDefault(StringParam, 'desc'), + }; + const [tableState, setTableState] = usePersistentTableState( + `project-applications-table-${projectId}`, + stateConfig, + ); + + const { + applications = [], + total, + loading, + refetch: refetchApplications, + } = useProjectApplications( + projectId, + mapValues(encodeQueryParams(stateConfig, tableState), (value) => + value ? `${value}` : undefined, + ), + ); + + const setSearchValue = (query = '') => { + setTableState({ query }); + }; + + const bodyLoadingRef = useLoading(loading); + + const { offset, limit, query, sortBy, sortOrder, ...filterState } = + tableState; + + const columns = useMemo( + () => [ + columnHelper.accessor('name', { + header: 'Name', + cell: ({ row }) => ( + + ), + meta: { + width: '25%', + }, + }), + columnHelper.accessor('environments', { + header: 'Environments', + cell: ({ row }) => ( + + ), + enableSorting: false, + meta: { + width: '25%', + }, + }), + columnHelper.accessor('instances', { + header: 'Instances', + cell: ({ row }) => ( + + ), + enableSorting: false, + meta: { + width: '25%', + }, + }), + columnHelper.accessor('sdks', { + header: 'SDK', + cell: SdkCell, + enableSorting: false, + meta: { + width: '25%', + }, + }), + ], + [], + ); + + const table = useReactTable( + withTableState(tableState, setTableState, { + columns, + data: applications, + }), + ); + + const rows = table.getRowModel().rows; + + return ( + + + + + + } + /> + + } + > + + } + /> + + } + > + +
+ +
+
+ ({ padding: theme.spacing(0, 2, 2) })}> + 0} + show={ + + No applications found matching “ + {query} + ” + + } + elseShow={ + + No applications found matching your + criteria. + + } + /> + + } + /> +
+ ); +}; diff --git a/frontend/src/component/project/ProjectApplications/SdkCell.tsx b/frontend/src/component/project/ProjectApplications/SdkCell.tsx new file mode 100644 index 0000000000..8993c9e22d --- /dev/null +++ b/frontend/src/component/project/ProjectApplications/SdkCell.tsx @@ -0,0 +1,65 @@ +import { VFC } from 'react'; +import { ProjectApplicationSchema } from 'openapi'; +import { styled } from '@mui/material'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; +import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; + +const StyledTag = styled('div')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, +})); + +interface ISdkCellProps { + row: { + original: ProjectApplicationSchema; + }; +} + +export const SdkCell: VFC = ({ row }) => { + const { searchQuery } = useSearchHighlightContext(); + + const isHighlighted = + searchQuery.length > 0 && + row.original.sdks.some( + (sdk) => + sdk.versions.some((version) => + version.toLowerCase().includes(searchQuery.toLowerCase()), + ) || sdk.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + if (!row.original.sdks || row.original.sdks.length === 0) + return ; + + return ( + + 0 && isHighlighted} + tooltip={ + <> + {row.original.sdks.map((sdk) => ( + + + {sdk.name} + +
    + {sdk.versions.map((version) => ( +
  • + + {version} + +
  • + ))} +
+
+ ))} + + } + > + {row.original.sdks?.length === 1 + ? '1 sdk' + : `${row.original.sdks.length} sdks`} +
+
+ ); +}; diff --git a/frontend/src/hooks/api/getters/useProjectApplications/useProjectApplications.ts b/frontend/src/hooks/api/getters/useProjectApplications/useProjectApplications.ts new file mode 100644 index 0000000000..03ffb714e3 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectApplications/useProjectApplications.ts @@ -0,0 +1,82 @@ +import useSWR, { SWRConfiguration } from 'swr'; +import { useCallback } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { + GetProjectApplicationsParams, + ProjectApplicationsSchema, +} from 'openapi'; + +type UseProjectApplicationsOutput = { + loading: boolean; + error: string; + refetch: () => void; +} & ProjectApplicationsSchema; + +const fallbackData: ProjectApplicationsSchema = { + applications: [], + total: 0, +}; + +const getPrefixKey = (projectId: string) => { + return `api/admin/projects/${projectId}/applications?`; +}; + +const createProjectApplications = () => { + return ( + projectId: string, + params: GetProjectApplicationsParams, + options: SWRConfiguration = {}, + ): UseProjectApplicationsOutput => { + const { KEY, fetcher } = getProjectApplicationsFetcher( + projectId, + params, + ); + + const { data, error, mutate, isLoading } = + useSWR(KEY, fetcher, options); + + const refetch = useCallback(() => { + mutate(); + }, [mutate]); + + const returnData = data || fallbackData; + return { + ...returnData, + loading: isLoading, + error, + refetch, + }; + }; +}; + +export const DEFAULT_PAGE_LIMIT = 25; + +export const useProjectApplications = createProjectApplications(); + +const getProjectApplicationsFetcher = ( + projectId: string, + params: GetProjectApplicationsParams, +) => { + const urlSearchParams = new URLSearchParams( + Array.from( + Object.entries(params) + .filter(([_, value]) => !!value) + .map(([key, value]) => [key, value.toString()]), // TODO: parsing non-string parameters + ), + ).toString(); + const KEY = `${getPrefixKey(projectId)}${urlSearchParams}`; + const fetcher = () => { + const path = formatApiPath(KEY); + return fetch(path, { + method: 'GET', + }) + .then(handleErrorResponses('Feature search')) + .then((res) => res.json()); + }; + + return { + fetcher, + KEY, + }; +}; diff --git a/src/lib/features/project/project-store.ts b/src/lib/features/project/project-store.ts index 8d5be980bc..ac4b5a5a5a 100644 --- a/src/lib/features/project/project-store.ts +++ b/src/lib/features/project/project-store.ts @@ -637,10 +637,17 @@ class ProjectStore implements IProjectStore { .joinRaw('CROSS JOIN total') .whereBetween('rank', [offset + 1, offset + limit]); const rows = await query; - const applications = this.getAggregatedApplicationsData(rows); + if (rows.length !== 0) { + const applications = this.getAggregatedApplicationsData(rows); + return { + applications, + total: Number(rows[0].total) || 0, + }; + } + return { - applications, - total: Number(rows[0].total) || 0, + applications: [], + total: 0, }; } @@ -801,6 +808,15 @@ class ProjectStore implements IProjectStore { } }); + entriesMap.forEach((entry) => { + entry.environments.sort(); + entry.instances.sort(); + entry.sdks.forEach((sdk) => { + sdk.versions.sort(); + }); + entry.sdks.sort((a, b) => a.name.localeCompare(b.name)); + }); + return Array.from(entriesMap.values()); } }