mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
parent
cb75a68cc3
commit
30a753b93f
@ -23,7 +23,13 @@ import {
|
|||||||
IPersonalAPIToken,
|
IPersonalAPIToken,
|
||||||
} from 'interfaces/personalAPIToken';
|
} from 'interfaces/personalAPIToken';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
import {
|
||||||
|
useTable,
|
||||||
|
SortingRule,
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout,
|
||||||
|
Column,
|
||||||
|
} from 'react-table';
|
||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
|
import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
|
||||||
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
||||||
@ -151,65 +157,69 @@ export const ServiceAccountTokens = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{
|
[
|
||||||
Header: 'Description',
|
{
|
||||||
accessor: 'description',
|
Header: 'Description',
|
||||||
Cell: HighlightCell,
|
accessor: 'description',
|
||||||
minWidth: 100,
|
Cell: HighlightCell,
|
||||||
searchable: true,
|
minWidth: 100,
|
||||||
},
|
searchable: true,
|
||||||
{
|
|
||||||
Header: 'Expires',
|
|
||||||
accessor: 'expiresAt',
|
|
||||||
Cell: ({ value }: { value: string }) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (date.getFullYear() > new Date().getFullYear() + 100) {
|
|
||||||
return <TextCell>Never</TextCell>;
|
|
||||||
}
|
|
||||||
return <DateCell value={value} />;
|
|
||||||
},
|
},
|
||||||
sortType: 'date',
|
{
|
||||||
maxWidth: 150,
|
Header: 'Expires',
|
||||||
},
|
accessor: 'expiresAt',
|
||||||
{
|
Cell: ({ value }: { value: string }) => {
|
||||||
Header: 'Created',
|
const date = new Date(value);
|
||||||
accessor: 'createdAt',
|
if (
|
||||||
Cell: DateCell,
|
date.getFullYear() >
|
||||||
sortType: 'date',
|
new Date().getFullYear() + 100
|
||||||
maxWidth: 150,
|
) {
|
||||||
},
|
return <TextCell>Never</TextCell>;
|
||||||
{
|
}
|
||||||
Header: 'Last seen',
|
return <DateCell value={value} />;
|
||||||
accessor: 'seenAt',
|
},
|
||||||
Cell: TimeAgoCell,
|
sortType: 'date',
|
||||||
sortType: 'date',
|
maxWidth: 150,
|
||||||
maxWidth: 150,
|
},
|
||||||
},
|
{
|
||||||
{
|
Header: 'Created',
|
||||||
Header: 'Actions',
|
accessor: 'createdAt',
|
||||||
id: 'Actions',
|
Cell: DateCell,
|
||||||
align: 'center',
|
sortType: 'date',
|
||||||
Cell: ({ row: { original: rowToken } }: any) => (
|
maxWidth: 150,
|
||||||
<ActionCell>
|
},
|
||||||
<Tooltip title="Delete token" arrow describeChild>
|
{
|
||||||
<span>
|
Header: 'Last seen',
|
||||||
<IconButton
|
accessor: 'seenAt',
|
||||||
onClick={() => {
|
Cell: TimeAgoCell,
|
||||||
setSelectedToken(rowToken);
|
sortType: 'date',
|
||||||
setDeleteOpen(true);
|
maxWidth: 150,
|
||||||
}}
|
},
|
||||||
>
|
{
|
||||||
<Delete />
|
Header: 'Actions',
|
||||||
</IconButton>
|
id: 'Actions',
|
||||||
</span>
|
align: 'center',
|
||||||
</Tooltip>
|
Cell: ({ row: { original: rowToken } }: any) => (
|
||||||
</ActionCell>
|
<ActionCell>
|
||||||
),
|
<Tooltip title="Delete token" arrow describeChild>
|
||||||
maxWidth: 100,
|
<span>
|
||||||
disableSortBy: true,
|
<IconButton
|
||||||
},
|
onClick={() => {
|
||||||
],
|
setSelectedToken(rowToken);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
] as Column<IPersonalAPIToken>[],
|
||||||
[setSelectedToken, setDeleteOpen]
|
[setSelectedToken, setDeleteOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,23 +1,20 @@
|
|||||||
import { VFC } from 'react';
|
|
||||||
import { TableHead, TableRow } from '@mui/material';
|
import { TableHead, TableRow } from '@mui/material';
|
||||||
import { HeaderGroup } from 'react-table';
|
import { HeaderGroup } from 'react-table';
|
||||||
import { CellSortable } from './CellSortable/CellSortable';
|
import { CellSortable } from './CellSortable/CellSortable';
|
||||||
|
|
||||||
interface ISortableTableHeaderProps {
|
export const SortableTableHeader = <T extends object>({
|
||||||
headerGroups: HeaderGroup<object>[];
|
|
||||||
className?: string;
|
|
||||||
flex?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SortableTableHeader: VFC<ISortableTableHeaderProps> = ({
|
|
||||||
headerGroups,
|
headerGroups,
|
||||||
className,
|
className,
|
||||||
flex,
|
flex,
|
||||||
|
}: {
|
||||||
|
headerGroups: HeaderGroup<T>[];
|
||||||
|
className?: string;
|
||||||
|
flex?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<TableHead className={className}>
|
<TableHead className={className}>
|
||||||
{headerGroups.map(headerGroup => (
|
{headerGroups.map(headerGroup => (
|
||||||
<TableRow {...headerGroup.getHeaderGroupProps()}>
|
<TableRow {...headerGroup.getHeaderGroupProps()}>
|
||||||
{headerGroup.headers.map((column: HeaderGroup) => {
|
{headerGroup.headers.map((column: HeaderGroup<T>) => {
|
||||||
const content = column.render('Header');
|
const content = column.render('Header');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, VFC } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import {
|
import {
|
||||||
SortableTableHeader,
|
SortableTableHeader,
|
||||||
@ -10,13 +10,6 @@ import {
|
|||||||
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
|
import { useVirtualizedRange } from 'hooks/useVirtualizedRange';
|
||||||
import { HeaderGroup, Row } from 'react-table';
|
import { HeaderGroup, Row } from 'react-table';
|
||||||
|
|
||||||
interface IVirtualizedTableProps {
|
|
||||||
rowHeight?: number;
|
|
||||||
headerGroups: HeaderGroup<object>[];
|
|
||||||
rows: Row<object>[];
|
|
||||||
prepareRow: (row: Row) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* READ BEFORE USE
|
* READ BEFORE USE
|
||||||
*
|
*
|
||||||
@ -27,11 +20,16 @@ interface IVirtualizedTableProps {
|
|||||||
* Remember to add `useFlexLayout` to `useTable`
|
* Remember to add `useFlexLayout` to `useTable`
|
||||||
* (more at: https://react-table-v7.tanstack.com/docs/api/useFlexLayout)
|
* (more at: https://react-table-v7.tanstack.com/docs/api/useFlexLayout)
|
||||||
*/
|
*/
|
||||||
export const VirtualizedTable: VFC<IVirtualizedTableProps> = ({
|
export const VirtualizedTable = <T extends object>({
|
||||||
rowHeight: rowHeightOverride,
|
rowHeight: rowHeightOverride,
|
||||||
headerGroups,
|
headerGroups,
|
||||||
rows,
|
rows,
|
||||||
prepareRow,
|
prepareRow,
|
||||||
|
}: {
|
||||||
|
rowHeight?: number;
|
||||||
|
headerGroups: HeaderGroup<T>[];
|
||||||
|
rows: Row<T>[];
|
||||||
|
prepareRow: (row: Row<T>) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const rowHeight = useMemo(
|
const rowHeight = useMemo(
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
|
import { createRef, useState } from 'react';
|
||||||
import { styled, Typography, Box } from '@mui/material';
|
import { styled, Typography, Box } from '@mui/material';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||||
import { useExportApi } from 'hooks/api/actions/useExportApi/useExportApi';
|
import { useExportApi } from 'hooks/api/actions/useExportApi/useExportApi';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { FeatureSchema } from 'openapi';
|
import type { FeatureSchema } from 'openapi';
|
||||||
|
|
||||||
import { createRef, useEffect, useState } from 'react';
|
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
interface IExportDialogProps {
|
interface IExportDialogProps {
|
||||||
showExportDialog: boolean;
|
showExportDialog: boolean;
|
||||||
data: FeatureSchema[];
|
data: Pick<FeatureSchema, 'name'>[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
environments: string[];
|
environments: string[];
|
||||||
}
|
}
|
||||||
@ -37,13 +37,6 @@ export const ExportDialog = ({
|
|||||||
label: env,
|
label: env,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const getPayload = () => {
|
|
||||||
return {
|
|
||||||
features: data.map(feature => feature.name),
|
|
||||||
environment: selected,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = (json: any) => {
|
const downloadFile = (json: any) => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
ref.current?.appendChild(link);
|
ref.current?.appendChild(link);
|
||||||
@ -65,7 +58,10 @@ export const ExportDialog = ({
|
|||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = getPayload();
|
const payload = {
|
||||||
|
features: data.map(feature => feature.name),
|
||||||
|
environment: selected,
|
||||||
|
};
|
||||||
const res = await createExport(payload);
|
const res = await createExport(payload);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
downloadFile(body);
|
downloadFile(body);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Checkbox,
|
||||||
IconButton,
|
IconButton,
|
||||||
styled,
|
styled,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -8,7 +9,14 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Add } from '@mui/icons-material';
|
import { Add } from '@mui/icons-material';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
import {
|
||||||
|
SortingRule,
|
||||||
|
useFlexLayout,
|
||||||
|
useSortBy,
|
||||||
|
useRowSelect,
|
||||||
|
useTable,
|
||||||
|
} from 'react-table';
|
||||||
|
import type { FeatureSchema } from 'openapi';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
@ -50,12 +58,13 @@ import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
|
|||||||
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
||||||
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
||||||
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
||||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
|
||||||
import { flexRow } from 'themes/themeStyles';
|
import { flexRow } from 'themes/themeStyles';
|
||||||
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
|
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
|
||||||
import FileDownload from '@mui/icons-material/FileDownload';
|
import FileDownload from '@mui/icons-material/FileDownload';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||||
|
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
|
||||||
|
import { SelectionActionsBar } from './SelectionActionsBar/SelectionActionsBar';
|
||||||
|
|
||||||
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@ -96,7 +105,7 @@ type ListItemType = Pick<
|
|||||||
someEnabledEnvironmentHasVariants: boolean;
|
someEnabledEnvironmentHasVariants: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const staticColumns = ['Actions', 'name', 'favorite'];
|
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
||||||
|
|
||||||
const defaultSort: SortingRule<string> & {
|
const defaultSort: SortingRule<string> & {
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
@ -223,8 +232,31 @@ export const ProjectFeatureToggles = ({
|
|||||||
[projectId, refetch]
|
[projectId, refetch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showTagsColumn = useMemo(
|
||||||
|
() => features.some(feature => feature?.tags?.length),
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
...(uiConfig?.flags?.bulkOperations
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'Select',
|
||||||
|
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||||
|
<Checkbox {...getToggleAllRowsSelectedProps()} />
|
||||||
|
),
|
||||||
|
Cell: ({ row }: any) => (
|
||||||
|
<RowSelectCell
|
||||||
|
{...row?.getToggleRowSelectedProps?.()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
maxWidth: 50,
|
||||||
|
disableSortBy: true,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'favorite',
|
id: 'favorite',
|
||||||
Header: (
|
Header: (
|
||||||
@ -242,6 +274,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
),
|
),
|
||||||
maxWidth: 50,
|
maxWidth: 50,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
|
hideInMenu: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Seen',
|
Header: 'Seen',
|
||||||
@ -271,18 +304,21 @@ export const ProjectFeatureToggles = ({
|
|||||||
sortType: 'alphanumeric',
|
sortType: 'alphanumeric',
|
||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
...(showTagsColumn
|
||||||
id: 'tags',
|
? [
|
||||||
Header: 'Tags',
|
{
|
||||||
accessor: (row: IFeatureToggleListItem) =>
|
id: 'tags',
|
||||||
row.tags
|
Header: 'Tags',
|
||||||
?.map(({ type, value }) => `${type}:${value}`)
|
accessor: (row: IFeatureToggleListItem) =>
|
||||||
.join('\n') || '',
|
row.tags
|
||||||
Cell: FeatureTagCell,
|
?.map(({ type, value }) => `${type}:${value}`)
|
||||||
width: 80,
|
.join('\n') || '',
|
||||||
hideInMenu: true,
|
Cell: FeatureTagCell,
|
||||||
searchable: true,
|
width: 80,
|
||||||
},
|
searchable: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
Header: 'Created',
|
Header: 'Created',
|
||||||
accessor: 'createdAt',
|
accessor: 'createdAt',
|
||||||
@ -343,6 +379,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
|
hideInMenu: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[projectId, environments, loading, onToggle]
|
[projectId, environments, loading, onToggle]
|
||||||
@ -397,7 +434,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
environments: {
|
environments: {
|
||||||
production: { name: 'production', enabled: false },
|
production: { name: 'production', enabled: false },
|
||||||
},
|
},
|
||||||
}) as object[];
|
}) as FeatureSchema[];
|
||||||
}
|
}
|
||||||
return searchedData;
|
return searchedData;
|
||||||
}, [loading, searchedData]);
|
}, [loading, searchedData]);
|
||||||
@ -438,6 +475,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
hiddenColumns,
|
hiddenColumns,
|
||||||
|
selectedRowIds: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[environments] // eslint-disable-line react-hooks/exhaustive-deps
|
[environments] // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@ -449,7 +487,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
allColumns,
|
allColumns,
|
||||||
headerGroups,
|
headerGroups,
|
||||||
rows,
|
rows,
|
||||||
state: { sortBy, hiddenColumns },
|
state: { selectedRowIds, sortBy, hiddenColumns },
|
||||||
prepareRow,
|
prepareRow,
|
||||||
setHiddenColumns,
|
setHiddenColumns,
|
||||||
} = useTable(
|
} = useTable(
|
||||||
@ -464,18 +502,8 @@ export const ProjectFeatureToggles = ({
|
|||||||
getRowId,
|
getRowId,
|
||||||
},
|
},
|
||||||
useFlexLayout,
|
useFlexLayout,
|
||||||
useSortBy
|
useSortBy,
|
||||||
);
|
useRowSelect
|
||||||
|
|
||||||
useConditionallyHiddenColumns(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
condition: !features.some(({ tags }) => tags?.length),
|
|
||||||
columns: ['tags'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
setHiddenColumns,
|
|
||||||
columns
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -559,7 +587,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
)}
|
)}
|
||||||
show={
|
show={
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title="Export current selection"
|
title="Export toggles visible in the table below"
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -686,6 +714,10 @@ export const ProjectFeatureToggles = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SelectionActionsBar
|
||||||
|
selectedIds={Object.keys(selectedRowIds)}
|
||||||
|
data={features}
|
||||||
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import { Box, Checkbox, styled } from '@mui/material';
|
||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
interface IRowSelectCellProps {
|
||||||
|
onChange: () => void;
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledBoxCell = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const RowSelectCell: FC<IRowSelectCellProps> = ({
|
||||||
|
onChange,
|
||||||
|
checked,
|
||||||
|
title,
|
||||||
|
}) => (
|
||||||
|
<StyledBoxCell>
|
||||||
|
<Checkbox onChange={onChange} title={title} checked={checked} />
|
||||||
|
</StyledBoxCell>
|
||||||
|
);
|
@ -0,0 +1,120 @@
|
|||||||
|
import { useMemo, useState, VFC } from 'react';
|
||||||
|
import { Box, Button, Paper, styled, Typography } from '@mui/material';
|
||||||
|
import { Archive, FileDownload, Label, WatchLater } from '@mui/icons-material';
|
||||||
|
import type { FeatureSchema } from 'openapi';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
interface ISelectionActionsBarProps {
|
||||||
|
selectedIds: string[];
|
||||||
|
data: FeatureSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledContainer = styled(Box)(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledBar = styled(Paper)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
padding: theme.spacing(2, 3),
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
border: `1px solid ${theme.palette.secondary.main}`,
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
columnGap: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCount = styled('span')(({ theme }) => ({
|
||||||
|
background: theme.palette.secondary.main,
|
||||||
|
color: theme.palette.background.paper,
|
||||||
|
padding: theme.spacing(0.5, 1),
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledText = styled(Typography)(({ theme }) => ({
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const SelectionActionsBar: VFC<ISelectionActionsBarProps> = ({
|
||||||
|
selectedIds,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||||
|
const selectedData = useMemo(
|
||||||
|
() => data.filter(d => selectedIds.includes(d.name)),
|
||||||
|
[data, selectedIds]
|
||||||
|
);
|
||||||
|
const environments = useMemo(() => {
|
||||||
|
const envs = selectedData
|
||||||
|
.flatMap(d => d.environments)
|
||||||
|
.map(env => env?.name)
|
||||||
|
.filter(env => env !== undefined) as string[];
|
||||||
|
return Array.from(new Set(envs));
|
||||||
|
}, [selectedData]);
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledBar elevation={4}>
|
||||||
|
<StyledText>
|
||||||
|
<StyledCount>{selectedIds.length}</StyledCount>
|
||||||
|
 selected
|
||||||
|
</StyledText>
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
startIcon={<Archive />}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
startIcon={<WatchLater />}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Mark as stale
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<FileDownload />}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowExportDialog(true)}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
startIcon={<Label />}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</Button>
|
||||||
|
</StyledBar>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
|
||||||
|
show={
|
||||||
|
<ExportDialog
|
||||||
|
showExportDialog={showExportDialog}
|
||||||
|
data={selectedData}
|
||||||
|
onClose={() => setShowExportDialog(false)}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -28,7 +28,13 @@ import {
|
|||||||
} from 'interfaces/personalAPIToken';
|
} from 'interfaces/personalAPIToken';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
import {
|
||||||
|
useTable,
|
||||||
|
SortingRule,
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout,
|
||||||
|
Column,
|
||||||
|
} from 'react-table';
|
||||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { CreatePersonalAPIToken } from './CreatePersonalAPIToken/CreatePersonalAPIToken';
|
import { CreatePersonalAPIToken } from './CreatePersonalAPIToken/CreatePersonalAPIToken';
|
||||||
@ -104,65 +110,69 @@ export const PersonalAPITokensTab = () => {
|
|||||||
const [selectedToken, setSelectedToken] = useState<IPersonalAPIToken>();
|
const [selectedToken, setSelectedToken] = useState<IPersonalAPIToken>();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{
|
[
|
||||||
Header: 'Description',
|
{
|
||||||
accessor: 'description',
|
Header: 'Description',
|
||||||
Cell: HighlightCell,
|
accessor: 'description',
|
||||||
minWidth: 100,
|
Cell: HighlightCell,
|
||||||
searchable: true,
|
minWidth: 100,
|
||||||
},
|
searchable: true,
|
||||||
{
|
|
||||||
Header: 'Expires',
|
|
||||||
accessor: 'expiresAt',
|
|
||||||
Cell: ({ value }: { value: string }) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (date.getFullYear() > new Date().getFullYear() + 100) {
|
|
||||||
return <TextCell>Never</TextCell>;
|
|
||||||
}
|
|
||||||
return <DateCell value={value} />;
|
|
||||||
},
|
},
|
||||||
sortType: 'date',
|
{
|
||||||
maxWidth: 150,
|
Header: 'Expires',
|
||||||
},
|
accessor: 'expiresAt',
|
||||||
{
|
Cell: ({ value }: { value: string }) => {
|
||||||
Header: 'Created',
|
const date = new Date(value);
|
||||||
accessor: 'createdAt',
|
if (
|
||||||
Cell: DateCell,
|
date.getFullYear() >
|
||||||
sortType: 'date',
|
new Date().getFullYear() + 100
|
||||||
maxWidth: 150,
|
) {
|
||||||
},
|
return <TextCell>Never</TextCell>;
|
||||||
{
|
}
|
||||||
Header: 'Last seen',
|
return <DateCell value={value} />;
|
||||||
accessor: 'seenAt',
|
},
|
||||||
Cell: TimeAgoCell,
|
sortType: 'date',
|
||||||
sortType: 'date',
|
maxWidth: 150,
|
||||||
maxWidth: 150,
|
},
|
||||||
},
|
{
|
||||||
{
|
Header: 'Created',
|
||||||
Header: 'Actions',
|
accessor: 'createdAt',
|
||||||
id: 'Actions',
|
Cell: DateCell,
|
||||||
align: 'center',
|
sortType: 'date',
|
||||||
Cell: ({ row: { original: rowToken } }: any) => (
|
maxWidth: 150,
|
||||||
<ActionCell>
|
},
|
||||||
<Tooltip title="Delete token" arrow describeChild>
|
{
|
||||||
<span>
|
Header: 'Last seen',
|
||||||
<IconButton
|
accessor: 'seenAt',
|
||||||
onClick={() => {
|
Cell: TimeAgoCell,
|
||||||
setSelectedToken(rowToken);
|
sortType: 'date',
|
||||||
setDeleteOpen(true);
|
maxWidth: 150,
|
||||||
}}
|
},
|
||||||
>
|
{
|
||||||
<Delete />
|
Header: 'Actions',
|
||||||
</IconButton>
|
id: 'Actions',
|
||||||
</span>
|
align: 'center',
|
||||||
</Tooltip>
|
Cell: ({ row: { original: rowToken } }: any) => (
|
||||||
</ActionCell>
|
<ActionCell>
|
||||||
),
|
<Tooltip title="Delete token" arrow describeChild>
|
||||||
maxWidth: 100,
|
<span>
|
||||||
disableSortBy: true,
|
<IconButton
|
||||||
},
|
onClick={() => {
|
||||||
],
|
setSelectedToken(rowToken);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
] as Column<IPersonalAPIToken>[],
|
||||||
[setSelectedToken, setDeleteOpen]
|
[setSelectedToken, setDeleteOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -186,7 +196,7 @@ export const PersonalAPITokensTab = () => {
|
|||||||
prepareRow,
|
prepareRow,
|
||||||
state: { sortBy },
|
state: { sortBy },
|
||||||
setHiddenColumns,
|
setHiddenColumns,
|
||||||
} = useTable(
|
} = useTable<IPersonalAPIToken>(
|
||||||
{
|
{
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { PatsSchema } from 'openapi';
|
||||||
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
|
||||||
export interface IUsePersonalAPITokensOutput {
|
export interface IUsePersonalAPITokensOutput {
|
||||||
@ -10,20 +11,15 @@ export interface IUsePersonalAPITokensOutput {
|
|||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePersonalAPITokens = (
|
export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => {
|
||||||
userId?: number
|
const { data, error, mutate } = useSWR<PatsSchema>(
|
||||||
): IUsePersonalAPITokensOutput => {
|
formatApiPath('api/admin/user/tokens'),
|
||||||
const { data, error, mutate } = useSWR(
|
|
||||||
formatApiPath(
|
|
||||||
userId
|
|
||||||
? `api/admin/user-admin/${userId}/pat`
|
|
||||||
: 'api/admin/user/tokens'
|
|
||||||
),
|
|
||||||
fetcher
|
fetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokens: data ? data.pats : undefined,
|
// FIXME: schema issue
|
||||||
|
tokens: data ? (data.pats as any) : undefined,
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
refetchTokens: () => mutate(),
|
refetchTokens: () => mutate(),
|
||||||
error,
|
error,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
import { PatsSchema } from 'openapi';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
||||||
@ -11,18 +12,21 @@ export interface IUseServiceAccountTokensOutput {
|
|||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServiceAccountTokens = (id: number) => {
|
export const useServiceAccountTokens = (
|
||||||
|
id: number
|
||||||
|
): IUseServiceAccountTokensOutput => {
|
||||||
const { isEnterprise } = useUiConfig();
|
const { isEnterprise } = useUiConfig();
|
||||||
|
|
||||||
const { data, error, mutate } = useConditionalSWR(
|
const { data, error, mutate } = useConditionalSWR<PatsSchema>(
|
||||||
isEnterprise(),
|
isEnterprise(),
|
||||||
{ tokens: [] },
|
{ pats: [] },
|
||||||
formatApiPath(`api/admin/service-account/${id}/token`),
|
formatApiPath(`api/admin/service-account/${id}/token`),
|
||||||
fetcher
|
fetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokens: data ? data.pats : undefined,
|
// FIXME: schema issue
|
||||||
|
tokens: data ? (data.pats as any) : undefined,
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
refetchTokens: () => mutate(),
|
refetchTokens: () => mutate(),
|
||||||
error,
|
error,
|
||||||
|
@ -7,7 +7,7 @@ import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
|||||||
import useUiConfig from '../useUiConfig/useUiConfig';
|
import useUiConfig from '../useUiConfig/useUiConfig';
|
||||||
|
|
||||||
export const useServiceAccounts = () => {
|
export const useServiceAccounts = () => {
|
||||||
const { uiConfig, isEnterprise } = useUiConfig();
|
const { isEnterprise } = useUiConfig();
|
||||||
|
|
||||||
const { data, error, mutate } = useConditionalSWR(
|
const { data, error, mutate } = useConditionalSWR(
|
||||||
isEnterprise(),
|
isEnterprise(),
|
||||||
|
@ -1,29 +1,34 @@
|
|||||||
interface IUseSearchOutput {
|
import { useCallback, useMemo } from 'react';
|
||||||
getSearchText: (input: string) => string;
|
|
||||||
data: any[];
|
|
||||||
getSearchContext: () => IGetSearchContextOutput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGetSearchContextOutput {
|
export type IGetSearchContextOutput<T extends any = any> = {
|
||||||
data: any[];
|
data: T[];
|
||||||
columns: any[];
|
columns: any[];
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useSearch = (
|
type IUseSearchOutput<T extends any> = {
|
||||||
|
getSearchText: (input: string) => string;
|
||||||
|
data: T[];
|
||||||
|
getSearchContext: () => IGetSearchContextOutput<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSearch = <T extends any>(
|
||||||
columns: any[],
|
columns: any[],
|
||||||
searchValue: string,
|
searchValue: string,
|
||||||
data: any[]
|
data: T[]
|
||||||
): IUseSearchOutput => {
|
): IUseSearchOutput<T> => {
|
||||||
const getSearchText = getSearchTextGenerator(columns);
|
const getSearchText = useCallback(
|
||||||
|
(value: string) => getSearchTextGenerator(columns)(value),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
const getSearchContext = () => {
|
const getSearchContext = useCallback(() => {
|
||||||
return { data, searchValue, columns };
|
return { data, searchValue, columns };
|
||||||
};
|
}, [data, searchValue, columns]);
|
||||||
|
|
||||||
if (!searchValue) return { data, getSearchText, getSearchContext };
|
const search = useMemo(() => {
|
||||||
|
if (!searchValue) return data;
|
||||||
|
|
||||||
const search = () => {
|
|
||||||
const filteredData = filter(columns, searchValue, data);
|
const filteredData = filter(columns, searchValue, data);
|
||||||
const searchedData = searchInFilteredData(
|
const searchedData = searchInFilteredData(
|
||||||
columns,
|
columns,
|
||||||
@ -32,9 +37,9 @@ export const useSearch = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return searchedData;
|
return searchedData;
|
||||||
};
|
}, [columns, searchValue, data, getSearchText]);
|
||||||
|
|
||||||
return { data: search(), getSearchText, getSearchContext };
|
return { data: search, getSearchText, getSearchContext };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filter = (columns: any[], searchValue: string, data: any[]) => {
|
export const filter = (columns: any[], searchValue: string, data: any[]) => {
|
||||||
@ -57,10 +62,10 @@ export const filter = (columns: any[], searchValue: string, data: any[]) => {
|
|||||||
return filteredDataSet;
|
return filteredDataSet;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchInFilteredData = (
|
export const searchInFilteredData = <T extends any>(
|
||||||
columns: any[],
|
columns: any[],
|
||||||
searchValue: string,
|
searchValue: string,
|
||||||
filteredData: any[]
|
filteredData: T[]
|
||||||
) => {
|
) => {
|
||||||
const searchableColumns = columns.filter(
|
const searchableColumns = columns.filter(
|
||||||
column => column.searchable && column.accessor
|
column => column.searchable && column.accessor
|
||||||
|
@ -40,6 +40,7 @@ process.nextTick(async () => {
|
|||||||
responseTimeWithAppNameKillSwitch: false,
|
responseTimeWithAppNameKillSwitch: false,
|
||||||
featuresExportImport: true,
|
featuresExportImport: true,
|
||||||
newProjectOverview: true,
|
newProjectOverview: true,
|
||||||
|
bulkOperations: true,
|
||||||
projectStatusApi: true,
|
projectStatusApi: true,
|
||||||
showProjectApiAccess: true,
|
showProjectApiAccess: true,
|
||||||
projectScopedSegments: true,
|
projectScopedSegments: true,
|
||||||
|
Loading…
Reference in New Issue
Block a user