1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00

feat: revive features (#3344)

This commit is contained in:
Jaanus Sellin 2023-03-17 20:21:13 +02:00 committed by GitHub
parent 2c2da4ad3f
commit d28e65b94c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 307 additions and 175 deletions

View File

@ -16,12 +16,12 @@ import { useNavigate } from 'react-router-dom';
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
import { useEnvironments } from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { AddonMultiSelector } from './AddonMultiSelector/AddonMultiSelector';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import PermissionButton from '../../common/PermissionButton/PermissionButton';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import {
CREATE_ADDON,
UPDATE_ADDON,

View File

@ -0,0 +1,54 @@
import { FC } from 'react';
import { Button } from '@mui/material';
import { Undo } from '@mui/icons-material';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import useToast from 'hooks/useToast';
interface IArchiveBatchActionsProps {
selectedIds: string[];
projectId: string;
}
export const ArchiveBatchActions: FC<IArchiveBatchActionsProps> = ({
selectedIds,
projectId,
}) => {
const { reviveFeatures } = useProjectApi();
const { setToastData, setToastApiError } = useToast();
const { refetchArchived } = useFeaturesArchive(projectId);
const onRevive = async () => {
try {
await reviveFeatures(projectId, selectedIds);
await refetchArchived();
setToastData({
type: 'success',
title: "And we're back!",
text: 'The feature toggles have been revived.',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<>
<PermissionHOC projectId={projectId} permission={UPDATE_FEATURE}>
{({ hasAccess }) => (
<Button
disabled={!hasAccess}
startIcon={<Undo />}
variant="outlined"
size="small"
onClick={onRevive}
>
Revive
</Button>
)}
</PermissionHOC>
</>
);
};

View File

@ -1,9 +1,15 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import {
SortingRule,
useFlexLayout,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useMediaQuery } from '@mui/material';
import { Checkbox, useMediaQuery } from '@mui/material';
import { sortTypes } from 'utils/sortTypes';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
@ -27,6 +33,10 @@ import { useSearchParams } from 'react-router-dom';
import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { RowSelectCell } from '../../project/Project/ProjectFeatureToggles/RowSelectCell/RowSelectCell';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ArchiveBatchActions } from './ArchiveBatchActions';
export interface IFeaturesArchiveTableProps {
archivedFeatures: FeatureSchema[];
@ -54,6 +64,7 @@ export const ArchiveTable = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletedFeature, setDeletedFeature] = useState<IFeatureToggle>();
@ -84,6 +95,24 @@ export const ArchiveTable = ({
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,
},
]
: []),
{
Header: 'Seen',
width: 85,
@ -203,12 +232,15 @@ export const ArchiveTable = ({
},
],
hiddenColumns: ['description'],
selectedRowIds: {},
}));
const getRowId = useCallback((row: any) => row.name, []);
const {
headerGroups,
rows,
state: { sortBy },
state: { sortBy, selectedRowIds },
prepareRow,
setHiddenColumns,
} = useTable(
@ -220,9 +252,11 @@ export const ArchiveTable = ({
autoResetHiddenColumns: false,
disableSortRemove: true,
autoResetSortBy: false,
getRowId,
},
useFlexLayout,
useSortBy
useSortBy,
useRowSelect
);
useConditionallyHiddenColumns(
@ -312,6 +346,19 @@ export const ArchiveTable = ({
setOpen={setDeleteModalOpen}
refetch={refetch}
/>
<ConditionallyRender
condition={Boolean(projectId)}
show={
<BatchSelectionActionsBar
selectedIds={Object.keys(selectedRowIds)}
>
<ArchiveBatchActions
selectedIds={Object.keys(selectedRowIds)}
projectId={projectId!}
/>
</BatchSelectionActionsBar>
}
/>
</PageContent>
);
};

View File

@ -25,7 +25,7 @@ import AccessContext from 'contexts/AccessContext';
import { ChangeRequestComment } from './ChangeRequestComments/ChangeRequestComment';
import { AddCommentField } from './ChangeRequestComments/AddCommentField';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useChangeRequestsEnabled } from '../../../hooks/useChangeRequestsEnabled';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { changesCount } from '../changesCount';

View File

@ -0,0 +1,61 @@
import { FC } from 'react';
import { Box, Paper, styled, Typography } from '@mui/material';
interface IBatchSelectionActionsBarProps {
selectedIds: string[];
}
const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
width: '100%',
flexWrap: 'wrap',
}));
const StyledBar = styled(Paper)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
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,
gap: theme.spacing(1),
flexWrap: 'wrap',
}));
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 }) => ({
paddingRight: theme.spacing(2),
marginRight: 'auto',
}));
export const BatchSelectionActionsBar: FC<IBatchSelectionActionsBarProps> = ({
selectedIds,
children,
}) => {
if (selectedIds.length === 0) {
return null;
}
return (
<StyledContainer>
<StyledBar elevation={4}>
<StyledText>
<StyledCount>{selectedIds.length}</StyledCount>
&ensp;selected
</StyledText>
{children}
</StyledBar>
</StyledContainer>
);
};

