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:
parent
bd907244c4
commit
03929e3031
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -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 “
|
||||
{query}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No applications found matching your
|
||||
criteria.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user