1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-01 13:47:27 +02:00

Merge remote-tracking branch 'origin/main' into group-service-sync-as-system

This commit is contained in:
Gastón Fournier 2024-05-31 09:21:01 +02:00
commit 1b3874dbe0
No known key found for this signature in database
GPG Key ID: AF45428626E17A8E
72 changed files with 1445 additions and 1237 deletions

22
.github/workflows/dependency-review.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Dependency review
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
license_review:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
#
deny-licenses: GPL-1.0, GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0, MPL-2.0, AGPL-3.0
comment-summary-in-pr: always

View File

@ -12,37 +12,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@exlinc/keycloak-passport": "^1.0.2",
"@passport-next/passport": "^3.1.0",
"@passport-next/passport-google-oauth2": "^1.0.0",
"basic-auth": "^2.0.1",
"passport": "^0.7.0",
"unleash-server": "file:../build"
},
"resolutions": {
"async": "^3.2.4",
"db-migrate/rc/minimist": "^1.2.5",
"es5-ext": "0.10.64",
"knex/liftoff/object.map/**/kind-of": "^6.0.3",
"knex/liftoff/findup-sync/micromatc/kind-of": "^6.0.3",
"knex/liftoff/findup-sync/micromatc/nanomatch/kind-of": "^6.0.3",
"knex/liftoff/findup-sync/micromatch/define-property/**/kind-of": "^6.0.3",
"node-forge": "^1.0.0",
"set-value": "^4.0.1",
"ansi-regex": "^5.0.1",
"ssh2": "^1.4.0",
"json-schema": "^0.4.0",
"ip": "^2.0.1",
"minimatch": "^5.0.0",
"path-scurry": "1.10.2",
"semver": "^7.5.3"
},
"overrides": {
"set-value": "^4.0.1",
"ansi-regex": "^5.0.1",
"ssh2": "^1.4.0",
"json-schema": "^0.4.0",
"semver": "^7.5.3"
},
"version": "4.12.6"
"version": "5.12.4"
}

View File