View File

@ -1,6 +1,6 @@
import PermissionButton, {
IPermissionButtonProps,
} from '../PermissionButton/PermissionButton';
} from 'component/common/PermissionButton/PermissionButton';
interface ICreateButtonProps extends IPermissionButtonProps {
name: string;

View File

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import PermissionButton from '../PermissionButton/PermissionButton';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { formatCreateStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { styled } from '@mui/material';

View File

@ -1,8 +1,8 @@
import React from 'react';
import { useMediaQuery } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PermissionButton from '../PermissionButton/PermissionButton';
import PermissionIconButton from '../PermissionIconButton/PermissionIconButton';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ITooltipResolverProps } from '../TooltipResolver/TooltipResolver';
interface IResponsiveButtonProps {

View File

@ -1,6 +1,6 @@
import PermissionButton, {
IPermissionButtonProps,
} from '../PermissionButton/PermissionButton';
} from 'component/common/PermissionButton/PermissionButton';
export const UpdateButton = ({ ...rest }: IPermissionButtonProps) => {
return (

View File

@ -64,7 +64,8 @@ import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
import { SelectionActionsBar } from './SelectionActionsBar/SelectionActionsBar';
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ProjectFeaturesBatchActions } from './SelectionActionsBar/ProjectFeaturesBatchActions';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
@ -714,11 +715,13 @@ export const ProjectFeatureToggles = ({
/>
}
/>
<SelectionActionsBar
selectedIds={Object.keys(selectedRowIds)}
data={features}
projectId={projectId}
/>
<BatchSelectionActionsBar selectedIds={Object.keys(selectedRowIds)}>
<ProjectFeaturesBatchActions
selectedIds={Object.keys(selectedRowIds)}
data={features}
projectId={projectId}
/>
</BatchSelectionActionsBar>
</PageContent>
);
};

View File

@ -0,0 +1,68 @@
import { FC, useMemo, useState } from 'react';
import { Button } from '@mui/material';
import { FileDownload, Label } 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';
import { ArchiveButton } from './ArchiveButton/ArchiveButton';
import { MoreActions } from './MoreActions/MoreActions';
interface IProjectFeaturesBatchActionsProps {
selectedIds: string[];
data: FeatureSchema[];
projectId: string;
}
export const ProjectFeaturesBatchActions: FC<
IProjectFeaturesBatchActionsProps
> = ({ selectedIds, data, projectId }) => {
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]);
return (
<>
<ArchiveButton projectId={projectId} features={selectedIds} />
<Button
startIcon={<FileDownload />}
variant="outlined"
size="small"
onClick={() => setShowExportDialog(true)}
>
Export
</Button>
<Button
disabled
startIcon={<Label />}
variant="outlined"
size="small"
>
Tags
</Button>
<MoreActions projectId={projectId} data={selectedData} />
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={selectedData}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
</>
);
};

View File

