1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-17 01:17:29 +02:00

feat: get suggested changeset draft (#2274)

* feat: get suggested changeset draft

* fix: update routes snapshot
This commit is contained in:
Tymoteusz Czech 2022-10-28 09:43:49 +02:00 committed by GitHub
parent c6c873d67d
commit b7183fdf98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 347 additions and 194 deletions

View File

@ -30,12 +30,16 @@ import StatusChip from 'component/common/StatusChip/StatusChip';
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound'; import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureArchiveDialog } from '../../common/FeatureArchiveDialog/FeatureArchiveDialog'; import { FeatureArchiveDialog } from '../../common/FeatureArchiveDialog/FeatureArchiveDialog';
import { DraftBanner } from 'component/suggestChanges/DraftBanner/DraftBanner';
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
export const FeatureView = () => { export const FeatureView = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { refetch: projectRefetch } = useProject(projectId); const { refetch: projectRefetch } = useProject(projectId);
const { refetchFeature } = useFeature(projectId, featureId); const { refetchFeature } = useFeature(projectId, featureId);
const { uiConfig } = useUiConfig();
const [openTagDialog, setOpenTagDialog] = useState(false); const [openTagDialog, setOpenTagDialog] = useState(false);
const [showDelDialog, setShowDelDialog] = useState(false); const [showDelDialog, setShowDelDialog] = useState(false);
@ -81,123 +85,145 @@ export const FeatureView = () => {
} }
return ( return (
<ConditionallyRender <MainLayout
condition={error === undefined} ref={ref}
show={ subheader={
<div ref={ref}> uiConfig?.flags?.suggestChanges ? (
<div className={styles.header}> <DraftBanner project={projectId} />
<div className={styles.innerContainer}> ) : null
<div className={styles.toggleInfoContainer}>
<h1
className={styles.featureViewHeader}
data-loading
>
{feature.name}{' '}
</h1>
<ConditionallyRender
condition={!smallScreen}
show={<StatusChip stale={feature?.stale} />}
/>
</div>
<div className={styles.toolbarContainer}>
<PermissionIconButton
permission={CREATE_FEATURE}
projectId={projectId}
data-loading
component={Link}
to={`/projects/${projectId}/features/${featureId}/strategies/copy`}
tooltipProps={{
title: 'Copy feature toggle',
}}
>
<FileCopy />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Archive feature toggle',
}}
data-loading
onClick={() => setShowDelDialog(true)}
>
<Archive />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenStaleDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Toggle stale state',
}}
data-loading
>
<WatchLater />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenTagDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{ title: 'Add tag' }}
data-loading
>
<Label />
</PermissionIconButton>
</div>
</div>
<div className={styles.separator} />
<div className={styles.tabContainer}>
<Tabs
value={activeTab.path}
indicatorColor="primary"
textColor="primary"
>
{tabData.map(tab => (
<Tab
key={tab.title}
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
className={styles.tabButton}
/>
))}
</Tabs>
</div>
</div>
<Routes>
<Route path="metrics" element={<FeatureMetrics />} />
<Route path="logs" element={<FeatureLog />} />
<Route path="variants" element={<FeatureVariants />} />
<Route path="settings" element={<FeatureSettings />} />
<Route path="*" element={<FeatureOverview />} />
</Routes>
<FeatureArchiveDialog
isOpen={showDelDialog}
onConfirm={() => {
projectRefetch();
navigate(`/projects/${projectId}`);
}}
onClose={() => setShowDelDialog(false)}
projectId={projectId}
featureId={featureId}
/>
<FeatureStaleDialog
isStale={feature.stale}
isOpen={openStaleDialog}
onClose={() => {
setOpenStaleDialog(false);
refetchFeature();
}}
featureId={featureId}
projectId={projectId}
/>
<AddTagDialog
open={openTagDialog}
setOpen={setOpenTagDialog}
/>
</div>
} }
/> >
<ConditionallyRender
condition={error === undefined}
show={
<div ref={ref}>
<div className={styles.header}>
<div className={styles.innerContainer}>
<div className={styles.toggleInfoContainer}>
<h1
className={styles.featureViewHeader}
data-loading
>
{feature.name}{' '}
</h1>
<ConditionallyRender
condition={!smallScreen}
show={
<StatusChip
stale={feature?.stale}
/>
}
/>
</div>
<div className={styles.toolbarContainer}>
<PermissionIconButton
permission={CREATE_FEATURE}
projectId={projectId}
data-loading
component={Link}
to={`/projects/${projectId}/features/${featureId}/strategies/copy`}
tooltipProps={{
title: 'Copy feature toggle',
}}
>
<FileCopy />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Archive feature toggle',
}}
data-loading
onClick={() => setShowDelDialog(true)}
>
<Archive />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenStaleDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{
title: 'Toggle stale state',
}}
data-loading
>
<WatchLater />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenTagDialog(true)}
permission={UPDATE_FEATURE}
projectId={projectId}
tooltipProps={{ title: 'Add tag' }}
data-loading
>
<Label />
</PermissionIconButton>
</div>
</div>
<div className={styles.separator} />
<div className={styles.tabContainer}>
<Tabs
value={activeTab.path}
indicatorColor="primary"
textColor="primary"
>
{tabData.map(tab => (
<Tab
key={tab.title}
label={tab.title}
value={tab.path}
onClick={() => navigate(tab.path)}
className={styles.tabButton}
/>
))}
</Tabs>
</div>
</div>
<Routes>
<Route
path="metrics"
element={<FeatureMetrics />}
/>
<Route path="logs" element={<FeatureLog />} />
<Route
path="variants"
element={<FeatureVariants />}
/>
<Route
path="settings"
element={<FeatureSettings />}
/>
<Route path="*" element={<FeatureOverview />} />
</Routes>
<FeatureArchiveDialog
isOpen={showDelDialog}
onConfirm={() => {
projectRefetch();
navigate(`/projects/${projectId}`);
}}
onClose={() => setShowDelDialog(false)}
projectId={projectId}
featureId={featureId}
/>
<FeatureStaleDialog
isStale={feature.stale}
isOpen={openStaleDialog}
onClose={() => {
setOpenStaleDialog(false);
refetchFeature();
}}
featureId={featureId}
projectId={projectId}
/>
<AddTagDialog
open={openTagDialog}
setOpen={setOpenTagDialog}
/>
</div>
}
/>
</MainLayout>
); );
}; };

