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:
commit
1b3874dbe0
22
.github/workflows/dependency-review.yml
vendored
Normal file
22
.github/workflows/dependency-review.yml
vendored
Normal 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
|
@ -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"
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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={
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 = (
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -13,6 +13,7 @@ const StyledFooter = styled('footer')(({ theme }) => ({
|
||||
flexGrow: 1,
|
||||
zIndex: 100,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
overflowY: 'hidden',
|
||||
}));
|
||||
|
||||
const StyledList = styled(List)({
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -126,7 +126,7 @@ exports[`returns all baseRoutes 1`] = `
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"enterprise": false,
|
||||
"enterprise": true,
|
||||
"menu": {
|
||||
"mobile": true,
|
||||
},
|
||||
|
@ -153,7 +153,7 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
menu: { mobile: true },
|
||||
notFlag: 'killInsightsUI',
|
||||
enterprise: false,
|
||||
enterprise: true,
|
||||
},
|
||||
|
||||
// Applications
|
||||
|
@ -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> = ({
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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),
|
||||
}));
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
}));
|
@ -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}
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
@ -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}
|
@ -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',
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
|
27
frontend/src/hooks/useCustomEvent.ts
Normal file
27
frontend/src/hooks/useCustomEvent.ts
Normal 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 };
|
||||
};
|
82
frontend/src/hooks/useLastViewedFlags.test.tsx
Normal file
82
frontend/src/hooks/useLastViewedFlags.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
55
frontend/src/hooks/useLastViewedFlags.ts
Normal file
55
frontend/src/hooks/useLastViewedFlags.ts
Normal 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 };
|
||||
};
|
58
frontend/src/hooks/useLastViewedProject.test.tsx
Normal file
58
frontend/src/hooks/useLastViewedProject.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
@ -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.
|
@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export PERF_AUTH_KEY="*:*.964a287e1b728cb5f4f3e0120df92cb5"
|
||||
export PERF_APP_URL="http://localhost:4242"
|
1
perf/seed/.gitignore
vendored
1
perf/seed/.gitignore
vendored
@ -1 +0,0 @@
|
||||
/export.json
|
@ -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
|
@ -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"
|
2
perf/test/.gitignore
vendored
2
perf/test/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
/artillery.json
|
||||
/artillery.json.html
|
@ -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"
|
@ -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"
|
@ -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"
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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:
|
||||
|
@ -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).
|
||||
|
||||
:::
|
||||
|
||||
|
60
website/docs/reference/insights.mdx
Normal file
60
website/docs/reference/insights.mdx
Normal 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."/>
|
@ -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',
|
||||
|
BIN
website/static/img/insights-evaluation.png
Normal file
BIN
website/static/img/insights-evaluation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
BIN
website/static/img/insights-health.png
Normal file
BIN
website/static/img/insights-health.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 73 KiB |
BIN
website/static/img/insights-production.png
Normal file
BIN
website/static/img/insights-production.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
BIN
website/static/img/insights-total-flags.png
Normal file
BIN
website/static/img/insights-total-flags.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
website/static/img/insights-total-users.png
Normal file
BIN
website/static/img/insights-total-users.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
BIN
website/static/img/insights-updates.png
Normal file
BIN
website/static/img/insights-updates.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
Loading…
Reference in New Issue
Block a user