@ -1,113 +0,0 @@
import { useMemo, useState, VFC } from 'react';
import { Box, Button, Paper, styled, Typography } from '@mui/material';
import { 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';
import { ArchiveButton } from './ArchiveButton/ArchiveButton';
import { MoreActions } from './MoreActions/MoreActions';
interface ISelectionActionsBarProps {
selectedIds: string[];
data: FeatureSchema[];
projectId: string;
}
const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
width: '100%',
flexWrap: 'wrap',
}));
const StyledBar = styled(Paper)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
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,
gap: theme.spacing(1),
flexWrap: 'wrap',
}));
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 }) => ({
paddingRight: theme.spacing(2),
marginRight: 'auto',
}));
export const SelectionActionsBar: VFC<ISelectionActionsBarProps> = ({
selectedIds,
data,
projectId,
}) => {
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>
&ensp;selected
</StyledText>
<ArchiveButton projectId={projectId} features={selectedIds} />
<Button
startIcon={<FileDownload />}
variant="outlined"
size="small"
onClick={() => setShowExportDialog(true)}
>
Export
</Button>
<Button
disabled
startIcon={<Label />}
variant="outlined"
size="small"
>
Tags
</Button>
<MoreActions projectId={projectId} data={selectedData} />
</StyledBar>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={selectedData}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
</StyledContainer>
);
};

View File

@ -7,7 +7,7 @@ import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureTog
import ProjectInfo from './ProjectInfo/ProjectInfo';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from '../../../hooks/useLastViewedProject';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ProjectStats } from './ProjectStats/ProjectStats';

View File

@ -227,6 +227,16 @@ const useProjectApi = () => {
return makeRequest(req.caller, req.id);
};
const reviveFeatures = async (projectId: string, featureIds: string[]) => {
const path = `api/admin/projects/${projectId}/revive`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ features: featureIds }),
});
return makeRequest(req.caller, req.id);
};
const staleFeatures = async (
projectId: string,
featureIds: string[],
@ -259,6 +269,7 @@ const useProjectApi = () => {
changeUserRole,
changeGroupRole,
archiveFeatures,
reviveFeatures,
staleFeatures,
searchProjectUser,
setDefaultProjectStickiness,

View File

@ -17,7 +17,8 @@ import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi';
import NotFoundError from '../../../error/notfound-error';
import Controller from '../../controller';
const PATH = '/:projectId/archive';
const PATH = '/:projectId';
const PATH_ARCHIVE = `${PATH}/archive`;
const PATH_DELETE = `${PATH}/delete`;
const PATH_REVIVE = `${PATH}/revive`;
@ -83,7 +84,7 @@ export default class ProjectArchiveController extends Controller {
this.route({
method: 'post',
path: PATH,
path: PATH_ARCHIVE,
handler: this.archiveFeatures,
permission: DELETE_FEATURE,
middleware: [

View File

@ -221,7 +221,7 @@ test('can bulk delete features and recreate after', async () => {
})
.expect(202);
await app.request
.post('/api/admin/projects/default/archive/delete')
.post('/api/admin/projects/default/delete')
.send({ features })
.expect(200);
for (const feature of features) {
@ -253,7 +253,7 @@ test('can bulk revive features', async () => {
})
.expect(202);
await app.request
.post('/api/admin/projects/default/archive/revive')
.post('/api/admin/projects/default/revive')
.send({ features })
.expect(200);
for (const feature of features) {

View File

@ -6130,7 +6130,7 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/archive/delete": {
"/api/admin/projects/{projectId}/delete": {
"post": {
"description": "This endpoint deletes the specified features, that are in archive.",
"operationId": "deleteFeatures",
@ -6166,42 +6166,6 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/archive/revive": {
"post": {
"description": "This endpoint revives the specified features.",
"operationId": "reviveFeatures",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/batchFeaturesSchema",
},
},
},
"description": "batchFeaturesSchema",
"required": true,
},
"responses": {
"200": {
"description": "This response has no body.",
},
},
"summary": "Revives a list of features",
"tags": [
"Archive",
],
},
},
"/api/admin/projects/{projectId}/environments": {
"post": {
"operationId": "addEnvironmentToProject",
@ -7582,6 +7546,42 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/revive": {
"post": {
"description": "This endpoint revives the specified features.",
"operationId": "reviveFeatures",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/batchFeaturesSchema",
},
},
},
"description": "batchFeaturesSchema",
"required": true,
},
"responses": {
"200": {
"description": "This response has no body.",
},
},
"summary": "Revives a list of features",
"tags": [
"Archive",
],
},
},
"/api/admin/projects/{projectId}/stale": {
"post": {
"description": "This endpoint stales the specified features.",