1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: project applications UI (#6260)

![image](https://github.com/Unleash/unleash/assets/964450/a1129857-820c-4e93-ac59-ef5f4743d774)
This commit is contained in:
Jaanus Sellin 2024-02-19 09:50:53 +02:00 committed by GitHub
parent bd907244c4
commit 03929e3031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 436 additions and 6 deletions

View File

@ -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,

View File

@ -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';

View File

@ -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<T> {
row: T;
field: keyof T;
singularLabel: string;
pluralLabel?: string;
}
export const StringArrayCell: VFC<IArrayFieldCellProps<any>> = ({
row,
field,
singularLabel,
pluralLabel,
}) => {
const { searchQuery } = useSearchHighlightContext();
const fieldValue = row[field];
if (!Array.isArray(fieldValue) || fieldValue.length === 0)
return <TextCell />;
const labelForMultiple = pluralLabel || `${singularLabel}s`;
return (
<TextCell>
<TooltipLink
highlighted={
searchQuery.length > 0 &&
fieldValue.some((item: string) =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
}
tooltip={
<>
{fieldValue.map((item: string) => (
<StyledTag key={item}>
<Highlighter search={searchQuery}>
{item}
</Highlighter>
</StyledTag>
))}
</>
}
>
{fieldValue.length === 1
? `1 ${singularLabel}`
: `${fieldValue.length} ${labelForMultiple}`}
</TooltipLink>
</TextCell>
);
};

View File

@ -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 = () => {
/>
<Route path='settings/*' element={<ProjectSettings />} />
<Route path='metrics' element={<ProjectDoraMetrics />} />
<Route path='applications' element={<ProjectApplications />} />
<Route path='*' element={<ProjectOverview />} />
</Routes>
<ImportModal

View File

@ -0,0 +1,202 @@
import { useMemo } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { Box, useMediaQuery } from '@mui/material';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { PaginatedTable, TablePlaceholder } from 'component/common/Table';
import theme from 'themes/theme';
import {
encodeQueryParams,
NumberParam,
StringParam,
withDefault,
} from 'use-query-params';
import { usePersistentTableState } from 'hooks/usePersistentTableState';
import useLoading from 'hooks/useLoading';
import { createColumnHelper, useReactTable } from '@tanstack/react-table';
import { withTableState } from 'utils/withTableState';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ProjectApplicationSchema } from '../../../openapi';
import mapValues from 'lodash.mapvalues';
import {
useProjectApplications,
DEFAULT_PAGE_LIMIT,
} from 'hooks/api/getters/useProjectApplications/useProjectApplications';
import { StringArrayCell } from 'component/common/Table/cells/StringArrayCell';
import { SdkCell } from './SdkCell';
const columnHelper = createColumnHelper<ProjectApplicationSchema>();
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 }) => (
<LinkCell
title={row.original.name}
to={`/applications/${row.original.name}`}
/>
),
meta: {
width: '25%',
},
}),
columnHelper.accessor('environments', {
header: 'Environments',
cell: ({ row }) => (
<StringArrayCell
row={row.original}
field={'environments'}
singularLabel={'environment'}
/>
),
enableSorting: false,
meta: {
width: '25%',
},
}),
columnHelper.accessor('instances', {
header: 'Instances',
cell: ({ row }) => (
<StringArrayCell
row={row.original}
field={'instances'}
singularLabel={'instance'}
/>
),
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 (
<PageContent
disableLoading={true}
bodyClass='no-padding'
header={
<PageHeader
title='Project applications'
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
placeholder='Search'
expandable
initialValue={query || ''}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={query || ''}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={query || ''}>
<div ref={bodyLoadingRef}>
<PaginatedTable tableInstance={table} totalItems={total} />
</div>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<Box sx={(theme) => ({ padding: theme.spacing(0, 2, 2) })}>
<ConditionallyRender
condition={(query || '')?.length > 0}
show={
<TablePlaceholder>
No applications found matching &ldquo;
{query}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No applications found matching your
criteria.
</TablePlaceholder>
}
/>
</Box>
}
/>
</PageContent>
);
};

View File

@ -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<ISdkCellProps> = ({ 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 <TextCell />;
return (
<TextCell>
<TooltipLink
highlighted={searchQuery.length > 0 && isHighlighted}
tooltip={
<>
{row.original.sdks.map((sdk) => (
<StyledTag key={sdk.name}>
<Highlighter search={searchQuery}>
{sdk.name}
</Highlighter>
<ul>
{sdk.versions.map((version) => (
<li key={version}>
<Highlighter search={searchQuery}>
{version}
</Highlighter>
</li>
))}
</ul>
</StyledTag>
))}
</>
}
>
{row.original.sdks?.length === 1
? '1 sdk'
: `${row.original.sdks.length} sdks`}
</TooltipLink>
</TextCell>
);
};

View File

@ -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<ProjectApplicationsSchema>(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,
};
};

View File

@ -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());
}
}