mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
feat: project applications UI (#6260)

This commit is contained in:
parent
bd907244c4
commit
03929e3031
@ -4,12 +4,10 @@ import {
|
|||||||
IChangeRequestUpdateSegment,
|
IChangeRequestUpdateSegment,
|
||||||
IChangeRequestUpdateStrategy,
|
IChangeRequestUpdateStrategy,
|
||||||
} from 'component/changeRequest/changeRequest.types';
|
} from 'component/changeRequest/changeRequest.types';
|
||||||
import { useChangeRequestPlausibleContext } from 'component/changeRequest/ChangeRequestContext';
|
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
import { IFeatureVariant } from 'interfaces/featureToggle';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { OverwriteWarning } from './OverwriteWarning';
|
import { OverwriteWarning } from './OverwriteWarning';
|
||||||
import {
|
import {
|
||||||
getEnvVariantChangesThatWouldBeOverwritten,
|
getEnvVariantChangesThatWouldBeOverwritten,
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 { UiFlags } from 'interfaces/uiConfig';
|
||||||
import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
||||||
import { ChangeRequestPlausibleProvider } from 'component/changeRequest/ChangeRequestContext';
|
import { ChangeRequestPlausibleProvider } from 'component/changeRequest/ChangeRequestContext';
|
||||||
|
import { ProjectApplications } from '../ProjectApplications/ProjectApplications';
|
||||||
|
|
||||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -110,6 +111,12 @@ export const Project = () => {
|
|||||||
name: 'dora',
|
name: 'dora',
|
||||||
isEnterprise: true,
|
isEnterprise: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Applications',
|
||||||
|
path: `${basePath}/applications`,
|
||||||
|
name: 'applications',
|
||||||
|
flag: 'sdkReporting',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Event log',
|
title: 'Event log',
|
||||||
path: `${basePath}/logs`,
|
path: `${basePath}/logs`,
|
||||||
@ -314,6 +321,7 @@ export const Project = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path='settings/*' element={<ProjectSettings />} />
|
<Route path='settings/*' element={<ProjectSettings />} />
|
||||||
<Route path='metrics' element={<ProjectDoraMetrics />} />
|
<Route path='metrics' element={<ProjectDoraMetrics />} />
|
||||||
|
<Route path='applications' element={<ProjectApplications />} />
|
||||||
<Route path='*' element={<ProjectOverview />} />
|
<Route path='*' element={<ProjectOverview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<ImportModal
|
<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')
|
.joinRaw('CROSS JOIN total')
|
||||||
.whereBetween('rank', [offset + 1, offset + limit]);
|
.whereBetween('rank', [offset + 1, offset + limit]);
|
||||||
const rows = await query;
|
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 {
|
return {
|
||||||
applications,
|
applications: [],
|
||||||
total: Number(rows[0].total) || 0,
|
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());
|
return Array.from(entriesMap.values());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user