View File

@ -54,6 +54,7 @@ exports[`returns all baseRoutes 1`] = `
}, },
{ {
"component": [Function], "component": [Function],
"isStandalone": true,
"menu": {}, "menu": {},
"parent": "/projects", "parent": "/projects",
"path": "/projects/:projectId/features/:featureId/*", "path": "/projects/:projectId/features/:featureId/*",

View File

@ -121,6 +121,7 @@ export const routes: IRoute[] = [
title: 'FeatureView', title: 'FeatureView',
component: FeatureView, component: FeatureView,
type: 'protected', type: 'protected',
isStandalone: true,
menu: {}, menu: {},
}, },
{ {

View File

@ -117,7 +117,11 @@ const Project = () => {
return ( return (
<MainLayout <MainLayout
ref={ref} ref={ref}
subheader={uiConfig?.flags?.suggestChanges ? <DraftBanner /> : null} subheader={
uiConfig?.flags?.suggestChanges ? (
<DraftBanner project={projectId} />
) : null
}
> >
<div className={styles.header}> <div className={styles.header}>
<div className={styles.innerContainer}> <div className={styles.innerContainer}>

View File

@ -4,14 +4,21 @@ import { useStyles as useAppStyles } from 'component/App.styles';
import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SuggestedChangesSidebar } from '../SuggestedChangesSidebar/SuggestedChangesSidebar'; import { SuggestedChangesSidebar } from '../SuggestedChangesSidebar/SuggestedChangesSidebar';
import { useSuggestedChangesDraft } from 'hooks/api/getters/useSuggestedChangesDraft/useSuggestedChangesDraft';
interface IDraftBannerProps { interface IDraftBannerProps {
environment?: string; project: string;
} }
export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => { export const DraftBanner: VFC<IDraftBannerProps> = ({ project }) => {
const { classes } = useAppStyles(); const { classes } = useAppStyles();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { draft, loading } = useSuggestedChangesDraft(project);
const environment = '';
if (!loading && !draft) {
return null;
}
return ( return (
<Box <Box
@ -63,6 +70,7 @@ export const DraftBanner: VFC<IDraftBannerProps> = ({ environment }) => {
</Box> </Box>
</Box> </Box>
<SuggestedChangesSidebar <SuggestedChangesSidebar
project={project}
open={isSidebarOpen} open={isSidebarOpen}
onClose={() => { onClose={() => {
setIsSidebarOpen(false); setIsSidebarOpen(false);

View File

@ -1,14 +1,16 @@
import React, { useState, VFC } from 'react'; import { VFC } from 'react';
import { Box, Button, Typography, styled, Tooltip } from '@mui/material'; import { Box, Button, Typography, styled, Tooltip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { HelpOutline } from '@mui/icons-material'; import { HelpOutline } from '@mui/icons-material';
import { useSuggestedChange } from 'hooks/api/getters/useSuggestChange/useSuggestedChange';
import { SuggestedChangeset } from '../SuggestedChangeset/SuggestedChangeset'; import { SuggestedChangeset } from '../SuggestedChangeset/SuggestedChangeset';
import { useSuggestedChangesDraft } from 'hooks/api/getters/useSuggestedChangesDraft/useSuggestedChangesDraft';
interface ISuggestedChangesSidebarProps { interface ISuggestedChangesSidebarProps {
open: boolean; open: boolean;
project: string;
onClose: () => void; onClose: () => void;
} }
const StyledPageContent = styled(PageContent)(({ theme }) => ({ const StyledPageContent = styled(PageContent)(({ theme }) => ({
@ -26,11 +28,13 @@ const StyledPageContent = styled(PageContent)(({ theme }) => ({
}, },
borderRadius: `${theme.spacing(1.5, 0, 0, 1.5)} !important`, borderRadius: `${theme.spacing(1.5, 0, 0, 1.5)} !important`,
})); }));
const StyledHelpOutline = styled(HelpOutline)(({ theme }) => ({ const StyledHelpOutline = styled(HelpOutline)(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader, fontSize: theme.fontSizes.mainHeader,
marginLeft: '0.3rem', marginLeft: '0.3rem',
color: theme.palette.grey[700], color: theme.palette.grey[700],
})); }));
const StyledHeaderHint = styled('div')(({ theme }) => ({ const StyledHeaderHint = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
@ -38,23 +42,43 @@ const StyledHeaderHint = styled('div')(({ theme }) => ({
export const SuggestedChangesSidebar: VFC<ISuggestedChangesSidebarProps> = ({ export const SuggestedChangesSidebar: VFC<ISuggestedChangesSidebarProps> = ({
open, open,
project,
onClose, onClose,
}) => { }) => {
const { data: suggestedChange } = useSuggestedChange(); const { draft, loading } = useSuggestedChangesDraft(project);
const onReview = async () => { const onReview = async () => {
console.log('approve'); alert('approve');
}; };
const onDiscard = async () => { const onDiscard = async () => {
console.log('discard'); alert('discard');
}; };
const onApply = async () => { const onApply = async () => {
try { try {
console.log('apply'); alert('apply');
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
}; };
if (!loading && !draft) {
return (
<SidebarModal open={open} onClose={onClose} label="Review changes">
<StyledPageContent
header={
<PageHeader
secondary
titleElement="Review your changes"
></PageHeader>
}
>
There are no changes to review.
{/* FIXME: empty state */}
</StyledPageContent>
</SidebarModal>
);
}
return ( return (
<SidebarModal open={open} onClose={onClose} label="Review changes"> <SidebarModal open={open} onClose={onClose} label="Review changes">
<StyledPageContent <StyledPageContent
@ -80,55 +104,82 @@ export const SuggestedChangesSidebar: VFC<ISuggestedChangesSidebarProps> = ({
></PageHeader> ></PageHeader>
} }
> >
{/* TODO: multiple environments (changesets) */} {draft?.map(environmentChangeset => (
<Typography>{suggestedChange?.state}</Typography> <Box
<br /> key={environmentChangeset.id}
<SuggestedChangeset suggestedChange={suggestedChange} /> sx={{
<Box sx={{ display: 'flex' }}> padding: 2,
<ConditionallyRender border: '2px solid',
condition={suggestedChange?.state === 'APPROVED'} borderColor: theme => theme.palette.neutral.light,
show={<Typography>Applied</Typography>} borderRadius: theme =>
/> `${theme.shape.borderRadiusLarge}px`,
<ConditionallyRender }}
condition={suggestedChange?.state === 'CLOSED'} >
show={<Typography>Applied</Typography>} <Typography>
/> env: {environmentChangeset?.environment}
<ConditionallyRender </Typography>
condition={suggestedChange?.state === 'APPROVED'} <Typography>
show={ state: {environmentChangeset?.state}
<> </Typography>
<Button <hr />
sx={{ mt: 2 }} <SuggestedChangeset
variant="contained" suggestedChange={environmentChangeset}
onClick={onApply} />
> <Box sx={{ display: 'flex' }}>
Apply changes <ConditionallyRender
</Button> condition={
</> environmentChangeset?.state === 'APPROVED'
} }
/> show={<Typography>Applied</Typography>}
<ConditionallyRender />
condition={suggestedChange?.state === 'CREATED'} <ConditionallyRender
show={ condition={
<> environmentChangeset?.state === 'CLOSED'
<Button }
sx={{ mt: 2, ml: 'auto' }} show={<Typography>Applied</Typography>}
variant="contained" />
onClick={onReview} <ConditionallyRender
> condition={
Request changes environmentChangeset?.state === 'APPROVED'
</Button> }
<Button show={
sx={{ mt: 2, ml: 2 }} <>
variant="outlined" <Button
onClick={onDiscard} sx={{ mt: 2 }}
> variant="contained"
Discard changes onClick={onApply}
</Button> >
</> Apply changes
} </Button>
/> </>
</Box> }
/>
<ConditionallyRender
condition={
environmentChangeset?.state === 'Draft'
}
show={
<>
<Button
sx={{ mt: 2, ml: 'auto' }}
variant="contained"
onClick={onReview}
>
Request changes
</Button>
<Button
sx={{ mt: 2, ml: 2 }}
variant="outlined"
onClick={onDiscard}
>
Discard changes
</Button>
</>
}
/>
</Box>
</Box>
))}
</StyledPageContent> </StyledPageContent>
</SidebarModal> </SidebarModal>
); );

View File

@ -4,38 +4,39 @@ import { SuggestedFeatureToggleChange } from '../SuggestedChangeOverview/Suggest
import { objectId } from 'utils/objectId'; import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ToggleStatusChange } from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/ToggleStatusChange'; import { ToggleStatusChange } from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/ToggleStatusChange';
import { // import {
StrategyAddedChange, // StrategyAddedChange,
StrategyDeletedChange, // StrategyDeletedChange,
StrategyEditedChange, // StrategyEditedChange,
} from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/StrategyChange'; // } from '../SuggestedChangeOverview/SuggestedFeatureToggleChange/StrategyChange';
import { // import {
formatStrategyName, // formatStrategyName,
GetFeatureStrategyIcon, // GetFeatureStrategyIcon,
} from 'utils/strategyNames'; // } from 'utils/strategyNames';
import type { ISuggestChangesResponse } from 'hooks/api/getters/useSuggestedChangesDraft/useSuggestedChangesDraft';
export const SuggestedChangeset: FC<{ suggestedChange: any }> = ({ export const SuggestedChangeset: FC<{
suggestedChange, suggestedChange: ISuggestChangesResponse;
}) => { }> = ({ suggestedChange }) => {
return ( return (
<Box> <Box>
Changes Changes
{suggestedChange.changes?.map((featureToggleChange: any) => ( {suggestedChange.features?.map(featureToggleChange => (
<SuggestedFeatureToggleChange <SuggestedFeatureToggleChange
key={featureToggleChange.feature} key={featureToggleChange.name}
featureToggleName={featureToggleChange.feature} featureToggleName={featureToggleChange.name}
> >
{featureToggleChange.changeSet.map((change: any) => ( {featureToggleChange.changes.map(change => (
<Box key={objectId(change)}> <Box key={objectId(change)}>
<ConditionallyRender <ConditionallyRender
condition={change.action === 'updateEnabled'} condition={change.action === 'updateEnabled'}
show={ show={
<ToggleStatusChange <ToggleStatusChange
enabled={change?.payload?.data?.data} enabled={change?.payload?.enabled}
/> />
} }
/> />
<ConditionallyRender {/* <ConditionallyRender
condition={change.action === 'addStrategy'} condition={change.action === 'addStrategy'}
show={ show={
<StrategyAddedChange> <StrategyAddedChange>
@ -55,7 +56,7 @@ export const SuggestedChangeset: FC<{ suggestedChange: any }> = ({
<ConditionallyRender <ConditionallyRender
condition={change.action === 'updateStrategy'} condition={change.action === 'updateStrategy'}
show={<StrategyEditedChange />} show={<StrategyEditedChange />}
/> /> */}
</Box> </Box>
))} ))}
</SuggestedFeatureToggleChange> </SuggestedFeatureToggleChange>

View File

@ -80,6 +80,9 @@ const data: any = {
], ],
}; };
/**
* @deprecated for draft: useSuggestedChangesDraft
*/
export const useSuggestedChange = () => { export const useSuggestedChange = () => {
// const { data, error, mutate } = useSWR( // const { data, error, mutate } = useSWR(
// formatApiPath(`api/admin/suggest-changes/${id}`), // formatApiPath(`api/admin/suggest-changes/${id}`),

View File

@ -0,0 +1,58 @@
import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
interface IChange {
id: number;
action: string;
payload: {
enabled: boolean; // FIXME: add other action types
};
createdAt: Date;
createdBy: {
id: number;
username?: any;
imageUrl?: any;
};
}
export interface ISuggestChangesResponse {
id: number;
environment: string;
state: string;
project: string;
createdBy: {
id: number;
username?: any;
imageUrl?: any;
};
createdAt: Date;
features: Array<{
name: string;
changes: IChange[];
}>;
}
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('SuggestedChanges'))
.then(res => res.json());
};
export const useSuggestedChangesDraft = (project: string) => {
const { data, error, mutate } = useSWR<ISuggestChangesResponse[]>(
formatApiPath(`api/admin/projects/${project}/suggest-changes/draft`),
fetcher
);
return useMemo(
() => ({
draft: data,
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate]
);
};