diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..af1d561765 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -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 diff --git a/docker/package.json b/docker/package.json index ec598165bc..e4e5b0da5b 100644 --- a/docker/package.json +++ b/docker/package.json @@ -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" } diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx index e04f0e41b9..17f9c503a9 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/NewGroupCardAvatars.tsx @@ -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 = ({ /> ))} 9} + condition={users.length > avatarLimit} show={ +{users.length - shownUsers.length} diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.test.ts b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.test.ts index 9cd1b26709..b15400e129 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.test.ts +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.test.ts @@ -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, diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.ts b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.ts index 1e8c2d9b27..3e5472928b 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.ts +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeOverwriteWarning/strategy-change-diff-calculation.ts @@ -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, ); } diff --git a/frontend/src/component/demo/DemoTopics/DemoTopics.tsx b/frontend/src/component/demo/DemoTopics/DemoTopics.tsx index c1c6552e55..7f50086b10 100644 --- a/frontend/src/component/demo/DemoTopics/DemoTopics.tsx +++ b/frontend/src/component/demo/DemoTopics/DemoTopics.tsx @@ -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, diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index 6b87db9ea8..3d6b7a0f14 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -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 ( diff --git a/frontend/src/component/insights/InsightsCharts.tsx b/frontend/src/component/insights/InsightsCharts.tsx index 637bde06d4..932aa83d3f 100644 --- a/frontend/src/component/insights/InsightsCharts.tsx +++ b/frontend/src/component/insights/InsightsCharts.tsx @@ -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 = ({ allMetricsDatapoints, loading, }) => { + const { isEnterprise } = useUiConfig(); const showAllProjects = projects[0] === allOption.id; const isOneProjectSelected = projects.length === 1; @@ -171,63 +173,79 @@ export const InsightsCharts: VFC = ({ } /> - - - - - - - - - - - - + + + + + + + + + + + + + + + } + /> - - - - theme.spacing(2) }} - > - - + + + + + theme.spacing(2) }} + > + + + + } + /> ); }; diff --git a/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx b/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx index 75fc56a9c3..cfb1210556 100644 --- a/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx +++ b/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx @@ -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 = ({ actions }) => { })} > Insights{' '} - Beta } actions={ diff --git a/frontend/src/component/insights/hooks/useFilteredFlagsSummary.test.ts b/frontend/src/component/insights/hooks/useFilteredFlagsSummary.test.ts index ed1d0fad36..96dee4b62e 100644 --- a/frontend/src/component/insights/hooks/useFilteredFlagsSummary.test.ts +++ b/frontend/src/component/insights/hooks/useFilteredFlagsSummary.test.ts @@ -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, }); }); }); diff --git a/frontend/src/component/insights/hooks/useFilteredFlagsSummary.ts b/frontend/src/component/insights/hooks/useFilteredFlagsSummary.ts index eab5630e89..3006114609 100644 --- a/frontend/src/component/insights/hooks/useFilteredFlagsSummary.ts +++ b/frontend/src/component/insights/hooks/useFilteredFlagsSummary.ts @@ -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 = ( diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx index ac10cdb769..49ed0dfedb 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx @@ -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 ( + + {flags.map((flag) => ( + + + + ))} + + ); +}; + export const PrimaryNavigationList: FC<{ mode: NavigationMode; onClick: (activeItem: string) => void; @@ -187,7 +212,12 @@ export const SecondaryNavigation: FC<{ return ( { onExpandChange(expand); @@ -208,7 +238,7 @@ export const RecentProjectsNavigation: FC<{ {mode === 'full' && ( Recent project @@ -221,3 +251,22 @@ export const RecentProjectsNavigation: FC<{ ); }; + +export const RecentFlagsNavigation: FC<{ + mode: NavigationMode; + flags: { featureId: string; projectId: string }[]; + onClick: () => void; +}> = ({ mode, onClick, flags }) => { + return ( + + {mode === 'full' && ( + + Recent flags + + )} + + + ); +}; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx index 4a65500afa..9c247090f2 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.test.tsx @@ -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 ; +}; + +test('print recent projects and flags', async () => { + render( + , + ); + + await screen.findByText('projectA'); + await screen.findByText('featureA'); +}); diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx index b9649a023f..e35eaad4bd 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationSidebar.tsx @@ -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 ( - - + { + changeExpanded('configure', expand); + }} + mode={mode} + title='Configure' + > + + + {mode === 'full' && ( { - changeExpanded('configure', expand); + changeExpanded('admin', expand); }} mode={mode} - title='Configure' + title='Admin' > - {mode === 'full' && ( - { - changeExpanded('admin', expand); - }} - mode={mode} - title='Admin' - > - - - )} + )} - {mode === 'mini' && ( - { - changeExpanded('admin', true); - setMode('full'); - }} - /> - )} - - {showRecentProject && ( - setActiveItem('/projects')} - /> - )} - - { - setMode(mode === 'full' ? 'mini' : 'full'); + changeExpanded('admin', true); + setMode('full'); }} /> - + )} + + {showRecentProject && ( + setActiveItem('/projects')} + /> + )} + + {showRecentFlags && ( + setActiveItem('/projects')} + /> + )} + + { + setMode(mode === 'full' ? 'mini' : 'full'); + }} + /> ); }; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/ShowHide.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/ShowHide.tsx index 7ee109dcdf..06ae806eaf 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/ShowHide.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/ShowHide.tsx @@ -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 ( - - {mode === 'full' && ( - ({ - color: theme.palette.neutral.main, - fontSize: 'small', - })} - > - Hide (⌘ + B) - - )} - - {mode === 'full' ? ( - - ) : ( - - - + + + {mode === 'full' && ( + ({ + color: theme.palette.neutral.main, + fontSize: 'small', + })} + > + Hide (⌘ + B) + )} - - + + {mode === 'full' ? ( + + ) : ( + + + + )} + + + ); }; diff --git a/frontend/src/component/menu/Footer/Footer.tsx b/frontend/src/component/menu/Footer/Footer.tsx index fea9d1e076..416c7bc798 100644 --- a/frontend/src/component/menu/Footer/Footer.tsx +++ b/frontend/src/component/menu/Footer/Footer.tsx @@ -13,6 +13,7 @@ const StyledFooter = styled('footer')(({ theme }) => ({ flexGrow: 1, zIndex: 100, backgroundColor: theme.palette.background.paper, + overflowY: 'hidden', })); const StyledList = styled(List)({ diff --git a/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap b/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap index c08f1ef092..e92281664a 100644 --- a/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap +++ b/frontend/src/component/menu/Footer/__snapshots__/Footer.test.tsx.snap @@ -3,7 +3,7 @@ exports[`should render DrawerMenu 1`] = ` [