@ -47,11 +47,13 @@ interface IGroupCardAvatarsProps {
imageUrl?: string;
}[];
header?: ReactNode;
avatarLimit?: number;
}
export const GroupCardAvatars = ({
users = [],
header = null,
avatarLimit = 9,
}: IGroupCardAvatarsProps) => {
const shownUsers = useMemo(
() =>
@ -68,7 +70,7 @@ export const GroupCardAvatars = ({
}
return 0;
})
.slice(0, 9),
.slice(0, avatarLimit),
[users],
);
@ -109,7 +111,7 @@ export const GroupCardAvatars = ({
/>
))}
<ConditionallyRender
condition={users.length > 9}
condition={users.length > avatarLimit}
show={
<StyledAvatar>
+{users.length - shownUsers.length}

View File

@ -99,6 +99,24 @@ describe('Strategy change conflict detection', () => {
expect(resultMissing).toBeNull();
});
test('It sorts segments before comparing (because their order is irrelevant)', () => {
const result = getStrategyChangesThatWouldBeOverwritten(
{
...existingStrategy,
segments: [25, 26, 1],
},
{
...change,
payload: {
...change.payload,
segments: [26, 1, 25],
},
},
);
expect(result).toBeNull();
});
test('It treats `undefined` or missing strategy variants in old config and change as equal to `[]`', () => {
const undefinedVariantsExistingStrategy = {
...existingStrategy,

View File

@ -142,9 +142,31 @@ export function getStrategyChangesThatWouldBeOverwritten(
change: IChangeRequestUpdateStrategy,
): ChangesThatWouldBeOverwritten | null {
const fallbacks = { segments: [], variants: [], title: '' };
const withSortedSegments = (() => {
if (!(change.payload.segments && currentStrategyConfig?.segments)) {
return { current: currentStrategyConfig, change: change };
}
const changeCopy = {
...change,
payload: {
...change.payload,
segments: [...change.payload.segments].sort(),
},
};
const currentCopy = {
...currentStrategyConfig,
segments: [...currentStrategyConfig.segments].sort(),
};
return { current: currentCopy, change: changeCopy };
})();
return getChangesThatWouldBeOverwritten(
omit(currentStrategyConfig, 'strategyName'),
change,
omit(withSortedSegments.current, 'strategyName'),
withSortedSegments.change,
fallbacks,
);
}

View File

@ -19,7 +19,7 @@ import { ReactComponent as StarsIcon } from 'assets/img/stars.svg';
const StyledAccordion = styled(Accordion)(({ theme }) => ({
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
width: '100%',
maxWidth: theme.spacing(30),
zIndex: theme.zIndex.sticky,

View File

@ -2,14 +2,18 @@ import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMe
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import {
FeatureStrategyEdit,
formatFeaturePath,
} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle';
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
import { styled } from '@mui/material';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useEffect } from 'react';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -37,6 +41,10 @@ const FeatureOverview = () => {
useHiddenEnvironments();
const onSidebarClose = () => navigate(featurePath);
usePageTitle(featureId);
const { setLastViewed } = useLastViewedFlags();
useEffect(() => {
setLastViewed({ featureId, projectId });
}, [featureId]);
return (
<StyledContainer>

View File

@ -22,6 +22,7 @@ import type {
import type { GroupedDataByProject } from './hooks/useGroupedProjectTrends';
import { allOption } from 'component/common/ProjectSelect/ProjectSelect';
import { chartInfo } from './chart-info';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IChartsProps {
flags: InstanceInsightsSchema['flags'];
@ -83,6 +84,7 @@ export const InsightsCharts: VFC<IChartsProps> = ({
allMetricsDatapoints,
loading,
}) => {
const { isEnterprise } = useUiConfig();
const showAllProjects = projects[0] === allOption.id;
const isOneProjectSelected = projects.length === 1;
@ -171,63 +173,79 @@ export const InsightsCharts: VFC<IChartsProps> = ({
</ChartWidget>
}
/>
<Widget {...chartInfo.averageHealth}>
<HealthStats
value={summary.averageHealth}
healthy={summary.active}
stale={summary.stale}
potentiallyStale={summary.potentiallyStale}
/>
</Widget>
<ChartWidget
{...(showAllProjects
? chartInfo.overallHealth
: chartInfo.healthPerProject)}
>
<ProjectHealthChart
projectFlagTrends={groupedProjectsData}
isAggregate={showAllProjects}
isLoading={loading}
/>
</ChartWidget>
<Widget {...chartInfo.medianTimeToProduction}>
<TimeToProduction
daysToProduction={summary.medianTimeToProduction}
/>
</Widget>
<ChartWidget
{...(showAllProjects
? chartInfo.timeToProduction
: chartInfo.timeToProductionPerProject)}
>
<TimeToProductionChart
projectFlagTrends={groupedProjectsData}
isAggregate={showAllProjects}
isLoading={loading}
/>
</ChartWidget>
<ConditionallyRender
condition={isEnterprise()}
show={
<>
<Widget {...chartInfo.averageHealth}>
<HealthStats
value={summary.averageHealth}
healthy={summary.active}
stale={summary.stale}
potentiallyStale={summary.potentiallyStale}
/>
</Widget>
<ChartWidget
{...(showAllProjects
? chartInfo.overallHealth
: chartInfo.healthPerProject)}
>
<ProjectHealthChart
projectFlagTrends={groupedProjectsData}
isAggregate={showAllProjects}
isLoading={loading}
/>
</ChartWidget>
<Widget {...chartInfo.medianTimeToProduction}>
<TimeToProduction
daysToProduction={
summary.medianTimeToProduction
}
/>
</Widget>
<ChartWidget
{...(showAllProjects
? chartInfo.timeToProduction
: chartInfo.timeToProductionPerProject)}
>
<TimeToProductionChart
projectFlagTrends={groupedProjectsData}
isAggregate={showAllProjects}
isLoading={loading}
/>
</ChartWidget>
</>
}
/>
</StyledGrid>
<Widget
{...(showAllProjects
? chartInfo.metrics
: chartInfo.metricsPerProject)}
>
<MetricsSummaryChart
metricsSummaryTrends={groupedMetricsData}
allDatapointsSorted={allMetricsDatapoints}
isAggregate={showAllProjects}
isLoading={loading}
/>
</Widget>
<Widget
{...chartInfo.updates}
sx={{ mt: (theme) => theme.spacing(2) }}
>
<UpdatesPerEnvironmentTypeChart
environmentTypeTrends={environmentTypeTrends}
isLoading={loading}
/>
</Widget>
<ConditionallyRender
condition={isEnterprise()}
show={
<>
<Widget
{...(showAllProjects
? chartInfo.metrics
: chartInfo.metricsPerProject)}
>
<MetricsSummaryChart
metricsSummaryTrends={groupedMetricsData}
allDatapointsSorted={allMetricsDatapoints}
isAggregate={showAllProjects}
isLoading={loading}
/>
</Widget>
<Widget
{...chartInfo.updates}
sx={{ mt: (theme) => theme.spacing(2) }}
>
<UpdatesPerEnvironmentTypeChart
environmentTypeTrends={environmentTypeTrends}
isLoading={loading}
/>
</Widget>
</>
}
/>
</>
);
};

View File

@ -10,7 +10,6 @@ import {
useTheme,
} from '@mui/material';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Badge } from 'component/common/Badge/Badge';
import { ShareLink } from './ShareLink/ShareLink';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
@ -82,7 +81,6 @@ export const InsightsHeader: VFC<DashboardHeaderProps> = ({ actions }) => {
})}
>
<span>Insights</span>{' '}
<Badge color='success'>Beta</Badge>
</Typography>
}
actions={

View File

@ -171,7 +171,61 @@ describe('useFilteredFlagTrends', () => {
averageUsers: 0,
averageHealth: undefined,
flagsPerUser: '0.00',
medianTimeToProduction: 0,
medianTimeToProduction: undefined,
});
});
it('should not use 0 timeToProduction projects for median calculation', () => {
const { result } = renderHook(() =>
useFilteredFlagsSummary(
[
{
week: '2024-01',
project: 'project1',
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
users: 0,
date: '',
timeToProduction: 0,
},
{
week: '2024-01',
project: 'project2',
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
users: 0,
date: '',
timeToProduction: 0,
},
{
week: '2024-01',
project: 'project3',
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
users: 0,
date: '',
timeToProduction: 5,
},
],
{ total: 1 } as unknown as InstanceInsightsSchemaUsers,
),
);
expect(result.current).toEqual({
total: 0,
active: 0,
stale: 0,
potentiallyStale: 0,
averageUsers: 0,
averageHealth: undefined,
flagsPerUser: '0.00',
medianTimeToProduction: 5,
});
});
});

View File

@ -6,7 +6,10 @@ import type {
const validTimeToProduction = (
item: InstanceInsightsSchemaProjectFlagTrendsItem,
) => Boolean(item) && typeof item.timeToProduction === 'number';
) =>
Boolean(item) &&
typeof item.timeToProduction === 'number' &&
item.timeToProduction !== 0;
// NOTE: should we move project filtering to the backend?
export const useFilteredFlagsSummary = (

View File

@ -18,6 +18,7 @@ import Accordion from '@mui/material/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import FlagIcon from '@mui/icons-material/OutlinedFlag';
const StyledBadgeContainer = styled('div')(({ theme }) => ({
paddingLeft: theme.spacing(2),
@ -119,6 +120,30 @@ export const RecentProjectsList: FC<{
);
};
export const RecentFlagsList: FC<{
flags: { featureId: string; projectId: string }[];
mode: NavigationMode;
onClick: () => void;
}> = ({ flags, mode, onClick }) => {
const DynamicListItem = mode === 'mini' ? MiniListItem : FullListItem;
return (
<List>
{flags.map((flag) => (
<DynamicListItem
href={`/projects/${flag.projectId}/features/${flag.featureId}`}
text={flag.featureId}
onClick={onClick}
selected={false}
key={flag.featureId}
>
<FlagIcon />
</DynamicListItem>
))}
</List>
);
};
export const PrimaryNavigationList: FC<{
mode: NavigationMode;
onClick: (activeItem: string) => void;
@ -187,7 +212,12 @@ export const SecondaryNavigation: FC<{
return (
<Accordion
disableGutters={true}
sx={{ boxShadow: 'none' }}
sx={{
boxShadow: 'none',
'&:before': {
display: 'none',
},
}}
expanded={expanded}
onChange={(_, expand) => {
onExpandChange(expand);
@ -208,7 +238,7 @@ export const RecentProjectsNavigation: FC<{
<Box>
{mode === 'full' && (
<Typography
sx={{ fontWeight: 'bold', fontSize: 'small', mb: 1, ml: 1 }}
sx={{ fontWeight: 'bold', fontSize: 'small', mb: 1, ml: 2 }}
>
Recent project
</Typography>
@ -221,3 +251,22 @@ export const RecentProjectsNavigation: FC<{
</Box>
);
};
export const RecentFlagsNavigation: FC<{
mode: NavigationMode;
flags: { featureId: string; projectId: string }[];
onClick: () => void;
}> = ({ mode, onClick, flags }) => {
return (
<Box>
{mode === 'full' && (
<Typography
sx={{ fontWeight: 'bold', fontSize: 'small', mb: 1, ml: 2 }}
>
Recent flags
</Typography>
)}
<RecentFlagsList flags={flags} mode={mode} onClick={onClick} />
</Box>
);
};

View File

@ -4,6 +4,12 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
import { createLocalStorage } from 'utils/createLocalStorage';
import { Route, Routes } from 'react-router-dom';
import { listItemButtonClasses as classes } from '@mui/material/ListItemButton';
import {
type LastViewedFlag,
useLastViewedFlags,
} from '../../../../hooks/useLastViewedFlags';
import { type FC, useEffect } from 'react';
import { useLastViewedProject } from '../../../../hooks/useLastViewedProject';
beforeEach(() => {
window.localStorage.clear();
@ -69,3 +75,32 @@ test('select active item', async () => {
expect(links[1]).toHaveClass(classes.selected);
});
const SetupComponent: FC<{ project: string; flags: LastViewedFlag[] }> = ({
project,
flags,
}) => {
const { setLastViewed: setProject } = useLastViewedProject();
const { setLastViewed: setFlag } = useLastViewedFlags();
useEffect(() => {
setProject(project);
flags.forEach((flag) => {
setFlag(flag);
});
}, []);
return <NavigationSidebar />;
};
test('print recent projects and flags', async () => {
render(
<SetupComponent
project={'projectA'}
flags={[{ featureId: 'featureA', projectId: 'projectB' }]}
/>,
);
await screen.findByText('projectA');
await screen.findByText('featureA');
});

View File

@ -7,12 +7,14 @@ import { useExpanded } from './useExpanded';
import {
OtherLinksList,
PrimaryNavigationList,
RecentFlagsNavigation,
RecentProjectsNavigation,
SecondaryNavigation,
SecondaryNavigationList,
} from './NavigationList';
import { useInitialPathname } from './useInitialPathname';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
export const MobileNavigationSidebar: FC<{ onClick: () => void }> = ({
onClick,
@ -41,9 +43,6 @@ export const StretchContainer = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
alignSelf: 'stretch',
}));
export const ScreenHeightBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
@ -59,74 +58,83 @@ export const NavigationSidebar = () => {
const [activeItem, setActiveItem] = useState(initialPathname);
const { lastViewed } = useLastViewedProject();
const showRecentProject = mode === 'full' && lastViewed;
const { lastViewed: lastViewedProject } = useLastViewedProject();
const showRecentProject = mode === 'full' && lastViewedProject;
const { lastViewed: lastViewedFlags } = useLastViewedFlags();
const showRecentFlags = mode === 'full' && lastViewedFlags.length > 0;
return (
<StretchContainer>
<ScreenHeightBox>
<PrimaryNavigationList
<PrimaryNavigationList
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
<SecondaryNavigation
expanded={expanded.includes('configure')}
onExpandChange={(expand) => {
changeExpanded('configure', expand);
}}
mode={mode}
title='Configure'
>
<SecondaryNavigationList
routes={routes.mainNavRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
{mode === 'full' && (
<SecondaryNavigation
expanded={expanded.includes('configure')}
expanded={expanded.includes('admin')}
onExpandChange={(expand) => {
changeExpanded('configure', expand);
changeExpanded('admin', expand);
}}
mode={mode}
title='Configure'
title='Admin'
>
<SecondaryNavigationList
routes={routes.mainNavRoutes}
routes={routes.adminRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
{mode === 'full' && (
<SecondaryNavigation
expanded={expanded.includes('admin')}
onExpandChange={(expand) => {
changeExpanded('admin', expand);
}}
mode={mode}
title='Admin'
>
<SecondaryNavigationList
routes={routes.adminRoutes}
mode={mode}
onClick={setActiveItem}
activeItem={activeItem}
/>
</SecondaryNavigation>
)}
)}
{mode === 'mini' && (
<ShowAdmin
onChange={() => {
changeExpanded('admin', true);
setMode('full');
}}
/>
)}
{showRecentProject && (
<RecentProjectsNavigation
mode={mode}
projectId={lastViewed}
onClick={() => setActiveItem('/projects')}
/>
)}
<ShowHide
mode={mode}
{mode === 'mini' && (
<ShowAdmin
onChange={() => {
setMode(mode === 'full' ? 'mini' : 'full');
changeExpanded('admin', true);
setMode('full');
}}
/>
</ScreenHeightBox>
)}
{showRecentProject && (
<RecentProjectsNavigation
mode={mode}
projectId={lastViewedProject}
onClick={() => setActiveItem('/projects')}
/>
)}
{showRecentFlags && (
<RecentFlagsNavigation
mode={mode}
flags={lastViewedFlags}
onClick={() => setActiveItem('/projects')}
/>
)}
<ShowHide
mode={mode}
onChange={() => {
setMode(mode === 'full' ? 'mini' : 'full');
}}
/>
</StretchContainer>
);
};

View File

@ -5,15 +5,24 @@ import HideIcon from '@mui/icons-material/KeyboardDoubleArrowLeft';
import ExpandIcon from '@mui/icons-material/KeyboardDoubleArrowRight';
import SettingsIcon from '@mui/icons-material/Settings';
const ShowHideWrapper = styled(Box, {
const ShowHideRow = styled(Box, {
shouldForwardProp: (prop) => prop !== 'mode',
})<{ mode: NavigationMode }>(({ theme, mode }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2, 1, 0, mode === 'mini' ? 1.5 : 2),
marginTop: 'auto',
cursor: 'pointer',
position: 'sticky',
bottom: theme.spacing(2),
width: '100%',
}));
// This component is needed when the sticky item could overlap with nav items. You can replicate it on a short screen.
const ShowHideContainer = styled(Box)(({ theme }) => ({
flexGrow: 1,
display: 'flex',
alignItems: 'end',
}));
export const ShowHide: FC<{ mode: NavigationMode; onChange: () => void }> = ({
@ -21,30 +30,32 @@ export const ShowHide: FC<{ mode: NavigationMode; onChange: () => void }> = ({
onChange,
}) => {
return (
<ShowHideWrapper onClick={onChange} mode={mode}>
{mode === 'full' && (
<Box
sx={(theme) => ({
color: theme.palette.neutral.main,
fontSize: 'small',
})}
>
Hide ( + B)
</Box>
)}
<IconButton>
{mode === 'full' ? (
<HideIcon color='primary' />
) : (
<Tooltip title='Expand (⌘ + B)' placement='right'>
<ExpandIcon
data-testid='expand-navigation'
color='primary'
/>
</Tooltip>
<ShowHideContainer>
<ShowHideRow onClick={onChange} mode={mode}>
{mode === 'full' && (
<Box
sx={(theme) => ({
color: theme.palette.neutral.main,
fontSize: 'small',
})}
>
Hide ( + B)
</Box>
)}
</IconButton>
</ShowHideWrapper>
<IconButton>
{mode === 'full' ? (
<HideIcon color='primary' />
) : (
<Tooltip title='Expand (⌘ + B)' placement='right'>
<ExpandIcon
data-testid='expand-navigation'
color='primary'
/>
</Tooltip>
)}
</IconButton>
</ShowHideRow>
</ShowHideContainer>
);
};

View File

@ -13,6 +13,7 @@ const StyledFooter = styled('footer')(({ theme }) => ({
flexGrow: 1,
zIndex: 100,
backgroundColor: theme.palette.background.paper,
overflowY: 'hidden',
}));
const StyledList = styled(List)({

View File

@ -3,7 +3,7 @@
exports[`should render DrawerMenu 1`] = `
[
<footer
className="css-10ce49z"
className="css-u2qlp3"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 css-16chest-MuiGrid-root"
@ -547,7 +547,7 @@ exports[`should render DrawerMenu 1`] = `
exports[`should render DrawerMenu with "features" selected 1`] = `
[
<footer
className="css-10ce49z"
className="css-u2qlp3"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 css-16chest-MuiGrid-root"

View File

@ -242,12 +242,9 @@ const OldHeader: VFC = () => {
<StyledLink to={'/search'}>Search</StyledLink>
<StyledLink to='/playground'>Playground</StyledLink>
<ConditionallyRender
condition={!killInsightsDashboard}
condition={!killInsightsDashboard && !isOss()}
show={
<StyledLinkWithBetaBadge
to={'/insights'}
title={'Insights'}
/>
<StyledLink to='/insights'>Insights</StyledLink>
}
/>
<StyledAdvancedNavButton

View File

@ -126,7 +126,7 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"enterprise": false,
"enterprise": true,
"menu": {
"mobile": true,
},

View File

@ -153,7 +153,7 @@ export const routes: IRoute[] = [
type: 'protected',
menu: { mobile: true },
notFlag: 'killInsightsUI',
enterprise: false,
enterprise: true,
},
// Applications

View File

@ -11,17 +11,17 @@ interface IProjectCardFooterProps {
}
const StyledFooter = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
alignItems: 'center',
padding: theme.spacing(1.5, 3),
padding: theme.spacing(1.5, 3, 2.5, 3),
background: theme.palette.envAccordion.expanded,
boxShadow: theme.boxShadows.accordionFooter,
}));
const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
marginRight: theme.spacing(-1),
marginLeft: 'auto',
marginBottom: theme.spacing(-1),
}));
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({

View File

@ -41,15 +41,13 @@ const useOwnersMap = () => {
};
};
const StyledContainer = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(1),
display: 'flex',
alignItems: 'flex-end',
}));
const StyledUserName = styled('p')(({ theme }) => ({
fontSize: theme.typography.body1.fontSize,
margin: theme.spacing(0, 0, 0.5, 0),
overflowX: 'hidden',
textOverflow: 'ellipsis',
textWrap: 'nowrap',
alignSelf: 'end',
}));
export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
@ -57,10 +55,11 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
const users = owners.map(ownersMap);
return (
<StyledContainer>
<>
<GroupCardAvatars
header={owners.length === 1 ? 'Owner' : 'Owners'}
users={users}
avatarLimit={8}
/>
<ConditionallyRender
condition={owners.length === 1}
@ -69,7 +68,8 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
{users[0]?.name || users[0]?.description}
</StyledUserName>
}
elseShow={<div />}
/>
</StyledContainer>
</>
);
};

View File

@ -0,0 +1,11 @@
import { styled } from '@mui/material';
import { StyledDropdownSearch } from './shared.styles';
export const TableSearchInput = styled(StyledDropdownSearch)({
maxWidth: '30ch',
});
export const ScrollContainer = styled('div')({
width: '100%',
overflow: 'auto',
});

View File

@ -0,0 +1,131 @@
import { type FC, useState, useMemo } from 'react';
import { ConfigButton, type ConfigButtonProps } from './ConfigButton';
import { InputAdornment } from '@mui/material';
import Search from '@mui/icons-material/Search';
import { ChangeRequestTable } from './ChangeRequestTable';
import {
ScrollContainer,
TableSearchInput,
} from './ChangeRequestTableConfigButton.styles';
type ChangeRequestTableConfigButtonProps = Pick<
ConfigButtonProps,
'button' | 'onOpen' | 'onClose' | 'description' | 'tooltipHeader'
> & {
search: {
label: string;
placeholder: string;
};
updateProjectChangeRequestConfiguration: {
disableChangeRequests: (env: string) => void;
enableChangeRequests: (env: string, requiredApprovals: number) => void;
};
activeEnvironments: {
name: string;
type: string;
}[];
projectChangeRequestConfiguration: Record<
string,
{ requiredApprovals: number }
>;
};
export const ChangeRequestTableConfigButton: FC<
ChangeRequestTableConfigButtonProps
> = ({
button,
search,
projectChangeRequestConfiguration,
updateProjectChangeRequestConfiguration,
activeEnvironments,
...props
}) => {
const configured = useMemo(() => {
return Object.fromEntries(
Object.entries(projectChangeRequestConfiguration).map(
([name, config]) => [
name,
{ ...config, changeRequestEnabled: true },
],
),
);
}, [projectChangeRequestConfiguration]);
const tableEnvs = useMemo(
() =>
activeEnvironments.map(({ name, type }) => ({
name,
type,
...(configured[name] ?? { changeRequestEnabled: false }),
})),
[configured, activeEnvironments],
);
const onEnable = (name: string, requiredApprovals: number) => {
updateProjectChangeRequestConfiguration.enableChangeRequests(
name,
requiredApprovals,
);
};
const onDisable = (name: string) => {
updateProjectChangeRequestConfiguration.disableChangeRequests(name);
};
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [searchText, setSearchText] = useState('');
const filteredEnvs = tableEnvs.filter((env) =>
env.name.toLowerCase().includes(searchText.toLowerCase()),
);
const toggleTopItem = (event: React.KeyboardEvent) => {
if (
event.key === 'Enter' &&
searchText.trim().length > 0 &&
filteredEnvs.length > 0
) {
const firstEnv = filteredEnvs[0];
if (firstEnv.name in configured) {
onDisable(firstEnv.name);
} else {
onEnable(firstEnv.name, 1);
}
}
};
return (
<ConfigButton
button={button}
{...props}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<TableSearchInput
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
hideLabel
label={search.label}
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
onKeyDown={toggleTopItem}
/>
<ScrollContainer>
<ChangeRequestTable
environments={filteredEnvs}
enableEnvironment={onEnable}
disableEnvironment={onDisable}
/>
</ScrollContainer>
</ConfigButton>
);
};

View File

@ -0,0 +1,32 @@
import { Popover, styled } from '@mui/material';
import { visuallyHiddenStyles } from './shared.styles';
export const StyledDropdown = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
maxHeight: '70vh',
}));
export const StyledPopover = styled(Popover)(({ theme }) => ({
'& .MuiPaper-root': {
borderRadius: `${theme.shape.borderRadiusMedium}px`,
},
}));
export const HiddenDescription = styled('p')(() => ({
...visuallyHiddenStyles,
position: 'absolute',
}));
export const ButtonLabel = styled('span', {
shouldForwardProp: (prop) => prop !== 'labelWidth',
})<{ labelWidth?: string }>(({ labelWidth, theme }) => ({
width: labelWidth || 'unset',
overflowX: 'hidden',
textOverflow: 'ellipsis',
[theme.breakpoints.down('sm')]: {
width: 'max-content',
},
}));

View File

@ -0,0 +1,97 @@
import { v4 as uuidv4 } from 'uuid';
import { type FC, type ReactNode, useRef, type PropsWithChildren } from 'react';
import { Box, Button } from '@mui/material';
import {
StyledDropdown,
StyledPopover,
HiddenDescription,
ButtonLabel,
} from './ConfigButton.styles';
import { TooltipResolver } from 'component/common/TooltipResolver/TooltipResolver';
export type ConfigButtonProps = {
button: { label: string; icon: ReactNode; labelWidth?: string };
onOpen?: () => void;
onClose?: () => void;
description: string;
preventOpen?: boolean;
anchorEl: HTMLDivElement | null | undefined;
setAnchorEl: (el: HTMLDivElement | null | undefined) => void;
tooltipHeader: string;
};
export const ConfigButton: FC<PropsWithChildren<ConfigButtonProps>> = ({
button,
onOpen = () => {},
onClose = () => {},
description,
children,
preventOpen,
anchorEl,
setAnchorEl,
tooltipHeader,
}) => {
const ref = useRef<HTMLDivElement>(null);
const descriptionId = uuidv4();
const open = () => {
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
return (
<>
<Box ref={ref}>
<TooltipResolver
titleComponent={
<article>
<h3>{tooltipHeader}</h3>
<p>{description}</p>
</article>
}
variant='custom'
>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
if (!preventOpen) {
open();
}
}}
>
<ButtonLabel labelWidth={button.labelWidth}>
{button.label}
</ButtonLabel>
</Button>
</TooltipResolver>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<HiddenDescription id={descriptionId}>
{description}
</HiddenDescription>
<StyledDropdown aria-describedby={descriptionId}>
{children}
</StyledDropdown>
</StyledPopover>
</>
);
};

View File

@ -0,0 +1,15 @@
import { Checkbox, ListItem, styled } from '@mui/material';
export const StyledListItem = styled(ListItem)(({ theme }) => ({
paddingLeft: theme.spacing(1),
cursor: 'pointer',
'&:hover, &:focus': {
backgroundColor: theme.palette.action.hover,
outline: 'none',
},
minHeight: theme.spacing(4.5),
}));
export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
padding: theme.spacing(1, 1, 1, 1.5),
}));

View File

@ -0,0 +1,151 @@
import Search from '@mui/icons-material/Search';
import { type FC, useRef, useState } from 'react';
import { InputAdornment, List, ListItemText } from '@mui/material';
import { StyledDropdownSearch } from './shared.styles';
import { StyledCheckbox, StyledListItem } from './DropdownList.styles';
const useSelectionManagement = (
handleToggle: (value: string) => () => void,
) => {
const listRefs = useRef<Array<HTMLInputElement | HTMLLIElement | null>>([]);
const handleSelection = (
event: React.KeyboardEvent,
index: number,
filteredOptions: { label: string; value: string }[],
) => {
// we have to be careful not to prevent other keys e.g tab
if (event.key === 'ArrowDown' && index < listRefs.current.length - 1) {
event.preventDefault();
listRefs.current[index + 1]?.focus();
} else if (event.key === 'ArrowUp' && index > 0) {
event.preventDefault();
listRefs.current[index - 1]?.focus();
} else if (
event.key === 'Enter' &&
index === 0 &&
listRefs.current[0]?.value &&
filteredOptions.length > 0
) {
// if the search field is not empty and the user presses
// enter from the search field, toggle the topmost item in
// the filtered list event.preventDefault();
handleToggle(filteredOptions[0].value)();
} else if (
event.key === 'Enter' ||
// allow selection with space when not in the search field
(index !== 0 && event.key === ' ')
) {
event.preventDefault();
if (index > 0) {
const listItemIndex = index - 1;
handleToggle(filteredOptions[listItemIndex].value)();
}
}
};
return { listRefs, handleSelection };
};
export type DropdownListProps = {
options: Array<{ label: string; value: string }>;
onChange: (value: string) => void;
search: {
label: string;
placeholder: string;
};
multiselect?: { selectedOptions: Set<string> };
};
export const DropdownList: FC<DropdownListProps> = ({
options,
onChange,
search,
multiselect,
}) => {
const [searchText, setSearchText] = useState('');
const onSelection = (selected: string) => {
onChange(selected);
};
const { listRefs, handleSelection } = useSelectionManagement(
(selected: string) => () => onSelection(selected),
);
const filteredOptions = options?.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase()),
);
return (
<>
<StyledDropdownSearch
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
label={search.label}
hideLabel
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
inputRef={(el) => {
listRefs.current[0] = el;
}}
onKeyDown={(event) =>
handleSelection(event, 0, filteredOptions)
}
/>
<List sx={{ overflowY: 'auto' }} disablePadding>
{filteredOptions.map((option, index) => {
const labelId = `checkbox-list-label-${option.value}`;
return (
<StyledListItem
aria-describedby={labelId}
key={option.value}
dense
disablePadding
tabIndex={0}
onClick={() => {
onSelection(option.value);
}}
ref={(el) => {
listRefs.current[index + 1] = el;
}}
onKeyDown={(event) =>
handleSelection(
event,
index + 1,
filteredOptions,
)
}
>
{multiselect ? (
<StyledCheckbox
edge='start'
checked={multiselect.selectedOptions.has(
option.value,
)}
tabIndex={-1}
inputProps={{
'aria-labelledby': labelId,
}}
size='small'
disableRipple
/>
) : null}
<ListItemText id={labelId} primary={option.label} />
</StyledListItem>
);
})}
</List>
</>
);
};

View File

@ -0,0 +1,42 @@
import { type FC, useState } from 'react';
import { ConfigButton, type ConfigButtonProps } from './ConfigButton';
import { DropdownList, type DropdownListProps } from './DropdownList';
type MultiSelectConfigButtonProps = Pick<
ConfigButtonProps,
'button' | 'onOpen' | 'onClose' | 'description' | 'tooltipHeader'
> &
Pick<DropdownListProps, 'search' | 'options'> & {
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
};
export const MultiSelectConfigButton: FC<MultiSelectConfigButtonProps> = ({
selectedOptions,
onChange,
...rest
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const handleToggle = (value: string) => {
if (selectedOptions.has(value)) {
selectedOptions.delete(value);
} else {
selectedOptions.add(value);
}
onChange(new Set(selectedOptions));
};
return (
<ConfigButton {...rest} anchorEl={anchorEl} setAnchorEl={setAnchorEl}>
<DropdownList
multiselect={{
selectedOptions,
}}
onChange={handleToggle}
{...rest}
/>
</ConfigButton>
);
};

View File

@ -0,0 +1,40 @@
import { type FC, useState } from 'react';
import { ConfigButton, type ConfigButtonProps } from './ConfigButton';
import { DropdownList, type DropdownListProps } from './DropdownList';
type SingleSelectConfigButtonProps = Pick<
ConfigButtonProps,
'button' | 'onOpen' | 'onClose' | 'description' | 'tooltipHeader'
> &
Pick<DropdownListProps, 'search' | 'onChange' | 'options'>;
export const SingleSelectConfigButton: FC<SingleSelectConfigButtonProps> = ({
onChange,
...props
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [recentlyClosed, setRecentlyClosed] = useState(false);
const handleChange = (value: any) => {
onChange(value);
setAnchorEl(null);
props.onClose && props.onClose();
setRecentlyClosed(true);
// this is a hack to prevent the button from being
// auto-clicked after you select an item by pressing enter
// in the search bar for single-select lists.
setTimeout(() => setRecentlyClosed(false), 1);
};
return (
<ConfigButton
{...props}
preventOpen={recentlyClosed}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<DropdownList {...props} onChange={handleChange} />
</ConfigButton>
);
};

View File

@ -0,0 +1,34 @@
import { TextField, styled } from '@mui/material';
export const visuallyHiddenStyles = {
border: 0,
clip: 'rect(0 0 0 0)',
height: 'auto',
margin: 0,
overflow: 'hidden',
padding: 0,
position: 'absolute',
width: '1px',
whiteSpace: 'nowrap',
};
export const StyledDropdownSearch = styled(TextField, {
shouldForwardProp: (prop) => prop !== 'hideLabel',
})<{ hideLabel?: boolean }>(({ theme, hideLabel }) => ({
'& .MuiInputBase-root': {
padding: theme.spacing(0, 1.5),
borderRadius: `${theme.shape.borderRadiusMedium}px`,
},
'& .MuiInputBase-input': {
padding: theme.spacing(0.75, 0),
fontSize: theme.typography.body2.fontSize,
},
...(hideLabel
? {
label: visuallyHiddenStyles,
'fieldset > legend > span': visuallyHiddenStyles,
}
: {}),
}));

View File

@ -2,7 +2,7 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import useToast from 'hooks/useToast';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { NewProjectForm } from '../NewProjectForm';
import { NewProjectForm } from './NewProjectForm';
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import useProjectForm, {
@ -21,7 +21,7 @@ interface ICreateProjectDialogProps {
onClose: () => void;
}
const StyledDialog = styled(Dialog)(({ theme, maxWidth }) => ({
const StyledDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialog-paper': {
borderRadius: theme.shape.borderRadiusLarge,
maxWidth: theme.spacing(170),
@ -29,14 +29,13 @@ const StyledDialog = styled(Dialog)(({ theme, maxWidth }) => ({
backgroundColor: 'transparent',
},
padding: 0,
'& .MuiPaper-root > section': {
overflowX: 'hidden',
},
}));
const CREATE_PROJECT_BTN = 'CREATE_PROJECT_BTN';
const StyledButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
const StyledProjectIcon = styled(ProjectIcon)(({ theme }) => ({
fill: theme.palette.common.white,
stroke: theme.palette.common.white,
@ -166,7 +165,7 @@ export const CreateProjectDialog = ({
overrideDocumentation={setDocumentation}
clearDocumentationOverride={clearDocumentationOverride}
>
<StyledButton onClick={onClose}>Cancel</StyledButton>
<Button onClick={onClose}>Cancel</Button>
<CreateButton
name='project'
permission={CREATE_PROJECT}

View File

@ -0,0 +1,79 @@
import { Typography, styled } from '@mui/material';
import Input from 'component/common/Input/Input';
import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg';
export const StyledForm = styled('form')(({ theme }) => ({
background: theme.palette.background.default,
}));
export const StyledFormSection = styled('div')(({ theme }) => ({
'& + *': {
borderBlockStart: `1px solid ${theme.palette.divider}`,
},
padding: theme.spacing(6),
}));
export const TopGrid = styled(StyledFormSection)(({ theme }) => ({
display: 'grid',
gridTemplateAreas:
'"icon header" "icon project-name" "icon project-description"',
gridTemplateColumns: 'auto 1fr',
gap: theme.spacing(4),
}));
export const StyledIcon = styled(ProjectIcon)(({ theme }) => ({
fill: theme.palette.primary.main,
stroke: theme.palette.primary.main,
}));
export const StyledHeader = styled(Typography)({
gridArea: 'header',
alignSelf: 'center',
fontWeight: 'lighter',
});
export const ProjectNameContainer = styled('div')({
gridArea: 'project-name',
});
export const ProjectDescriptionContainer = styled('div')({
gridArea: 'project-description',
});
export const StyledInput = styled(Input)({
width: '100%',
fieldset: { border: 'none' },
});
export const OptionButtons = styled(StyledFormSection)(({ theme }) => ({
display: 'flex',
flexFlow: 'row wrap',
gap: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
flexFlow: 'column nowrap',
'div:has(button)': {
display: 'flex',
button: {
flex: 1,
},
},
},
}));
export const FormActions = styled(StyledFormSection)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(5),
justifyContent: 'flex-end',
flexFlow: 'row wrap',
[theme.breakpoints.down('sm')]: {
flexFlow: 'column nowrap',
gap: theme.spacing(2),
'& > *': {
display: 'flex',
button: {
flex: 1,
},
},
},
}));

View File

@ -1,12 +1,4 @@
import { Typography, styled } from '@mui/material';
import Input from 'component/common/Input/Input';
import type { ProjectMode } from '../hooks/useProjectEnterpriseSettingsForm';
import { ReactComponent as ProjectIcon } from 'assets/icons/projectIconSmall.svg';
import {
MultiSelectList,
SingleSelectList,
TableSelect,
} from './SelectionButton';
import type { ProjectMode } from '../../hooks/useProjectEnterpriseSettingsForm';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import StickinessIcon from '@mui/icons-material/FormatPaint';
import ProjectModeIcon from '@mui/icons-material/Adjust';
@ -17,63 +9,20 @@ import { useStickinessOptions } from 'hooks/useStickinessOptions';
import { ReactComponent as ChangeRequestIcon } from 'assets/icons/merge.svg';
import type { ReactNode } from 'react';
import theme from 'themes/theme';
const StyledForm = styled('form')(({ theme }) => ({
background: theme.palette.background.default,
}));
const StyledFormSection = styled('div')(({ theme }) => ({
'& + *': {
borderBlockStart: `1px solid ${theme.palette.divider}`,
},
padding: theme.spacing(6),
}));
const TopGrid = styled(StyledFormSection)(({ theme }) => ({
display: 'grid',
gridTemplateAreas:
'"icon header" "icon project-name" "icon project-description"',
gridTemplateColumns: 'auto 1fr',
gap: theme.spacing(4),
}));
const StyledIcon = styled(ProjectIcon)(({ theme }) => ({
fill: theme.palette.primary.main,
stroke: theme.palette.primary.main,
}));
const StyledHeader = styled(Typography)(({ theme }) => ({
gridArea: 'header',
alignSelf: 'center',
fontWeight: 'lighter',
}));
const ProjectNameContainer = styled('div')(({ theme }) => ({
gridArea: 'project-name',
}));
const ProjectDescriptionContainer = styled('div')(({ theme }) => ({
gridArea: 'project-description',
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
fieldset: { border: 'none' },
}));
const OptionButtons = styled(StyledFormSection)(({ theme }) => ({
display: 'flex',
flexFlow: 'row wrap',
gap: theme.spacing(2),
}));
const FormActions = styled(StyledFormSection)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(5),
justifyContent: 'flex-end',
flexFlow: 'row wrap',
}));
import {
FormActions,
OptionButtons,
ProjectDescriptionContainer,
ProjectNameContainer,
StyledForm,
StyledHeader,
StyledIcon,
StyledInput,
TopGrid,
} from './NewProjectForm.styles';
import { MultiSelectConfigButton } from './ConfigButtons/MultiSelectConfigButton';
import { SingleSelectConfigButton } from './ConfigButtons/SingleSelectConfigButton';
import { ChangeRequestTableConfigButton } from './ConfigButtons/ChangeRequestTableConfigButton';
type FormProps = {
projectId: string;
@ -104,6 +53,31 @@ type FormProps = {
const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT';
const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT';
const projectModeOptions = [
{ value: 'open', label: 'open' },
{ value: 'protected', label: 'protected' },
{ value: 'private', label: 'private' },
];
const configButtonData = {
environments: {
icon: <EnvironmentsIcon />,
text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`,
},
stickiness: {
icon: <StickinessIcon />,
text: 'Stickiness is used to guarantee that your users see the same result when using a gradual rollout. Default stickiness allows you to choose which field is used by default in this project.',
},
mode: {
icon: <ProjectModeIcon />,
text: 'Mode defines who should be allowed to interact and see your project. Private mode hides the project from anyone except the project owner and members.',
},
changeRequests: {
icon: <ChangeRequestIcon />,
text: 'Change requests can be configured per environment and require changes to go through an approval process before being applied.',
},
};
export const NewProjectForm: React.FC<FormProps> = ({
children,
handleSubmit,
@ -126,6 +100,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
const { isEnterprise } = useUiConfig();
const { environments: allEnvironments } = useEnvironments();
const activeEnvironments = allEnvironments.filter((env) => env.enabled);
const stickinessOptions = useStickinessOptions(projectStickiness);
const handleProjectNameUpdate = (
e: React.ChangeEvent<HTMLInputElement>,
@ -134,33 +109,6 @@ export const NewProjectForm: React.FC<FormProps> = ({
setProjectName(input);
};
const projectModeOptions = [
{ value: 'open', label: 'open' },
{ value: 'protected', label: 'protected' },
{ value: 'private', label: 'private' },
];
const stickinessOptions = useStickinessOptions(projectStickiness);
const selectionButtonData = {
environments: {
icon: <EnvironmentsIcon />,
text: `Each feature flag can have a separate configuration per environment. This setting configures which environments your project should start with.`,
},
stickiness: {
icon: <StickinessIcon />,
text: 'Stickiness is used to guarantee that your users see the same result when using a gradual rollout. Default stickiness allows you to choose which field is used by default in this project.',
},
mode: {
icon: <ProjectModeIcon />,
text: 'Mode defines who should be allowed to interact and see your project. Private mode hides the project from anyone except the project owner and members.',
},
changeRequests: {
icon: <ChangeRequestIcon />,
text: 'Change requests can be configured per environment and require changes to go through an approval process before being applied.',
},
};
const numberOfConfiguredChangeRequestEnvironments = Object.keys(
projectChangeRequestConfiguration,
).length;
@ -231,8 +179,9 @@ export const NewProjectForm: React.FC<FormProps> = ({
</TopGrid>
<OptionButtons>
<MultiSelectList
description={selectionButtonData.environments.text}
<MultiSelectConfigButton
tooltipHeader='Select project environments'
description={configButtonData.environments.text}
selectedOptions={projectEnvironments}
options={activeEnvironments.map((env) => ({
label: env.name,
@ -252,13 +201,14 @@ export const NewProjectForm: React.FC<FormProps> = ({
placeholder: 'Select project environments',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.environments)
overrideDocumentation(configButtonData.environments)
}
onClose={clearDocumentationOverride}
/>
<SingleSelectList
description={selectionButtonData.stickiness.text}
<SingleSelectConfigButton
tooltipHeader='Set default project stickiness'
description={configButtonData.stickiness.text}
options={stickinessOptions.map(({ key, ...rest }) => ({
value: key,
...rest,
@ -269,13 +219,14 @@ export const NewProjectForm: React.FC<FormProps> = ({
button={{
label: projectStickiness,
icon: <StickinessIcon />,
labelWidth: '12ch',
}}
search={{
label: 'Filter stickiness options',
placeholder: 'Select default stickiness',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.stickiness)
overrideDocumentation(configButtonData.stickiness)
}
onClose={clearDocumentationOverride}
/>
@ -283,8 +234,9 @@ export const NewProjectForm: React.FC<FormProps> = ({
<ConditionallyRender
condition={isEnterprise()}
show={
<SingleSelectList
description={selectionButtonData.mode.text}
<SingleSelectConfigButton
tooltipHeader='Set project mode'
description={configButtonData.mode.text}
options={projectModeOptions}
onChange={(value: any) => {
setProjectMode(value);
@ -299,7 +251,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
placeholder: 'Select project mode',
}}
onOpen={() =>
overrideDocumentation(selectionButtonData.mode)
overrideDocumentation(configButtonData.mode)
}
onClose={clearDocumentationOverride}
/>
@ -308,10 +260,9 @@ export const NewProjectForm: React.FC<FormProps> = ({
<ConditionallyRender
condition={isEnterprise()}
show={
<TableSelect
description={
selectionButtonData.changeRequests.text
}
<ChangeRequestTableConfigButton
tooltipHeader='Configure change requests'
description={configButtonData.changeRequests.text}
activeEnvironments={
availableChangeRequestEnvironments
}
@ -334,7 +285,7 @@ export const NewProjectForm: React.FC<FormProps> = ({
}
onOpen={() =>
overrideDocumentation(
selectionButtonData.changeRequests,
configButtonData.changeRequests,
)
}
onClose={clearDocumentationOverride}

View File

@ -1,82 +0,0 @@
import { Checkbox, ListItem, Popover, TextField, styled } from '@mui/material';
export const StyledDropdown = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
maxHeight: '70vh',
}));
export const StyledListItem = styled(ListItem)(({ theme }) => ({
paddingLeft: theme.spacing(1),
cursor: 'pointer',
'&:hover, &:focus': {
backgroundColor: theme.palette.action.hover,
outline: 'none',
},
minHeight: theme.spacing(4.5),
}));
export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
padding: theme.spacing(1, 1, 1, 1.5),
}));
export const StyledPopover = styled(Popover)(({ theme }) => ({
'& .MuiPaper-root': {
borderRadius: `${theme.shape.borderRadiusMedium}px`,
},
}));
const visuallyHiddenStyles = {
border: 0,
clip: 'rect(0 0 0 0)',
height: 'auto',
margin: 0,
overflow: 'hidden',
padding: 0,
position: 'absolute',
width: '1px',
whiteSpace: 'nowrap',
};
export const HiddenDescription = styled('p')(() => ({
...visuallyHiddenStyles,
position: 'absolute',
}));
export const StyledDropdownSearch = styled(TextField, {
shouldForwardProp: (prop) => prop !== 'hideLabel',
})<{ hideLabel?: boolean }>(({ theme, hideLabel }) => ({
'& .MuiInputBase-root': {
padding: theme.spacing(0, 1.5),
borderRadius: `${theme.shape.borderRadiusMedium}px`,
},
'& .MuiInputBase-input': {
padding: theme.spacing(0.75, 0),
fontSize: theme.typography.body2.fontSize,
},
...(hideLabel
? {
label: visuallyHiddenStyles,
'fieldset > legend > span': visuallyHiddenStyles,
}
: {}),
}));
export const TableSearchInput = styled(StyledDropdownSearch)(({ theme }) => ({
maxWidth: '30ch',
}));
export const ScrollContainer = styled('div')(({ theme }) => ({
width: '100%',
overflow: 'auto',
}));
export const ButtonLabel = styled('span', {
shouldForwardProp: (prop) => prop !== 'labelWidth',
})<{ labelWidth?: string }>(({ labelWidth }) => ({
width: labelWidth || 'unset',
}));

View File

@ -1,459 +0,0 @@
import Search from '@mui/icons-material/Search';
import { v4 as uuidv4 } from 'uuid';
import {
type FC,
type ReactNode,
useRef,
useState,
useMemo,
type PropsWithChildren,
} from 'react';
import { Box, Button, InputAdornment, List, ListItemText } from '@mui/material';
import {
StyledCheckbox,
StyledDropdown,
StyledListItem,
StyledPopover,
StyledDropdownSearch,
TableSearchInput,
HiddenDescription,
ButtonLabel,
} from './SelectionButton.styles';
import { ChangeRequestTable } from './ChangeRequestTable';
export interface IFilterItemProps {
label: ReactNode;
options: Array<{ label: string; value: string }>;
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
}
export type FilterItemParams = {
operator: string;
values: string[];
};
interface UseSelectionManagementProps {
handleToggle: (value: string) => () => void;
}
const useSelectionManagement = ({
handleToggle,
}: UseSelectionManagementProps) => {
const listRefs = useRef<Array<HTMLInputElement | HTMLLIElement | null>>([]);
const handleSelection = (
event: React.KeyboardEvent,
index: number,
filteredOptions: { label: string; value: string }[],
) => {
// we have to be careful not to prevent other keys e.g tab
if (event.key === 'ArrowDown' && index < listRefs.current.length - 1) {
event.preventDefault();
listRefs.current[index + 1]?.focus();
} else if (event.key === 'ArrowUp' && index > 0) {
event.preventDefault();
listRefs.current[index - 1]?.focus();
} else if (
event.key === 'Enter' &&
index === 0 &&
listRefs.current[0]?.value &&
filteredOptions.length > 0
) {
// if the search field is not empty and the user presses
// enter from the search field, toggle the topmost item in
// the filtered list event.preventDefault();
handleToggle(filteredOptions[0].value)();
} else if (
event.key === 'Enter' ||
// allow selection with space when not in the search field
(index !== 0 && event.key === ' ')
) {
event.preventDefault();
if (index > 0) {
const listItemIndex = index - 1;
handleToggle(filteredOptions[listItemIndex].value)();
}
}
};
return { listRefs, handleSelection };
};
type CombinedSelectProps = {
options: Array<{ label: string; value: string }>;
onChange: (value: string) => void;
button: { label: string; icon: ReactNode; labelWidth?: string };
search: {
label: string;
placeholder: string;
};
multiselect?: { selectedOptions: Set<string> };
onOpen?: () => void;
onClose?: () => void;
description: string; // visually hidden, for assistive tech
};
const CombinedSelect: FC<
PropsWithChildren<{
button: { label: string; icon: ReactNode; labelWidth?: string };
onOpen?: () => void;
onClose?: () => void;
description: string;
preventOpen?: boolean;
anchorEl: HTMLDivElement | null | undefined;
setAnchorEl: (el: HTMLDivElement | null | undefined) => void;
}>
> = ({
button,
onOpen = () => {},
onClose = () => {},
description,
children,
preventOpen,
anchorEl,
setAnchorEl,
}) => {
const ref = useRef<HTMLDivElement>(null);
const descriptionId = uuidv4();
const open = () => {
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
return (
<>
<Box ref={ref}>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
if (!preventOpen) {
open();
}
}}
>
<ButtonLabel labelWidth={button.labelWidth}>
{button.label}
</ButtonLabel>
</Button>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<HiddenDescription id={descriptionId}>
{description}
</HiddenDescription>
<StyledDropdown aria-describedby={descriptionId}>
{children}
</StyledDropdown>
</StyledPopover>
</>
);
};
const DropdownList: FC<CombinedSelectProps> = ({
options,
onChange,
search,
multiselect,
}) => {
const [searchText, setSearchText] = useState('');
const onSelection = (selected: string) => {
onChange(selected);
};
const { listRefs, handleSelection } = useSelectionManagement({
handleToggle: (selected: string) => () => onSelection(selected),
});
const filteredOptions = options?.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase()),
);
return (
<>
<StyledDropdownSearch
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
label={search.label}
hideLabel
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
inputRef={(el) => {
listRefs.current[0] = el;
}}
onKeyDown={(event) =>
handleSelection(event, 0, filteredOptions)
}
/>
<List sx={{ overflowY: 'auto' }} disablePadding>
{filteredOptions.map((option, index) => {
const labelId = `checkbox-list-label-${option.value}`;
return (
<StyledListItem
aria-describedby={labelId}
key={option.value}
dense
disablePadding
tabIndex={0}
onClick={() => {
onSelection(option.value);
}}
ref={(el) => {
listRefs.current[index + 1] = el;
}}
onKeyDown={(event) =>
handleSelection(
event,
index + 1,
filteredOptions,
)
}
>
{multiselect ? (
<StyledCheckbox
edge='start'
checked={multiselect.selectedOptions.has(
option.value,
)}
tabIndex={-1}
inputProps={{
'aria-labelledby': labelId,
}}
size='small'
disableRipple
/>
) : null}
<ListItemText id={labelId} primary={option.label} />
</StyledListItem>
);
})}
</List>
</>
);
};
type SingleSelectListProps = Pick<
CombinedSelectProps,
| 'options'
| 'button'
| 'search'
| 'onChange'
| 'onOpen'
| 'onClose'
| 'description'
>;
export const SingleSelectList: FC<SingleSelectListProps> = ({
onChange,
...props
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [recentlyClosed, setRecentlyClosed] = useState(false);
const handleChange = (value: any) => {
onChange(value);
setAnchorEl(null);
props.onClose && props.onClose();
setRecentlyClosed(true);
// this is a hack to prevent the button from being
// auto-clicked after you select an item by pressing enter
// in the search bar for single-select lists.
setTimeout(() => setRecentlyClosed(false), 1);
};
return (
<CombinedSelect
{...props}
preventOpen={recentlyClosed}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<DropdownList {...props} onChange={handleChange} />
</CombinedSelect>
);
};
type MultiselectListProps = Pick<
CombinedSelectProps,
'options' | 'button' | 'search' | 'onOpen' | 'onClose' | 'description'
> & {
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
};
export const MultiSelectList: FC<MultiselectListProps> = ({
selectedOptions,
onChange,
...rest
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const handleToggle = (value: string) => {
if (selectedOptions.has(value)) {
selectedOptions.delete(value);
} else {
selectedOptions.add(value);
}
onChange(new Set(selectedOptions));
};
return (
<CombinedSelect {...rest} anchorEl={anchorEl} setAnchorEl={setAnchorEl}>
<DropdownList
multiselect={{
selectedOptions,
}}
onChange={handleToggle}
{...rest}
/>
</CombinedSelect>
);
};
type TableSelectProps = Pick<
CombinedSelectProps,
'button' | 'search' | 'onOpen' | 'onClose' | 'description'
> & {
updateProjectChangeRequestConfiguration: {
disableChangeRequests: (env: string) => void;
enableChangeRequests: (env: string, requiredApprovals: number) => void;
};
activeEnvironments: {
name: string;
type: string;
}[];
projectChangeRequestConfiguration: Record<
string,
{ requiredApprovals: number }
>;
};
export const TableSelect: FC<TableSelectProps> = ({
button,
search,
projectChangeRequestConfiguration,
updateProjectChangeRequestConfiguration,
activeEnvironments,
onOpen = () => {},
onClose = () => {},
...props
}) => {
const configured = useMemo(() => {
return Object.fromEntries(
Object.entries(projectChangeRequestConfiguration).map(
([name, config]) => [
name,
{ ...config, changeRequestEnabled: true },
],
),
);
}, [projectChangeRequestConfiguration]);
const tableEnvs = useMemo(
() =>
activeEnvironments.map(({ name, type }) => ({
name,
type,
...(configured[name] ?? { changeRequestEnabled: false }),
})),
[configured, activeEnvironments],
);
const onEnable = (name: string, requiredApprovals: number) => {
updateProjectChangeRequestConfiguration.enableChangeRequests(
name,
requiredApprovals,
);
};
const onDisable = (name: string) => {
updateProjectChangeRequestConfiguration.disableChangeRequests(name);
};
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [searchText, setSearchText] = useState('');
const filteredEnvs = tableEnvs.filter((env) =>
env.name.toLowerCase().includes(searchText.toLowerCase()),
);
const toggleTopItem = (event: React.KeyboardEvent) => {
if (
event.key === 'Enter' &&
searchText.trim().length > 0 &&
filteredEnvs.length > 0
) {
const firstEnv = filteredEnvs[0];
if (firstEnv.name in configured) {
onDisable(firstEnv.name);
} else {
onEnable(firstEnv.name, 1);
}
}
};
return (
<CombinedSelect
button={button}
{...props}
anchorEl={anchorEl}
setAnchorEl={setAnchorEl}
>
<TableSearchInput
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
hideLabel
label={search.label}
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
onKeyDown={toggleTopItem}
/>
<ChangeRequestTable
environments={filteredEnvs}
enableEnvironment={onEnable}
disableEnvironment={onDisable}
/>
</CombinedSelect>
);
};

View File

@ -24,7 +24,7 @@ import { useUiFlag } from 'hooks/useUiFlag';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import { groupProjects } from './group-projects';
import { ProjectGroup } from './ProjectGroup';
import { CreateProjectDialog } from '../Project/CreateProject/CreateProjectDialog/CreateProjectDialog';
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect } from 'react';
/**
* A hook that provides methods to emit and listen to custom DOM events.
* @param eventName The name of the event to listen for and dispatch.
*/
export const useCustomEvent = (
eventName: string,
handler: (event: CustomEvent) => void,
) => {
const emitEvent = useCallback(() => {
const event = new CustomEvent(eventName);
document.dispatchEvent(event);
}, [eventName]);
useEffect(() => {
const eventListener = (event: Event) => handler(event as CustomEvent);
document.addEventListener(eventName, eventListener);
return () => {
document.removeEventListener(eventName, eventListener);
};
}, [eventName, handler]);
return { emitEvent };
};

View File

@ -0,0 +1,82 @@
import type React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useLastViewedFlags } from './useLastViewedFlags';
const TestComponent: React.FC<{
testId: string;
featureId: string;
projectId: string;
}> = ({ testId, featureId, projectId }) => {
const { lastViewed, setLastViewed } = useLastViewedFlags();
const handleUpdate = () => {
setLastViewed({ featureId, projectId });
};
return (
<div>
<div data-testid={testId}>
{lastViewed.map((flag, index) => (
<div
key={index}
>{`${flag.featureId} - ${flag.projectId}`}</div>
))}
</div>
<button
type='button'
onClick={handleUpdate}
data-testid={`update-${testId}`}
>
Add {featureId} {projectId}
</button>
</div>
);
};
describe('Last three unique flags are persisted and duplicates are skipped', () => {
beforeEach(() => {
localStorage.clear();
render(
<>
<TestComponent
testId='component1'
featureId='Feature1'
projectId='Project1'
/>
<TestComponent
testId='component2'
featureId='Feature2'
projectId='Project2'
/>
<TestComponent
testId='component3'
featureId='Feature3'
projectId='Project3'
/>
<TestComponent
testId='component4'
featureId='Feature4'
projectId='Project4'
/>
</>,
);
});
it('only persists the last three unique flags across components and skips duplicates', async () => {
fireEvent.click(screen.getByTestId('update-component1'));
fireEvent.click(screen.getByTestId('update-component2'));
fireEvent.click(screen.getByTestId('update-component1')); // duplicate
fireEvent.click(screen.getByTestId('update-component3'));
fireEvent.click(screen.getByTestId('update-component4'));
expect(await screen.findAllByText('Feature2 - Project2')).toHaveLength(
4,
);
expect(await screen.findAllByText('Feature3 - Project3')).toHaveLength(
4,
);
expect(await screen.findAllByText('Feature4 - Project4')).toHaveLength(
4,
);
});
});

View File

@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
import { basePath } from 'utils/formatPath';
import { useCustomEvent } from './useCustomEvent';
const MAX_ITEMS = 3;
export type LastViewedFlag = { featureId: string; projectId: string };
const removeIncorrect = (flags?: any[]): LastViewedFlag[] => {
if (!Array.isArray(flags)) return [];
return flags.filter((flag) => flag.featureId && flag.projectId);
};
const localStorageItems = (key: string) => {
return removeIncorrect(getLocalStorageItem(key) || []);
};
export const useLastViewedFlags = () => {
const key = `${basePath}:unleash-lastViewedFlags`;
const [lastViewed, setLastViewed] = useState<LastViewedFlag[]>(() =>
localStorageItems(key),
);
const { emitEvent } = useCustomEvent(
'lastViewedFlagsUpdated',
useCallback(() => {
setLastViewed(localStorageItems(key));
}, [key]),
);
useEffect(() => {
if (lastViewed) {
setLocalStorageItem(key, lastViewed);
emitEvent();
}
}, [JSON.stringify(lastViewed), key, emitEvent]);
const setCappedLastViewed = useCallback(
(flag: { featureId: string; projectId: string }) => {
if (!flag.featureId || !flag.projectId) return;
if (lastViewed.find((item) => item.featureId === flag.featureId))
return;
const updatedLastViewed = removeIncorrect([...lastViewed, flag]);
setLastViewed(
updatedLastViewed.length > MAX_ITEMS
? updatedLastViewed.slice(-MAX_ITEMS)
: updatedLastViewed,
);
},
[JSON.stringify(lastViewed)],
);
return { lastViewed, setLastViewed: setCappedLastViewed };
};

View File

@ -0,0 +1,58 @@
import type React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useLastViewedProject } from './useLastViewedProject';
const TestComponent: React.FC<{ testId: string; buttonLabel: string }> = ({
testId,
buttonLabel,
}) => {
const { lastViewed, setLastViewed } = useLastViewedProject();
return (
<div>
<span data-testid={testId}>{lastViewed}</span>
<button
type='button'
onClick={() => setLastViewed(`${buttonLabel} Project`)}
data-testid={`update-${testId}`}
>
Update to {buttonLabel} Project
</button>
</div>
);
};
describe('Synchronization between multiple components using useLastViewedProject', () => {
beforeEach(() => {
localStorage.clear();
render(
<>
<TestComponent testId='component1' buttonLabel='First' />
<TestComponent testId='component2' buttonLabel='Second' />
</>,
);
});
it('updates both components when one updates its last viewed project', async () => {
expect(screen.getByTestId('component1').textContent).toBe('');
expect(screen.getByTestId('component2').textContent).toBe('');
fireEvent.click(screen.getByTestId('update-component1'));
expect(await screen.findByTestId('component1')).toHaveTextContent(
'First Project',
);
expect(await screen.findByTestId('component2')).toHaveTextContent(
'First Project',
);
fireEvent.click(screen.getByTestId('update-component2'));
expect(await screen.findByTestId('component1')).toHaveTextContent(
'Second Project',
);
expect(await screen.findByTestId('component2')).toHaveTextContent(
'Second Project',
);
});
});

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
import { basePath } from 'utils/formatPath';
import { useCustomEvent } from './useCustomEvent';
export const useLastViewedProject = () => {
const key = `${basePath}:unleash-lastViewedProject`;
@ -9,11 +10,16 @@ export const useLastViewedProject = () => {
return getLocalStorageItem(key);
});
const { emitEvent } = useCustomEvent('lastViewedProjectUpdated', () => {
setLastViewed(getLocalStorageItem(key));
});
useEffect(() => {
if (lastViewed) {
setLocalStorageItem(key, lastViewed);
emitEvent();
}
}, [lastViewed, key]);
}, [lastViewed, key, emitEvent]);
return {
lastViewed,

View File

@ -1,32 +0,0 @@
# /perf
Testing performance testing! Files of note:
```shell
# Configure the app URL and auth token to use in performance testing.
./env.sh
# Export all the data from the app at the configured URL.
./seed/export.sh
# Import previously exported data to the app instance.
./seed/import.sh
# Measure the GZIP response size for interesting endpoints.
./test/gzip.sh
# Run a few load test scenarios against the app.
./test/artillery.sh
```
See also the following scripts in `package.json`:
```shell
# Fill the unleash_testing/seed schema with seed data.
$ yarn seed:setup
# Serve the unleash_testing/seed schema data, for exports.
$ yarn seed:serve
```
Edit files in `/test/e2e/seed` to change the amount data.

View File

@ -1,4 +0,0 @@
#!/usr/bin/env bash
export PERF_AUTH_KEY="*:*.964a287e1b728cb5f4f3e0120df92cb5"
export PERF_APP_URL="http://localhost:4242"

View File

@ -1 +0,0 @@
/export.json

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
# Export data. Delete environments since they can't be imported.
curl -H "Authorization: $PERF_AUTH_KEY" "$PERF_APP_URL/api/admin/state/export" \
| jq 'del(.environments)' \
> export.json

View File

@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: $PERF_AUTH_KEY" \
-d @export.json \
"$PERF_APP_URL/api/admin/state/import?drop=true&keep=false"

View File

@ -1,2 +0,0 @@
/artillery.json
/artillery.json.html

View File

@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
artillery run ./artillery.yaml --output artillery.json
artillery report artillery.json
echo "See artillery.json.html for results"

View File

@ -1,12 +0,0 @@
config:
target: "http://localhost:4242"
defaults:
headers:
authorization: "{{ $processEnvironment.PERF_AUTH_KEY }}"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- flow:
- get:
url: "/api/client/features"

View File

@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -feu
cd "$(dirname "$0")"
. ../env.sh
print_response_size () {
local URL
local RES
URL="$1"
RES="$(curl -s -H "Authorization: $PERF_AUTH_KEY" "$URL")"
echo
echo "$URL"
echo
echo "* Byte size: $(echo "$RES" | wc -c) bytes"
echo "* GZIP size: $(echo "$RES" | gzip -6 | wc -c) bytes"
}
print_response_size "$PERF_APP_URL/api/admin/projects"
print_response_size "$PERF_APP_URL/api/admin/features"
print_response_size "$PERF_APP_URL/api/client/features"

View File

@ -14,10 +14,8 @@ import {
FeatureStrategyUpdateEvent,
type FeatureToggle,
type FeatureToggleDTO,
type FeatureToggleLegacy,
type FeatureToggleView,
type FeatureToggleWithEnvironment,
FeatureUpdatedEvent,
FeatureVariantEvent,
type IAuditUser,
type IConstraint,
@ -1815,67 +1813,6 @@ class FeatureToggleService {
return feature;
}
// @deprecated
async storeFeatureUpdatedEventLegacy(
featureName: string,
auditUser: IAuditUser,
): Promise<FeatureToggleLegacy> {
const feature = await this.getFeatureToggleLegacy(featureName);
// Legacy event. Will not be used from v4.3.
// We do not include 'preData' on purpose.
await this.eventService.storeEvent(
new FeatureUpdatedEvent({
featureName,
data: feature,
project: feature.project,
auditUser,
}),
);
return feature;
}
// @deprecated
async toggle(
projectId: string,
featureName: string,
environment: string,
auditUser: IAuditUser,
): Promise<FeatureToggle> {
await this.featureToggleStore.get(featureName);
const isEnabled =
await this.featureEnvironmentStore.isEnvironmentEnabled(
featureName,
environment,
);
return this.updateEnabled(
projectId,
featureName,
environment,
!isEnabled,
auditUser,
);
}
// @deprecated
async getFeatureToggleLegacy(
featureName: string,
): Promise<FeatureToggleLegacy> {
const feature =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
);
const { environments, ...legacyFeature } = feature;
const defaultEnv = environments.find((e) => e.name === DEFAULT_ENV);
const strategies = defaultEnv?.strategies || [];
const enabled = defaultEnv?.enabled || false;
return {
...legacyFeature,
enabled,
strategies,
};
}
async changeProject(
featureName: string,
newProject: string,

View File

@ -5,11 +5,10 @@ import { NONE, UPDATE_FEATURE } from '../../../types/permissions';
import type { IUnleashConfig } from '../../../types/option';
import type { IUnleashServices } from '../../../types';
import type FeatureToggleService from '../feature-toggle-service';
import { featureSchema, querySchema } from '../../../schema/feature-schema';
import { querySchema } from '../../../schema/feature-schema';
import type { IFeatureToggleQuery } from '../../../types/model';
import type FeatureTagService from '../../../services/feature-tag-service';
import type { IAuthRequest } from '../../../routes/unleash-types';
import { DEFAULT_ENV } from '../../../util/constants';
import type { TagSchema } from '../../../openapi/spec/tag-schema';
import type { TagsSchema } from '../../../openapi/spec/tags-schema';
import type { OpenApiService } from '../../../services/openapi-service';
@ -184,15 +183,6 @@ class FeatureController extends Controller {
return query;
}
async getToggle(
req: Request<{ featureName: string }, any, any, any>,
res: Response,
): Promise<void> {
const name = req.params.featureName;
const feature = await this.service.getFeatureToggleLegacy(name);
res.json(feature).end();
}
async listTags(
req: Request<{ featureName: string }, any, any, any>,
res: Response<TagsSchema>,
@ -274,182 +264,5 @@ class FeatureController extends Controller {
);
res.status(200).end();
}
async createToggle(req: IAuthRequest, res: Response): Promise<void> {
const toggle = req.body;
const validatedToggle = await featureSchema.validateAsync(toggle);
const { enabled, project, name, variants = [] } = validatedToggle;
const createdFeature = await this.service.createFeatureToggle(
project,
validatedToggle,
req.audit,
true,
);
const strategies = await Promise.all(
(toggle.strategies ?? []).map(async (s) =>
this.service.createStrategy(
s,
{
projectId: project,
featureName: name,
environment: DEFAULT_ENV,
},
req.audit,
req.user,
),
),
);
await this.service.updateEnabled(
project,
name,
DEFAULT_ENV,
enabled,
req.audit,
);
await this.service.saveVariants(name, project, variants, req.audit);
res.status(201).json({
...createdFeature,
variants,
enabled,
strategies,
});
}
async updateToggle(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
const updatedFeature = req.body;
updatedFeature.name = featureName;
const projectId = await this.service.getProjectId(featureName);
const value = await featureSchema.validateAsync(updatedFeature);
await this.service.updateFeatureToggle(
projectId,
value,
featureName,
req.audit,
);
await this.service.removeAllStrategiesForEnv(featureName);
if (updatedFeature.strategies) {
await Promise.all(
updatedFeature.strategies.map(async (s) =>
this.service.createStrategy(
s,
{
projectId: projectId!!,
featureName,
environment: DEFAULT_ENV,
},
req.audit,
req.user,
),
),
);
}
await this.service.updateEnabled(
projectId!!,
featureName,
DEFAULT_ENV,
updatedFeature.enabled,
req.audit,
req.user,
);
await this.service.saveVariants(
featureName,
projectId!!,
value.variants || [],
req.audit,
);
const feature = await this.service.storeFeatureUpdatedEventLegacy(
featureName,
req.audit,
);
res.status(200).json(feature);
}
/**
* @deprecated TODO: remove?
*
* Kept to keep backward compatibility
*/
async toggle(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
const projectId = await this.service.getProjectId(featureName);
const feature = await this.service.toggle(
projectId,
featureName,
DEFAULT_ENV,
req.audit,
);
await this.service.storeFeatureUpdatedEventLegacy(
featureName,
req.audit,
);
res.status(200).json(feature);
}
async toggleOn(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
const projectId = await this.service.getProjectId(featureName);
const feature = await this.service.updateEnabled(
projectId,
featureName,
DEFAULT_ENV,
true,
req.audit,
req.user,
);
await this.service.storeFeatureUpdatedEventLegacy(
featureName,
req.audit,
);
res.json(feature);
}
async toggleOff(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
const projectId = await this.service.getProjectId(featureName);
const feature = await this.service.updateEnabled(
projectId,
featureName,
DEFAULT_ENV,
false,
req.audit,
req.user,
);
await this.service.storeFeatureUpdatedEventLegacy(
featureName,
req.audit,
);
res.json(feature);
}
async staleOn(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
await this.service.updateStale(featureName, true, req.audit);
const feature = await this.service.getFeatureToggleLegacy(featureName);
res.json(feature);
}
async staleOff(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
await this.service.updateStale(featureName, false, req.audit);
const feature = await this.service.getFeatureToggleLegacy(featureName);
res.json(feature);
}
async archiveToggle(req: IAuthRequest, res: Response): Promise<void> {
const { featureName } = req.params;
await this.service.archiveToggle(featureName, req.user, req.audit);
res.status(200).end();
}
}
export default FeatureController;

View File

@ -25,7 +25,7 @@ export const clientApplicationSchema = {
example: 'unleash-client-java:7.0.0',
},
environment: {
description: `The SDK's configured 'environment' property. Deprecated. This property does **not** control which Unleash environment the SDK gets toggles for. To control Unleash environments, use the SDKs API key.`,
description: `The SDK's configured 'environment' property. This property was deprecated in v5. This property does **not** control which Unleash environment the SDK gets toggles for. To control Unleash environments, use the SDKs API key.`,
deprecated: true,
type: 'string',
example: 'development',

View File

@ -23,7 +23,7 @@ export const clientFeaturesQuerySchema = {
type: 'string',
},
description:
'Features that are part of these projects are included in this response. (DEPRECATED) - Handled by API tokens',
'Features that are part of these projects are included in this response. This is now handled by API tokens and was marked as deprecated in v5',
example: ['new.payment.flow'],
deprecated: true,
},
@ -36,7 +36,7 @@ export const clientFeaturesQuerySchema = {
environment: {
type: 'string',
description:
'Strategies for the feature flag configured for this environment are included. (DEPRECATED) - Handled by API tokens',
'Strategies for the feature flag configured for this environment are included. This is now handled by API tokens and was marked as deprecated in v5',
deprecated: true,
},
inlineSegmentConstraints: {

View File

@ -33,7 +33,7 @@ const usernameSchema = {
deprecated: true,
type: 'string',
description:
'The name of the token. This property is deprecated. Use `tokenName` instead.',
'The name of the token. This property was deprecated in v5. Use `tokenName` instead.',
example: 'token-64523',
},
},

View File

@ -87,7 +87,7 @@ export const featureSchema = {
deprecated: true,
example: '2023-01-28T16:21:39.975Z',
description:
'The date when metrics where last collected for the feature. This field is deprecated, use the one in featureEnvironmentSchema',
'The date when metrics where last collected for the feature. This field was deprecated in v5, use the one in featureEnvironmentSchema',
},
environments: {
type: 'array',
@ -110,7 +110,8 @@ export const featureSchema = {
items: {
type: 'object',
},
description: 'This is a legacy field that will be deprecated',
description:
'This was deprecated in v5 and will be removed in a future major version',
deprecated: true,
},
tags: {

View File

@ -101,7 +101,7 @@ export const featureSearchResponseSchema = {
deprecated: true,
example: '2023-01-28T16:21:39.975Z',
description:
'The date when metrics where last collected for the feature. This field is deprecated, use the one in featureEnvironmentSchema',
'The date when metrics where last collected for the feature. This field was deprecated in v5 and will be removed in a future release, use the one in featureEnvironmentSchema',
},
environments: {
type: 'array',
@ -124,7 +124,8 @@ export const featureSearchResponseSchema = {
items: {
$ref: '#/components/schemas/variantSchema',
},
description: 'The list of feature variants',
description:
'The list of feature variants. This field was deprecated in v5',
deprecated: true,
},
strategies: {
@ -132,7 +133,7 @@ export const featureSearchResponseSchema = {
items: {
type: 'object',
},
description: 'This is a legacy field that will be deprecated',
description: 'This is a legacy field that was deprecated in v5',
deprecated: true,
},
tags: {

View File

@ -15,7 +15,7 @@ export const userSchema = {
},
isAPI: {
description:
'(Deprecated): Used internally to know which operations the user should be allowed to perform',
'Deprecated in v5. Used internally to know which operations the user should be allowed to perform',
type: 'boolean',
example: true,
deprecated: true,

View File

@ -142,7 +142,7 @@ describe('Playground API E2E', () => {
);
};
test('Returned features should be a subset of the provided toggles', async () => {
test('Returned features should be a subset of the provided flags', async () => {
await fc.assert(
fc
.asyncProperty(
@ -257,7 +257,7 @@ describe('Playground API E2E', () => {
if (features.length !== body.features.length) {
ctx.log(
`I expected the number of mapped toggles (${body.features.length}) to be the same as the number of created toggles (${features.length}), but that was not the case.`,
`I expected the number of mapped flags (${body.features.length}) to be the same as the number of created toggles (${features.length}), but that was not the case.`,
);
return false;
}

View File

@ -11,14 +11,20 @@ In the codebase, we need to have a uniform way of performing style updates.
## Decision
We have decided to move away from using makeStyles as it's currently deprecated from @material/ui, and kept alive with an
We have decided to move away from using makeStyles as it's currently deprecated from @material/ui, and kept alive with an
external interop package to maintain compatability with the latest version. The preferred path forward is to use styled components which is
supported natively in @material/ui and sparingly use the sx prop available on all mui components.
### When to use `sx` vs `styled`
As with everything else, whether to use styled components or the `sx` prop depends on the context.
Styled components have better performance characteristics, but it's fairly minor (refer to Material UI's [performance tradeoffs](https://mui.com/system/getting-started/usage/#performance-tradeoffs) doc for more information). So unless you're rendering something a lot of times, it's not really a big deal. But when in doubt: Use styled components. And when using a styled component feels like too much overhead, consider using the `sx` prop.
### Consequences: code sharing
With makeStyles it was common to reuse CSS fragments via library utilities.
In the styled components approach we use themeable functions and object literals
With makeStyles it was common to reuse CSS fragments via library utilities.
In the styled components approach we use themeable functions and object literals.
```ts
import { Theme } from '@mui/material';

View File

@ -9,9 +9,9 @@ The import and export API first appeared in Unleash 3.3.0.
:::
:::caution Deprecation notice
:::caution Removal notice
Api admin state is deprecated from version 5. We recommend using the new [Environment Import & Export](https://docs.getunleash.io/reference/deploy/environment-import-export).
Api admin state is deprecated from version 5 and removed from version 6. We recommend using the new [Environment Import & Export](https://docs.getunleash.io/reference/deploy/environment-import-export).
:::
@ -33,7 +33,7 @@ Be careful when using the `drop` parameter in production environments: cleaning
:::caution Removal notice
State Service has been removed as of Unleash 6.0
State Service has been removed as of Unleash 6
:::
@ -58,6 +58,12 @@ It is also possible to not override existing feature flags (and strategies) by u
### API Export {#api-export}
:::caution Removal notice
State API has been removed as of Unleash 6
:::
The api endpoint `/api/admin/state/export` will export feature-toggles and strategies as json by default.
You can customize the export with query parameters:
@ -76,6 +82,12 @@ For example if you want to download just feature-toggles as yaml:
### API Import {#api-import}
:::caution Removal notice
State API has been removed as of Unleash 6
:::
:::caution Importing environments in Unleash 4.19 and below
This is only relevant if you use **Unleash 4.19 or earlier**:
@ -108,6 +120,12 @@ Example usage:
## Startup import {#startup-import}
:::caution Removal notice
State service startup import has been removed as of Unleash 6
:::
You can import flags and strategies on startup by using an import file in JSON or YAML format. As with other forms of imports, you can also choose to remove the current flag and strategy configuration in the database before importing.
Unleash lets you do this both via configuration parameters and environment variables. The relevant parameters/variables are:

View File

@ -3,9 +3,9 @@ id: state
title: /api/admin/state
---
:::caution Deprecation notice
:::caution Removal notice
Api admin state is deprecated from version 5. We recommend using the new [Environment Import & Export](https://docs.getunleash.io/reference/deploy/environment-import-export).
Api admin state is deprecated from version 5 and removed in version 6. We recommend using the new [Environment Import & Export](https://docs.getunleash.io/reference/deploy/environment-import-export).
:::

View File

@ -0,0 +1,60 @@
---
title: Insights
---
import Figure from '@site/src/components/Figure/Figure.tsx'
:::info Availability
Insights is released in Unleash 6.0 and is available for Enterprise and Pro customers. For Pro customers only the Total users and Total flags charts are available.
:::
Insights is a feature designed to help you better understand and gain insights into what is happening in your Unleash instance. You can view insights across all projects or by selecting single or multiple projects using the filter.
In total, there are 6 different charts available that show information over time:
- Total users (Pro, Enterprise)
- Total flags (Pro, Enterprise)
- Average health (Enterprise)
- Median time to production (Enterprise)
- Flag evaluation metrics (Enterprise)
- Updates per environment type (Enterprise)
### Total users
The Total users chart provides information about the total number of current users in your Unleash instance. This chart helps you understand how the user base is growing or changing over time. Additionally, it shows the number of users in selected projects and how the user distribution varies among them.
<Figure img="/img/insights-total-users.png" caption="Total users chart showing the growth of users over time."/>
### Total flags
The Total flags chart displays the total number of active (not archived) feature flags across all projects. It provides insights into how the number of flags has changed over time, helping you track the growth and usage of feature flags. You can also view the data for specific projects.
<Figure img="/img/insights-total-flags.png" caption="Total flags chart illustrating the active feature flags and their trends over time."/>
### Average health
The average health chart represents the percentage of flags in the selected projects that are not stale or potentially stale. This chart helps you monitor the overall health of your feature flags, ensuring that they are actively maintained and relevant. The chart also shows how the overall health changes over time, allowing you to identify potential issues early and take corrective actions.
<Figure img="/img/insights-health.png" caption="Average health chart displaying the percentage of non-stale flags and their changes over time."/>
### Median time to production
The median time to production chart measures the average time from when a feature flag is created until it is enabled in a "production" type environment. This metric is calculated only for feature flags of the type "release" and is the median across the selected projects. Understanding this metric helps in assessing the efficiency of your development and deployment processes. It also highlights areas where you can improve to reduce time to market for new features.
<Figure img="/img/insights-production.png" caption="Median time to production chart showing the average time taken for feature flags to go live."/>
### Flag evaluation metrics
The flag evaluation metrics chart provides a summary of all flag evaluations reported by SDKs across all projects. This chart helps you understand how often feature flags are being evaluated and used within your applications. It can indicate the performance impact and the effectiveness of your feature flag implementations. By analyzing these metrics per project, you can gain deeper insights into the usage patterns and optimize accordingly.
<Figure img="/img/insights-evaluation.png" caption="Flag evaluation metrics chart summarizing the evaluations and usage patterns of feature flags."/>
### Updates per environment type
The updates per environment type chart summarizes all feature configuration updates per environment type. This chart is crucial for understanding how configuration changes propagate across different environments, such as development, testing, and production. It helps in tracking the frequency and impact of updates, ensuring that changes are consistently and safely deployed. Monitoring updates per environment type can also help identify potential bottlenecks or issues in the deployment pipeline.
<Figure img="/img/insights-updates.png" caption="Updates per environment type chart showing the summary of configuration updates across different environments."/>

View File

@ -250,6 +250,7 @@ module.exports = {
'reference/feature-toggle-variants',
'reference/front-end-api',
'reference/impression-data',
'reference/insights',
'reference/login-history',
'reference/maintenance-mode',
'reference/network-view',

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB