1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01:00
This commit is contained in:
Pranshu Khanna 2023-10-19 17:41:43 +02:00
commit d212917fd0
50 changed files with 1416 additions and 345 deletions
frontend/src
component
App.tsx
banners
Banner
externalBanners
internalBanners
changeRequest
common/Sticky
demo/DemoBanner
feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel
layout/MainLayout/DraftBanner
maintenance
messageBanners
externalMessageBanners
internalMessageBanners
playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList
hooks
api
actions/useMessageBannersApi
getters/useBanners
usePlausibleTracker.ts
index.tsx
interfaces
setupTests.ts
src

View File

@ -20,8 +20,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import MaintenanceBanner from './maintenance/MaintenanceBanner';
import { styled } from '@mui/material';
import { InitialRedirect } from './InitialRedirect';
import { InternalMessageBanners } from './messageBanners/internalMessageBanners/InternalMessageBanners';
import { ExternalMessageBanners } from './messageBanners/externalMessageBanners/ExternalMessageBanners';
import { InternalBanners } from './banners/internalBanners/InternalBanners';
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
const StyledContainer = styled('div')(() => ({
'& ul': {
@ -65,8 +65,8 @@ export const App = () => {
)}
show={<MaintenanceBanner />}
/>
<ExternalMessageBanners />
<InternalMessageBanners />
<ExternalBanners />
<InternalBanners />
<StyledContainer>
<ToastRenderer />
<Routes>

View File

@ -7,33 +7,26 @@ import {
import { styled, Icon, Link } from '@mui/material';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useNavigate } from 'react-router-dom';
import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
import { BannerDialog } from './BannerDialog/BannerDialog';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { BannerVariant, IMessageBanner } from 'interfaces/messageBanner';
import { BannerVariant, IBanner } from 'interfaces/banner';
import { Sticky } from 'component/common/Sticky/Sticky';
const StyledBar = styled('aside', {
shouldForwardProp: (prop) => prop !== 'variant' && prop !== 'sticky',
})<{ variant: BannerVariant; sticky?: boolean }>(
({ theme, variant, sticky }) => ({
position: sticky ? 'sticky' : 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette[variant].border,
background: theme.palette[variant].light,
color: theme.palette[variant].dark,
fontSize: theme.fontSizes.smallBody,
...(sticky && {
top: 0,
zIndex: theme.zIndex.sticky - 100,
}),
}),
);
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: BannerVariant }>(({ theme, variant }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette[variant].border,
background: theme.palette[variant].light,
color: theme.palette[variant].dark,
fontSize: theme.fontSizes.smallBody,
}));
const StyledIcon = styled('div', {
shouldForwardProp: (prop) => prop !== 'variant',
@ -43,11 +36,11 @@ const StyledIcon = styled('div', {
color: theme.palette[variant].main,
}));
interface IMessageBannerProps {
messageBanner: IMessageBanner;
interface IBannerProps {
banner: IBanner;
}
export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
export const Banner = ({ banner }: IBannerProps) => {
const [open, setOpen] = useState(false);
const {
@ -60,10 +53,10 @@ export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
plausibleEvent,
dialogTitle,
dialog,
} = messageBanner;
} = banner;
return (
<StyledBar variant={variant} sticky={sticky}>
const bannerBar = (
<StyledBar variant={variant}>
<StyledIcon variant={variant}>
<BannerIcon icon={icon} variant={variant} />
</StyledIcon>
@ -75,15 +68,21 @@ export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
>
{linkText}
</BannerButton>
<MessageBannerDialog
<BannerDialog
open={open}
setOpen={setOpen}
title={dialogTitle || linkText}
>
{dialog!}
</MessageBannerDialog>
</BannerDialog>
</StyledBar>
);
if (sticky) {
return <Sticky>{bannerBar}</Sticky>;
}
return bannerBar;
};
const VariantIcons = {
@ -127,7 +126,7 @@ const BannerButton = ({
const trackEvent = () => {
if (!plausibleEvent) return;
tracker.trackEvent('message_banner', {
tracker.trackEvent('banner', {
props: { event: plausibleEvent },
});
};

View File

@ -8,19 +8,19 @@ const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({
},
}));
interface IMessageBannerDialogProps {
interface IBannerDialogProps {
title: string;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
children: string;
}
export const MessageBannerDialog = ({
export const BannerDialog = ({
open,
setOpen,
title,
children,
}: IMessageBannerDialogProps) => {
}: IBannerDialogProps) => {
return (
<Dialogue
title={title}

View File

@ -0,0 +1,30 @@
import { Banner } from 'component/banners/Banner/Banner';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useVariant } from 'hooks/useVariant';
import { IBanner } from 'interfaces/banner';
export const ExternalBanners = () => {
const { uiConfig } = useUiConfig();
const bannerVariantFromMessageBannerFlag = useVariant<IBanner | IBanner[]>(
uiConfig.flags.messageBanner,
);
const bannerVariantFromBannerFlag = useVariant<IBanner | IBanner[]>(
uiConfig.flags.banner,
);
const bannerVariant =
bannerVariantFromMessageBannerFlag || bannerVariantFromBannerFlag || [];
const banners: IBanner[] = Array.isArray(bannerVariant)
? bannerVariant
: [bannerVariant];
return (
<>
{banners.map((banner) => (
<Banner key={banner.message} banner={banner} />
))}
</>
);
};

View File

@ -0,0 +1,14 @@
import { Banner } from 'component/banners/Banner/Banner';
import { useBanners } from 'hooks/api/getters/useBanners/useBanners';
export const InternalBanners = () => {
const { banners } = useBanners();
return (
<>
{banners.map((banner) => (
<Banner key={banner.id} banner={banner} />
))}
</>
);
};

View File

@ -8,6 +8,7 @@ import { AccessProvider } from '../providers/AccessProvider/AccessProvider';
import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider';
import { testServerRoute, testServerSetup } from '../../utils/testServer';
import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer';
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
const server = testServerSetup();
@ -227,12 +228,16 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
<MemoryRouter initialEntries={[path]}>
<ThemeProvider>
<AnnouncerProvider>
<Routes>
<Route
path={pathTemplate}
element={<MainLayout>{children}</MainLayout>}
/>
</Routes>
<StickyProvider>
<Routes>
<Route
path={pathTemplate}
element={
<MainLayout>{children}</MainLayout>
}
/>
</Routes>
</StickyProvider>
</AnnouncerProvider>
</ThemeProvider>
</MemoryRouter>

View File

@ -10,6 +10,7 @@ import { FC } from 'react';
import { IPermission } from '../../interfaces/user';
import { SWRConfig } from 'swr';
import { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm';
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
const server = testServerSetup();
@ -186,9 +187,14 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
<MemoryRouter initialEntries={[path]}>
<ThemeProvider>
<AnnouncerProvider>
<Routes>
<Route path={pathTemplate} element={children} />
</Routes>
<StickyProvider>
<Routes>
<Route
path={pathTemplate}
element={children}
/>
</Routes>
</StickyProvider>
</AnnouncerProvider>
</ThemeProvider>
</MemoryRouter>

View File

@ -0,0 +1,112 @@
import { render, screen, cleanup } from '@testing-library/react';
import { Sticky } from './Sticky';
import { IStickyContext, StickyContext } from './StickyContext';
import { vi, expect } from 'vitest';
describe('Sticky component', () => {
let originalConsoleError: () => void;
let mockRegisterStickyItem: () => void;
let mockUnregisterStickyItem: () => void;
let mockGetTopOffset: () => number;
let mockContextValue: IStickyContext;
beforeEach(() => {
originalConsoleError = console.error;
console.error = vi.fn();
mockRegisterStickyItem = vi.fn();
mockUnregisterStickyItem = vi.fn();
mockGetTopOffset = vi.fn(() => 10);
mockContextValue = {
registerStickyItem: mockRegisterStickyItem,
unregisterStickyItem: mockUnregisterStickyItem,
getTopOffset: mockGetTopOffset,
stickyItems: [],
};
});
afterEach(() => {
cleanup();
console.error = originalConsoleError;
});
it('renders correctly within StickyContext', () => {
render(
<StickyContext.Provider value={mockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('throws error when not wrapped in StickyContext', () => {
console.error = vi.fn();
expect(() => render(<Sticky>Content</Sticky>)).toThrow(
'Sticky component must be used within a StickyProvider',
);
});
it('applies sticky positioning', () => {
render(
<StickyContext.Provider value={mockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
const stickyElement = screen.getByText('Content');
expect(stickyElement).toHaveStyle({ position: 'sticky' });
});
it('registers and unregisters sticky item on mount/unmount', () => {
const { unmount } = render(
<StickyContext.Provider value={mockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
expect(mockRegisterStickyItem).toHaveBeenCalledTimes(1);
unmount();
expect(mockUnregisterStickyItem).toHaveBeenCalledTimes(1);
});
it('correctly sets the top value when mounted', async () => {
render(
<StickyContext.Provider value={mockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
const stickyElement = await screen.findByText('Content');
expect(stickyElement).toHaveStyle({ top: '10px' });
});
it('updates top offset when stickyItems changes', async () => {
const { rerender } = render(
<StickyContext.Provider value={mockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
let stickyElement = await screen.findByText('Content');
expect(stickyElement).toHaveStyle({ top: '10px' });
const updatedMockContextValue = {
...mockContextValue,
getTopOffset: vi.fn(() => 20),
};
rerender(
<StickyContext.Provider value={updatedMockContextValue}>
<Sticky>Content</Sticky>
</StickyContext.Provider>,
);
stickyElement = await screen.findByText('Content');
expect(stickyElement).toHaveStyle({ top: '20px' });
});
});

View File

@ -0,0 +1,80 @@
import {
HTMLAttributes,
ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { StickyContext } from './StickyContext';
import { styled } from '@mui/material';
const StyledSticky = styled('div', {
shouldForwardProp: (prop) => prop !== 'top',
})<{ top?: number }>(({ theme, top }) => ({
position: 'sticky',
zIndex: theme.zIndex.sticky - 100,
...(top !== undefined
? {
'&': {
top,
},
}
: {}),
}));
interface IStickyProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export const Sticky = ({ children, ...props }: IStickyProps) => {
const context = useContext(StickyContext);
const ref = useRef<HTMLDivElement>(null);
const [initialTopOffset, setInitialTopOffset] = useState<number | null>(
null,
);
const [top, setTop] = useState<number>();
if (!context) {
throw new Error(
'Sticky component must be used within a StickyProvider',
);
}
const { registerStickyItem, unregisterStickyItem, getTopOffset } = context;
useEffect(() => {
// We should only set the initial top offset once - when the component is mounted
// This value will be set based on the initial top that was set for this component
// After that, the top will be calculated based on the height of the previous sticky items + this initial top offset
if (ref.current && initialTopOffset === null) {
setInitialTopOffset(
parseInt(getComputedStyle(ref.current).getPropertyValue('top')),
);
}
}, []);
useEffect(() => {
// (Re)calculate the top offset based on the sticky items
setTop(getTopOffset(ref) + (initialTopOffset || 0));
}, [getTopOffset, initialTopOffset]);
useEffect(() => {
// We should register the sticky item when it is mounted and unregister it when it is unmounted
if (!ref.current) {
return;
}
registerStickyItem(ref);
return () => {
unregisterStickyItem(ref);
};
}, [ref, registerStickyItem, unregisterStickyItem]);
return (
<StyledSticky ref={ref} top={top} {...props}>
{children}
</StyledSticky>
);
};

View File

@ -0,0 +1,12 @@
import { RefObject, createContext } from 'react';
export interface IStickyContext {
stickyItems: RefObject<HTMLDivElement>[];
registerStickyItem: (ref: RefObject<HTMLDivElement>) => void;
unregisterStickyItem: (ref: RefObject<HTMLDivElement>) => void;
getTopOffset: (ref: RefObject<HTMLDivElement>) => number;
}
export const StickyContext = createContext<IStickyContext | undefined>(
undefined,
);

View File

@ -0,0 +1,160 @@
import { render, cleanup } from '@testing-library/react';
import { StickyProvider } from './StickyProvider';
import { IStickyContext, StickyContext } from './StickyContext';
import { expect } from 'vitest';
const defaultGetBoundingClientRect = {
width: 0,
height: 0,
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
toJSON() {},
};
describe('StickyProvider component', () => {
afterEach(cleanup);
it('provides the sticky context with expected functions', () => {
let receivedContext = null;
render(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
receivedContext = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
expect(receivedContext).not.toBeNull();
expect(receivedContext).toHaveProperty('stickyItems');
expect(receivedContext).toHaveProperty('registerStickyItem');
expect(receivedContext).toHaveProperty('unregisterStickyItem');
expect(receivedContext).toHaveProperty('getTopOffset');
});
it('registers and unregisters sticky items', () => {
let contextValues: IStickyContext | undefined;
const refMock = { current: document.createElement('div') };
const { rerender } = render(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
contextValues?.registerStickyItem(refMock);
rerender(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
expect(contextValues?.stickyItems).toContain(refMock);
contextValues?.unregisterStickyItem(refMock);
rerender(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
expect(contextValues?.stickyItems).not.toContain(refMock);
});
it('sorts sticky items based on their DOM position', () => {
let contextValues: IStickyContext | undefined;
const refMockA = { current: document.createElement('div') };
const refMockB = { current: document.createElement('div') };
refMockA.current.getBoundingClientRect = () => ({
...defaultGetBoundingClientRect,
top: 200,
});
refMockB.current.getBoundingClientRect = () => ({
...defaultGetBoundingClientRect,
top: 100,
});
render(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
contextValues?.registerStickyItem(refMockA);
contextValues?.registerStickyItem(refMockB);
expect(contextValues?.stickyItems[0]).toBe(refMockB);
expect(contextValues?.stickyItems[1]).toBe(refMockA);
});
it('calculates top offset correctly', () => {
let contextValues: IStickyContext | undefined;
const refMockA = { current: document.createElement('div') };
const refMockB = { current: document.createElement('div') };
refMockA.current.getBoundingClientRect = () => ({
...defaultGetBoundingClientRect,
height: 100,
});
refMockB.current.getBoundingClientRect = () => ({
...defaultGetBoundingClientRect,
height: 200,
});
const { rerender } = render(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
contextValues?.registerStickyItem(refMockA);
contextValues?.registerStickyItem(refMockB);
rerender(
<StickyProvider>
<StickyContext.Consumer>
{(context) => {
contextValues = context;
return null;
}}
</StickyContext.Consumer>
</StickyProvider>,
);
const topOffset = contextValues?.getTopOffset(refMockB);
expect(topOffset).toBe(100);
});
});

View File

@ -0,0 +1,133 @@
import { useState, useCallback, ReactNode, RefObject, useEffect } from 'react';
import { StickyContext } from './StickyContext';
interface IStickyProviderProps {
children: ReactNode;
}
export const StickyProvider = ({ children }: IStickyProviderProps) => {
const [stickyItems, setStickyItems] = useState<RefObject<HTMLDivElement>[]>(
[],
);
const [resizeListeners, setResizeListeners] = useState(
new Set<RefObject<HTMLDivElement>>(),
);
const registerStickyItem = useCallback(
(item: RefObject<HTMLDivElement>) => {
setStickyItems((prevItems) => {
// We should only register a new item if it is not already registered
if (!prevItems.includes(item)) {
// Register resize listener for the item
registerResizeListener(item);
const newItems = [...prevItems, item];
// We should try to sort the items by their top on the viewport, so that their order in the DOM is the same as their order in the array
return newItems.sort((a, b) => {
const elementA = a.current;
const elementB = b.current;
if (elementA && elementB) {
return (
elementA.getBoundingClientRect().top -
elementB.getBoundingClientRect().top
);
}
return 0;
});
}
return prevItems;
});
},
[],
);
const unregisterStickyItem = useCallback(
(ref: RefObject<HTMLDivElement>) => {
unregisterResizeListener(ref);
setStickyItems((prev) => prev.filter((item) => item !== ref));
},
[],
);
const registerResizeListener = useCallback(
(ref: RefObject<HTMLDivElement>) => {
setResizeListeners((prev) => new Set(prev).add(ref));
},
[],
);
const unregisterResizeListener = useCallback(
(ref: RefObject<HTMLDivElement>) => {
setResizeListeners((prev) => {
const newListeners = new Set(prev);
newListeners.delete(ref);
return newListeners;
});
},
[],
);
const getTopOffset = useCallback(
(ref: RefObject<HTMLDivElement>) => {
if (!stickyItems.some((item) => item === ref)) {
// Return 0 in case the item is not registered yet
return 0;
}
const stickyItemsUpToOurItem = stickyItems.slice(
0,
stickyItems.findIndex((item) => item === ref),
);
return stickyItemsUpToOurItem.reduce((acc, item) => {
if (item === ref) {
// We should not include the current item in the calculation
return acc;
}
// Accumulate the height of all sticky items above our item
const itemHeight =
item.current?.getBoundingClientRect().height || 0;
return acc + itemHeight;
}, 0);
},
[stickyItems],
);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
// We should recalculate top offsets whenever there's a resize
// This will trigger the dependency in `getTopOffset` and recalculate the top offsets in the Sticky components
setStickyItems((prev) => [...prev]);
});
resizeListeners.forEach((item) => {
if (item.current) {
resizeObserver.observe(item.current);
}
});
return () => {
if (resizeListeners.size > 0) {
resizeListeners.forEach((item) => {
if (item.current) {
resizeObserver.unobserve(item.current);
}
});
}
};
}, [resizeListeners]);
return (
<StickyContext.Provider
value={{
stickyItems,
registerStickyItem,
unregisterStickyItem,
getTopOffset,
}}
>
{children}
</StickyContext.Provider>
);
};

View File

@ -1,9 +1,8 @@
import { Button, styled } from '@mui/material';
import { Sticky } from 'component/common/Sticky/Sticky';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
const StyledBanner = styled('div')(({ theme }) => ({
position: 'sticky',
top: 0,
const StyledBanner = styled(Sticky)(({ theme }) => ({
zIndex: theme.zIndex.sticky,
display: 'flex',
gap: theme.spacing(1),

View File

@ -5,9 +5,9 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails';
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags';
import { Sticky } from 'component/common/Sticky/Sticky';
const StyledContainer = styled('div')(({ theme }) => ({
position: 'sticky',
const StyledContainer = styled(Sticky)(({ theme }) => ({
top: theme.spacing(2),
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,

View File

@ -5,6 +5,7 @@ import { ChangeRequestSidebar } from 'component/changeRequest/ChangeRequestSideb
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { IChangeRequest } from 'component/changeRequest/changeRequest.types';
import { changesCount } from 'component/changeRequest/changesCount';
import { Sticky } from 'component/common/Sticky/Sticky';
interface IDraftBannerProps {
project: string;
@ -98,10 +99,7 @@ const DraftBannerContent: FC<{
);
};
const StickyBanner = styled(Box)(({ theme }) => ({
position: 'sticky',
top: -1,
zIndex: 250 /* has to lower than header.zIndex and higher than body.zIndex */,
const StickyBanner = styled(Sticky)(({ theme }) => ({
borderTop: `1px solid ${theme.palette.warning.border}`,
borderBottom: `1px solid ${theme.palette.warning.border}`,
color: theme.palette.warning.contrastText,

View File

@ -1,5 +1,6 @@
import { styled } from '@mui/material';
import { ErrorOutlineRounded } from '@mui/icons-material';
import { Sticky } from 'component/common/Sticky/Sticky';
const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
color: theme.palette.error.main,
@ -8,7 +9,7 @@ const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
marginRight: theme.spacing(1),
}));
const StyledDiv = styled('div')(({ theme }) => ({
const StyledDiv = styled(Sticky)(({ theme }) => ({
display: 'flex',
fontSize: theme.fontSizes.smallBody,
justifyContent: 'center',
@ -18,8 +19,6 @@ const StyledDiv = styled('div')(({ theme }) => ({
height: '65px',
borderBottom: `1px solid ${theme.palette.error.border}`,
whiteSpace: 'pre-wrap',
position: 'sticky',
top: 0,
zIndex: theme.zIndex.sticky - 100,
}));

View File

@ -1,28 +0,0 @@
import { MessageBanner } from 'component/messageBanners/MessageBanner/MessageBanner';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useVariant } from 'hooks/useVariant';
import { IMessageBanner } from 'interfaces/messageBanner';
export const ExternalMessageBanners = () => {
const { uiConfig } = useUiConfig();
const messageBannerVariant =
useVariant<IMessageBanner | IMessageBanner[]>(
uiConfig.flags.messageBanner,
) || [];
const messageBanners: IMessageBanner[] = Array.isArray(messageBannerVariant)
? messageBannerVariant
: [messageBannerVariant];
return (
<>
{messageBanners.map((messageBanner) => (
<MessageBanner
key={messageBanner.message}
messageBanner={messageBanner}
/>
))}
</>
);
};

View File

@ -1,17 +0,0 @@
import { MessageBanner } from 'component/messageBanners/MessageBanner/MessageBanner';
import { useMessageBanners } from 'hooks/api/getters/useMessageBanners/useMessageBanners';
export const InternalMessageBanners = () => {
const { messageBanners } = useMessageBanners();
return (
<>
{messageBanners.map((messageBanner) => (
<MessageBanner
key={messageBanner.id}
messageBanner={messageBanner}
/>
))}
</>
);
};

View File

@ -3,6 +3,7 @@ import { render } from 'utils/testRenderer';
import React from 'react';
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { PlaygroundResultFeatureStrategyList } from './PlaygroundResultFeatureStrategyList';
import { vi } from 'vitest';
const testCases = [
{
@ -62,8 +63,72 @@ const testCases = [
expectedText:
'If environment was enabled, then this feature toggle would be TRUE with strategies evaluated like so:',
},
{
name: 'Has disabled strategies and is enabled in environment',
feature: {
strategies: {
result: true,
data: [
{
name: 'default',
parameters: {},
result: { enabled: true, evaluationStatus: 'complete' },
},
{
name: 'default',
parameters: {},
disabled: true,
result: {
enabled: 'unknown',
evaluationStatus: 'unevaluated',
},
},
],
},
isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
} as PlaygroundFeatureSchema,
expectedText:
'Disabled strategies are not evaluated for the overall result.',
},
{
name: 'Has disabled strategies and is disabled in environment',
feature: {
strategies: {
result: true,
data: [
{
name: 'default',
parameters: {},
result: { enabled: true, evaluationStatus: 'complete' },
},
{
name: 'default',
parameters: {},
disabled: true,
result: {
enabled: 'unknown',
evaluationStatus: 'unevaluated',
},
},
],
},
isEnabledInCurrentEnvironment: false,
hasUnsatisfiedDependency: false,
} as PlaygroundFeatureSchema,
expectedText:
'Disabled strategies are not evaluated for the overall result.',
},
];
vi.mock('../../../../../../hooks/useUiFlag', () => ({
useUiFlag: vi.fn().mockImplementation(() => true),
}));
afterAll(() => {
vi.clearAllMocks();
});
testCases.forEach(({ name, feature, expectedText }) => {
test(name, async () => {
render(

View File

@ -5,6 +5,7 @@ import {
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { Alert } from '@mui/material';
import { useUiFlag } from '../../../../../../hooks/useUiFlag';
interface PlaygroundResultFeatureStrategyListProps {
feature: PlaygroundFeatureSchema;
@ -15,6 +16,17 @@ export const PlaygroundResultFeatureStrategyList = ({
feature,
input,
}: PlaygroundResultFeatureStrategyListProps) => {
const playgroundImprovements = useUiFlag('playgroundImprovements');
const enabledStrategies = feature.strategies?.data?.filter(
(strategy) => !strategy.disabled,
);
const disabledStrategies = feature.strategies?.data?.filter(
(strategy) => strategy.disabled,
);
const showDisabledStrategies =
playgroundImprovements && disabledStrategies?.length > 0;
return (
<>
<ConditionallyRender
@ -39,10 +51,28 @@ export const PlaygroundResultFeatureStrategyList = ({
/>
}
elseShow={
<PlaygroundResultStrategyLists
strategies={feature?.strategies?.data || []}
input={input}
/>
<>
<PlaygroundResultStrategyLists
strategies={enabledStrategies || []}
input={input}
titlePrefix={
showDisabledStrategies ? 'Enabled' : ''
}
/>
<ConditionallyRender
condition={showDisabledStrategies}
show={
<PlaygroundResultStrategyLists
strategies={disabledStrategies}
input={input}
titlePrefix={'Disabled'}
infoText={
'Disabled strategies are not evaluated for the overall result.'
}
/>
}
/>
</>
}
/>
</>

View File

@ -4,6 +4,8 @@ import { PlaygroundStrategySchema, PlaygroundRequestSchema } from 'openapi';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DisabledStrategyExecution } from './StrategyExecution/DisabledStrategyExecution';
interface IFeatureStrategyItemProps {
strategy: PlaygroundStrategySchema;
@ -19,7 +21,8 @@ export const FeatureStrategyItem = ({
const { result } = strategy;
const theme = useTheme();
const label =
result.evaluationStatus === 'incomplete'
result.evaluationStatus === 'incomplete' ||
result.evaluationStatus === 'unevaluated'
? 'Unevaluated'
: result.enabled
? 'True'
@ -28,9 +31,10 @@ export const FeatureStrategyItem = ({
return (
<StrategyItemContainer
style={{
borderColor: result.enabled
? theme.palette.success.main
: 'none',
borderColor:
result.enabled && result.evaluationStatus === 'complete'
? theme.palette.success.main
: 'none',
}}
strategy={{ ...strategy, id: `${objectId(strategy)}` }}
orderNumber={index + 1}
@ -42,10 +46,21 @@ export const FeatureStrategyItem = ({
/>
}
>
<StrategyExecution
strategyResult={strategy}
input={input}
percentageFill={theme.palette.background.elevation2}
<ConditionallyRender
condition={Boolean(strategy.disabled)}
show={
<DisabledStrategyExecution
strategyResult={strategy}
input={input}
/>
}
elseShow={
<StrategyExecution
strategyResult={strategy}
input={input}
percentageFill={theme.palette.background.elevation2}
/>
}
/>
</StrategyItemContainer>
);

View File

@ -0,0 +1,43 @@
import { Fragment, VFC } from 'react';
import {
PlaygroundConstraintSchema,
PlaygroundRequestSchema,
PlaygroundStrategySchemaResultAnyOfEvaluationStatus,
} from 'openapi';
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { styled } from '@mui/material';
import { ConstraintAccordionView } from 'component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { ConstraintError } from './ConstraintError/ConstraintError';
import { ConstraintOk } from './ConstraintOk/ConstraintOk';
interface IConstraintExecutionWithoutResultsProps {
constraints?: PlaygroundConstraintSchema[];
}
export const ConstraintExecutionWrapper = styled('div')(() => ({
width: '100%',
display: 'flex',
flexDirection: 'column',
}));
export const ConstraintExecutionWithoutResults: VFC<
IConstraintExecutionWithoutResultsProps
> = ({ constraints }) => {
if (!constraints) return null;
return (
<ConstraintExecutionWrapper>
{constraints?.map((constraint, index) => (
<Fragment key={objectId(constraint)}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
<ConstraintAccordionView constraint={constraint} compact />
</Fragment>
))}
</ConstraintExecutionWrapper>
);
};

View File

@ -0,0 +1,85 @@
import { Fragment, VFC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { styled } from '@mui/material';
import { PlaygroundRequestSchema, PlaygroundStrategySchema } from 'openapi';
import { ConstraintExecution } from './ConstraintExecution/ConstraintExecution';
import { SegmentExecution } from './SegmentExecution/SegmentExecution';
import { PlaygroundResultStrategyExecutionParameters } from './StrategyExecutionParameters/StrategyExecutionParameters';
import { CustomStrategyParams } from './CustomStrategyParams/CustomStrategyParams';
import { formattedStrategyNames } from 'utils/strategyNames';
import { StyledBoxSummary } from './StrategyExecution.styles';
import { Badge } from 'component/common/Badge/Badge';
import { ConstraintExecutionWithoutResults } from './ConstraintExecution/ConstraintExecutionWithoutResults';
import { SegmentExecutionWithoutResult } from './SegmentExecution/SegmentExecutionWithoutResult';
interface IDisabledStrategyExecutionProps {
strategyResult: PlaygroundStrategySchema;
percentageFill?: string;
input?: PlaygroundRequestSchema;
}
const StyledStrategyExecutionWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0),
}));
export const DisabledStrategyExecution: VFC<IDisabledStrategyExecutionProps> =
({ strategyResult, input, percentageFill }) => {
const { name, constraints, segments, parameters } = strategyResult;
const hasSegments = Boolean(segments && segments.length > 0);
const hasConstraints = Boolean(constraints && constraints?.length > 0);
const hasExecutionParameters =
name !== 'default' &&
Object.keys(formattedStrategyNames).includes(name);
const hasCustomStrategyParameters =
Object.keys(parameters).length > 0 &&
strategyResult.result.evaluationStatus === 'incomplete'; // Use of custom strategy can be more explicit from the API
if (!parameters) {
return null;
}
const items = [
hasSegments && (
<SegmentExecutionWithoutResult segments={segments} />
),
hasConstraints && (
<ConstraintExecutionWithoutResults constraints={constraints} />
),
hasExecutionParameters && (
<PlaygroundResultStrategyExecutionParameters
parameters={parameters}
constraints={constraints}
input={input}
/>
),
hasCustomStrategyParameters && (
<CustomStrategyParams
strategyName={name}
parameters={parameters}
/>
),
name === 'default' && (
<StyledBoxSummary sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge>{' '}
for all users.
</StyledBoxSummary>
),
].filter(Boolean);
return (
<StyledStrategyExecutionWrapper>
{items.map((item, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledStrategyExecutionWrapper>
);
};

View File

@ -0,0 +1,47 @@
import { Fragment, VFC } from 'react';
import { PlaygroundSegmentSchema, PlaygroundRequestSchema } from 'openapi';
import { ConstraintExecution } from '../ConstraintExecution/ConstraintExecution';
import { CancelOutlined } from '@mui/icons-material';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { styled, Typography } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SegmentItem } from 'component/common/SegmentItem/SegmentItem';
import { ConstraintExecutionWithoutResults } from '../ConstraintExecution/ConstraintExecutionWithoutResults';
interface ISegmentExecutionWithoutResultProps {
segments?: PlaygroundSegmentSchema[];
}
export const SegmentExecutionWithoutResult: VFC<
ISegmentExecutionWithoutResultProps
> = ({ segments }) => {
if (!segments) return null;
return (
<>
{segments.map((segment, index) => (
<Fragment key={segment.id}>
<SegmentItem
segment={segment}
constraintList={
<ConstraintExecutionWithoutResults
constraints={segment.constraints}
/>
}
isExpanded
/>
<ConditionallyRender
condition={
// Add IF there is a next segment
index >= 0 &&
segments.length > 1 &&
// Don't add if it's the last segment item
index !== segments.length - 1
}
show={<StrategySeparator text='AND' />}
/>
</Fragment>
))}
</>
);
};

View File

@ -10,6 +10,8 @@ import { CustomStrategyParams } from './CustomStrategyParams/CustomStrategyParam
import { formattedStrategyNames } from 'utils/strategyNames';
import { StyledBoxSummary } from './StrategyExecution.styles';
import { Badge } from 'component/common/Badge/Badge';
import { ConstraintExecutionWithoutResults } from './ConstraintExecution/ConstraintExecutionWithoutResults';
import { SegmentExecutionWithoutResult } from './SegmentExecution/SegmentExecutionWithoutResult';
interface IStrategyExecutionProps {
strategyResult: PlaygroundStrategySchema;

View File

@ -8,6 +8,7 @@ import {
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { useUiFlag } from '../../../../../../../hooks/useUiFlag';
const StyledAlertWrapper = styled('div')(({ theme }) => ({
display: 'flex',
@ -31,20 +32,38 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
interface PlaygroundResultStrategyListProps {
strategies: PlaygroundStrategySchema[];
input?: PlaygroundRequestSchema;
titlePrefix?: string;
infoText?: string;
}
const StyledSubtitle = styled(Typography)(({ theme }) => ({
margin: theme.spacing(2, 1, 2, 0),
color: 'text.secondary',
}));
export const PlaygroundResultStrategyLists = ({
strategies,
input,
titlePrefix,
infoText,
}: PlaygroundResultStrategyListProps) => (
<ConditionallyRender
condition={strategies.length > 0}
show={
<>
<Typography
variant={'subtitle1'}
sx={{ mt: 2, ml: 1, mb: 2, color: 'text.secondary' }}
>{`Strategies (${strategies?.length})`}</Typography>
<StyledSubtitle variant={'subtitle1'}>{`${
titlePrefix
? titlePrefix.concat(' strategies')
: 'Strategies'
} (${strategies?.length})`}</StyledSubtitle>
<ConditionallyRender
condition={Boolean(infoText)}
show={
<StyledSubtitle variant={'subtitle2'}>
{infoText}
</StyledSubtitle>
}
/>
<Box sx={{ width: '100%' }}>
{strategies?.map((strategy, index) => (
<Fragment key={strategy.id}>
@ -91,6 +110,17 @@ export const WrappedPlaygroundResultStrategyList = ({
feature,
input,
}: IWrappedPlaygroundResultStrategyListProps) => {
const playgroundImprovements = useUiFlag('playgroundImprovements');
const enabledStrategies = feature.strategies?.data?.filter(
(strategy) => !strategy.disabled,
);
const disabledStrategies = feature.strategies?.data?.filter(
(strategy) => strategy.disabled,
);
const showDisabledStrategies =
playgroundImprovements && disabledStrategies?.length > 0;
return (
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
<StyledAlert severity={'info'} color={'warning'}>
@ -100,10 +130,26 @@ export const WrappedPlaygroundResultStrategyList = ({
</StyledAlert>
<StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists
strategies={feature.strategies?.data || []}
strategies={enabledStrategies || []}
input={input}
titlePrefix={showDisabledStrategies ? 'Enabled' : ''}
/>
</StyledListWrapper>
<ConditionallyRender
condition={showDisabledStrategies}
show={
<StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists
strategies={disabledStrategies}
input={input}
titlePrefix={'Disabled'}
infoText={
'Disabled strategies are not evaluated for the overall result.'
}
/>
</StyledListWrapper>
}
/>
</StyledAlertWrapper>
);
};

View File

@ -1,27 +1,22 @@
import { IInternalMessageBanner } from 'interfaces/messageBanner';
import { IInternalBanner } from 'interfaces/banner';
import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/message-banners';
const ENDPOINT = 'api/admin/banners';
type AddOrUpdateMessageBanner = Omit<
IInternalMessageBanner,
'id' | 'createdAt'
>;
type AddOrUpdateBanner = Omit<IInternalBanner, 'id' | 'createdAt'>;
export const useMessageBannersApi = () => {
export const useBannersApi = () => {
const { loading, makeRequest, createRequest, errors } = useAPI({
propagateErrors: true,
});
const addMessageBanner = async (
messageBanner: AddOrUpdateMessageBanner,
) => {
const requestId = 'addMessageBanner';
const addBanner = async (banner: AddOrUpdateBanner) => {
const requestId = 'addBanner';
const req = createRequest(
ENDPOINT,
{
method: 'POST',
body: JSON.stringify(messageBanner),
body: JSON.stringify(banner),
},
requestId,
);
@ -30,16 +25,16 @@ export const useMessageBannersApi = () => {
return response.json();
};
const updateMessageBanner = async (
messageBannerId: number,
messageBanner: AddOrUpdateMessageBanner,
const updateBanner = async (
bannerId: number,
banner: AddOrUpdateBanner,
) => {
const requestId = 'updateMessageBanner';
const requestId = 'updateBanner';
const req = createRequest(
`${ENDPOINT}/${messageBannerId}`,
`${ENDPOINT}/${bannerId}`,
{
method: 'PUT',
body: JSON.stringify(messageBanner),
body: JSON.stringify(banner),
},
requestId,
);
@ -47,10 +42,10 @@ export const useMessageBannersApi = () => {
await makeRequest(req.caller, req.id);
};
const removeMessageBanner = async (messageBannerId: number) => {
const requestId = 'removeMessageBanner';
const removeBanner = async (bannerId: number) => {
const requestId = 'removeBanner';
const req = createRequest(
`${ENDPOINT}/${messageBannerId}`,
`${ENDPOINT}/${bannerId}`,
{ method: 'DELETE' },
requestId,
);
@ -59,9 +54,9 @@ export const useMessageBannersApi = () => {
};
return {
addMessageBanner,
updateMessageBanner,
removeMessageBanner,
addBanner,
updateBanner,
removeBanner,
errors,
loading,
};

View File

@ -4,25 +4,24 @@ import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
import { useUiFlag } from 'hooks/useUiFlag';
import { IInternalMessageBanner } from 'interfaces/messageBanner';
import { IInternalBanner } from 'interfaces/banner';
const ENDPOINT = 'api/admin/message-banners';
const ENDPOINT = 'api/admin/banners';
export const useMessageBanners = () => {
export const useBanners = () => {
const { isEnterprise } = useUiConfig();
const internalMessageBanners = useUiFlag('internalMessageBanners');
const bannersEnabled = useUiFlag('banners');
const { data, error, mutate } = useConditionalSWR(
isEnterprise() && internalMessageBanners,
{ messageBanners: [] },
isEnterprise() && bannersEnabled,
{ banners: [] },
formatApiPath(ENDPOINT),
fetcher,
);
return useMemo(
() => ({
messageBanners: (data?.messageBanners ??
[]) as IInternalMessageBanner[],
banners: (data?.banners ?? []) as IInternalBanner[],
loading: !error && !data,
refetch: () => mutate(),
error,
@ -33,6 +32,6 @@ export const useMessageBanners = () => {
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Message Banners'))
.then(handleErrorResponses('Banners'))
.then((res) => res.json());
};

View File

@ -15,7 +15,7 @@ export type CustomEvents =
| 'change_request'
| 'favorite'
| 'maintenance'
| 'message_banner'
| 'banner'
| 'hidden_environment'
| 'project_overview'
| 'suggest_tags'

View File

@ -13,6 +13,7 @@ import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/Feedb
import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer';
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
window.global ||= window;
@ -23,10 +24,12 @@ ReactDOM.render(
<ThemeProvider>
<AnnouncerProvider>
<FeedbackCESProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
<StickyProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
</StickyProvider>
</FeedbackCESProvider>
</AnnouncerProvider>
</ThemeProvider>

View File

@ -1,6 +1,6 @@
export type BannerVariant = 'warning' | 'info' | 'error' | 'success';
export interface IMessageBanner {
export interface IBanner {
message: string;
variant?: BannerVariant;
sticky?: boolean;
@ -12,7 +12,7 @@ export interface IMessageBanner {
dialog?: string;
}
export interface IInternalMessageBanner extends IMessageBanner {
export interface IInternalBanner extends IBanner {
id: number;
enabled: boolean;
createdAt: string;

View File

@ -47,6 +47,7 @@ export type UiFlags = {
embedProxyFrontend?: boolean;
maintenanceMode?: boolean;
messageBanner?: Variant;
banner?: Variant;
featuresExportImport?: boolean;
caseInsensitiveInOperators?: boolean;
proPlanAutoCharge?: boolean;
@ -69,7 +70,7 @@ export type UiFlags = {
accessOverview?: boolean;
datadogJsonTemplate?: boolean;
dependentFeatures?: boolean;
internalMessageBanners?: boolean;
banners?: boolean;
disableEnvsOnRevive?: boolean;
playgroundImprovements?: boolean;
};

View File

@ -2,4 +2,14 @@ import '@testing-library/jest-dom';
import 'whatwg-fetch';
import 'regenerator-runtime';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver;
}
process.env.TZ = 'UTC';

View File

@ -77,6 +77,7 @@ exports[`should create default config 1`] = `
"flags": {
"accessOverview": false,
"anonymiseEventLog": false,
"banners": false,
"caseInsensitiveInOperators": false,
"customRootRolesKillSwitch": false,
"datadogJsonTemplate": false,
@ -93,7 +94,6 @@ exports[`should create default config 1`] = `
"featuresExportImport": true,
"filterInvalidClientMetrics": false,
"googleAuthEnabled": false,
"internalMessageBanners": false,
"lastSeenByEnvironment": false,
"maintenanceMode": false,
"messageBanner": {
@ -122,6 +122,7 @@ exports[`should create default config 1`] = `
"experiments": {
"accessOverview": false,
"anonymiseEventLog": false,
"banners": false,
"caseInsensitiveInOperators": false,
"customRootRolesKillSwitch": false,
"datadogJsonTemplate": false,
@ -138,7 +139,6 @@ exports[`should create default config 1`] = `
"featuresExportImport": true,
"filterInvalidClientMetrics": false,
"googleAuthEnabled": false,
"internalMessageBanners": false,
"lastSeenByEnvironment": false,
"maintenanceMode": false,
"messageBanner": {

View File

@ -41,9 +41,9 @@ import {
GROUP_UPDATED,
IConstraint,
IEvent,
MESSAGE_BANNER_CREATED,
MESSAGE_BANNER_DELETED,
MESSAGE_BANNER_UPDATED,
BANNER_CREATED,
BANNER_DELETED,
BANNER_UPDATED,
PROJECT_CREATED,
PROJECT_DELETED,
SEGMENT_CREATED,
@ -232,16 +232,16 @@ const EVENT_MAP: Record<string, IEventData> = {
action: '*{{user}}* updated group *{{event.preData.name}}*',
path: '/admin/groups',
},
[MESSAGE_BANNER_CREATED]: {
action: '*{{user}}* created message banner *{{event.data.message}}*',
[BANNER_CREATED]: {
action: '*{{user}}* created banner *{{event.data.message}}*',
path: '/admin/message-banners',
},
[MESSAGE_BANNER_DELETED]: {
action: '*{{user}}* deleted message banner *{{event.preData.message}}*',
[BANNER_DELETED]: {
action: '*{{user}}* deleted banner *{{event.preData.message}}*',
path: '/admin/message-banners',
},
[MESSAGE_BANNER_UPDATED]: {
action: '*{{user}}* updated message banner *{{event.preData.message}}*',
[BANNER_UPDATED]: {
action: '*{{user}}* updated banner *{{event.preData.message}}*',
path: '/admin/message-banners',
},
[PROJECT_CREATED]: {

View File

@ -49,9 +49,9 @@ import {
SERVICE_ACCOUNT_DELETED,
SERVICE_ACCOUNT_UPDATED,
GROUP_DELETED,
MESSAGE_BANNER_CREATED,
MESSAGE_BANNER_UPDATED,
MESSAGE_BANNER_DELETED,
BANNER_CREATED,
BANNER_UPDATED,
BANNER_DELETED,
} from '../types/events';
import { IAddonDefinition } from '../types/model';
@ -127,9 +127,9 @@ const slackAppDefinition: IAddonDefinition = {
GROUP_CREATED,
GROUP_DELETED,
GROUP_UPDATED,
MESSAGE_BANNER_CREATED,
MESSAGE_BANNER_UPDATED,
MESSAGE_BANNER_DELETED,
BANNER_CREATED,
BANNER_UPDATED,
BANNER_DELETED,
PROJECT_CREATED,
PROJECT_DELETED,
SEGMENT_CREATED,

View File

@ -96,18 +96,26 @@ export class DependentFeaturesService {
);
}
const [children, parentExists, sameProject] = await Promise.all([
this.dependentFeaturesReadModel.getChildren([child]),
this.featuresReadModel.featureExists(parent),
this.featuresReadModel.featuresInTheSameProject(child, parent),
]);
const [grandchildren, grandparents, parentExists, sameProject] =
await Promise.all([
this.dependentFeaturesReadModel.getChildren([child]),
this.dependentFeaturesReadModel.getParents(parent),
this.featuresReadModel.featureExists(parent),
this.featuresReadModel.featuresInTheSameProject(child, parent),
]);
if (children.length > 0) {
if (grandchildren.length > 0) {
throw new InvalidOperationError(
'Transitive dependency detected. Cannot add a dependency to the feature that other features depend on.',
);
}
if (grandparents.length > 0) {
throw new InvalidOperationError(
'Transitive dependency detected. Cannot add a dependency to the feature that has parent dependency.',
);
}
if (!parentExists) {
throw new InvalidOperationError(
`No active feature ${parent} exists`,

View File

@ -138,7 +138,7 @@ test('should add and delete feature dependencies', async () => {
]);
});
test('should not allow to add a parent dependency to a feature that already has children', async () => {
test('should not allow to add grandparent', async () => {
const grandparent = uuidv4();
const parent = uuidv4();
const child = uuidv4();
@ -158,8 +158,28 @@ test('should not allow to add a parent dependency to a feature that already has
);
});
test('should not allow to add non-existent parent dependency', async () => {
test('should not allow to add grandchild', async () => {
const grandparent = uuidv4();
const parent = uuidv4();
const child = uuidv4();
await app.createFeature(grandparent);
await app.createFeature(parent);
await app.createFeature(child);
await addFeatureDependency(parent, {
feature: grandparent,
});
await addFeatureDependency(
child,
{
feature: parent,
},
403,
);
});
test('should not allow to add non-existent parent dependency', async () => {
const parent = uuidv4();
const child = uuidv4();
await app.createFeature(child);

View File

@ -198,6 +198,10 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
builder.addSelectColumn('df.enabled as parent_enabled');
}
if (featureQuery?.project) {
builder.forProject(featureQuery.project);
}
const rows = await builder.internalQuery.select(
builder.getSelectColumns(),
);

View File

@ -129,7 +129,11 @@ export class FeatureToggleListBuilder {
userId,
);
});
return this;
}
}
forProject = (project: string[]) => {
this.internalQuery.whereIn('features.project', project);
}
}

View File

@ -1,16 +1,22 @@
import dbInit from '../../../../test/e2e/helpers/database-init';
import getLogger from '../../../../test/fixtures/no-logger';
import { FeatureToggleDTO, IFeatureToggleStore } from '../../../types';
import {
FeatureToggleDTO,
IFeatureToggleStore,
IProjectStore,
} from '../../../types';
let stores;
let db;
let featureToggleStore: IFeatureToggleStore;
let projectStore: IProjectStore;
beforeAll(async () => {
getLogger.setMuteError(true);
db = await dbInit('feature_toggle_store_serial', getLogger);
stores = db.stores;
featureToggleStore = stores.featureToggleStore;
projectStore = stores.projectStore;
});
afterAll(async () => {
@ -301,5 +307,24 @@ describe('potentially_stale marking', () => {
expect(potentiallyStale).toBeFalsy();
});
test('it should filter projects for playground', async () => {
await projectStore.create({
id: 'MyProject',
name: 'MyProject',
description: 'MyProject',
});
await featureToggleStore.create('default', { name: 'featureA' });
await featureToggleStore.create('MyProject', { name: 'featureB' });
const playgroundFeatures =
await featureToggleStore.getPlaygroundFeatures({
project: ['MyProject'],
});
expect(playgroundFeatures).toHaveLength(1);
expect(playgroundFeatures[0].project).toBe('MyProject');
});
});
});

View File

@ -101,7 +101,7 @@ export class PlaygroundService {
): Promise<AdvancedPlaygroundFeatureEvaluationResult[]> {
const segments = await this.segmentService.getActive();
let filteredProjects: typeof projects;
let filteredProjects: typeof projects = projects;
if (this.flagResolver.isEnabled('privateProjects')) {
const projectAccess =
await this.privateProjectChecker.getUserAccessibleProjects(

View File

@ -14,7 +14,6 @@ import FakeGroupStore from '../../../test/fixtures/fake-group-store';
import FakeEventStore from '../../../test/fixtures/fake-event-store';
import ProjectStore from '../../db/project-store';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import FeatureTypeStore from '../../db/feature-type-store';
import { FeatureEnvironmentStore } from '../../db/feature-environment-store';
import ProjectStatsStore from '../../db/project-stats-store';
import {
@ -29,7 +28,6 @@ import { FavoriteFeaturesStore } from '../../db/favorite-features-store';
import { FavoriteProjectsStore } from '../../db/favorite-projects-store';
import FakeProjectStore from '../../../test/fixtures/fake-project-store';
import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
import FakeEnvironmentStore from '../../../test/fixtures/fake-environment-store';
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store';
@ -41,8 +39,6 @@ import {
createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import { LastSeenAtReadModel } from '../../services/client-metrics/last-seen/last-seen-read-model';
import { FakeLastSeenReadModel } from '../../services/client-metrics/last-seen/fake-last-seen-read-model';
export const createProjectService = (
db: Db,
@ -63,7 +59,6 @@ export const createProjectService = (
getLogger,
flagResolver,
);
const featureTypeStore = new FeatureTypeStore(db, getLogger);
const accountStore = new AccountStore(db, getLogger);
const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
const featureEnvironmentStore = new FeatureEnvironmentStore(
@ -106,14 +101,12 @@ export const createProjectService = (
);
const privateProjectChecker = createPrivateProjectChecker(db, config);
const lastSeenReadModel = new LastSeenAtReadModel(db);
return new ProjectService(
{
projectStore,
eventStore,
featureToggleStore,
featureTypeStore,
environmentStore,
featureEnvironmentStore,
accountStore,
@ -126,7 +119,6 @@ export const createProjectService = (
favoriteService,
eventService,
privateProjectChecker,
lastSeenReadModel,
);
};
@ -138,7 +130,6 @@ export const createFakeProjectService = (
const projectStore = new FakeProjectStore();
const groupStore = new FakeGroupStore();
const featureToggleStore = new FakeFeatureToggleStore();
const featureTypeStore = new FakeFeatureTypeStore();
const accountStore = new FakeAccountStore();
const environmentStore = new FakeEnvironmentStore();
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
@ -169,14 +160,12 @@ export const createFakeProjectService = (
);
const privateProjectChecker = createFakePrivateProjectChecker();
const fakeLastSeenReadModel = new FakeLastSeenReadModel();
return new ProjectService(
{
projectStore,
eventStore,
featureToggleStore,
featureTypeStore,
environmentStore,
featureEnvironmentStore,
accountStore,
@ -189,6 +178,5 @@ export const createFakeProjectService = (
favoriteService,
eventService,
privateProjectChecker,
fakeLastSeenReadModel,
);
};

View File

@ -568,7 +568,7 @@ export class AccessService {
}
async removeDefaultProjectRoles(
owner: User,
owner: IUser,
projectId: string,
): Promise<void> {
this.logger.info(`Removing project roles for ${projectId}`);

View File

@ -1,6 +1,6 @@
import { subDays } from 'date-fns';
import { ValidationError } from 'joi';
import User, { IUser } from '../types/user';
import { IUser } from '../types/user';
import { AccessService, AccessWithRoles } from './access-service';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
@ -15,7 +15,6 @@ import {
IEventStore,
IFeatureEnvironmentStore,
IFeatureToggleStore,
IFeatureTypeStore,
IProject,
IProjectOverview,
IProjectWithCount,
@ -65,8 +64,6 @@ import { ProjectDoraMetricsSchema } from 'lib/openapi';
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import EventService from './event-service';
import { ILastSeenReadModel } from './client-metrics/last-seen/types/last-seen-read-model-type';
import { LastSeenMapper } from './client-metrics/last-seen/last-seen-mapper';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
@ -89,6 +86,10 @@ interface ICalculateStatus {
updates: IProjectStats;
}
function includes(list: number[], { id }: { id: number }): boolean {
return list.some((l) => l === id);
}
export default class ProjectService {
private projectStore: IProjectStore;
@ -98,8 +99,6 @@ export default class ProjectService {
private featureToggleStore: IFeatureToggleStore;
private featureTypeStore: IFeatureTypeStore;
private featureEnvironmentStore: IFeatureEnvironmentStore;
private environmentStore: IEnvironmentStore;
@ -120,8 +119,6 @@ export default class ProjectService {
private projectStatsStore: IProjectStatsStore;
private lastSeenReadModel: ILastSeenReadModel;
private flagResolver: IFlagResolver;
private isEnterprise: boolean;
@ -131,7 +128,6 @@ export default class ProjectService {
projectStore,
eventStore,
featureToggleStore,
featureTypeStore,
environmentStore,
featureEnvironmentStore,
accountStore,
@ -141,7 +137,6 @@ export default class ProjectService {
| 'projectStore'
| 'eventStore'
| 'featureToggleStore'
| 'featureTypeStore'
| 'environmentStore'
| 'featureEnvironmentStore'
| 'accountStore'
@ -154,7 +149,6 @@ export default class ProjectService {
favoriteService: FavoritesService,
eventService: EventService,
privateProjectChecker: IPrivateProjectChecker,
lastSeenReadModel: ILastSeenReadModel,
) {
this.projectStore = projectStore;
this.environmentStore = environmentStore;
@ -162,7 +156,6 @@ export default class ProjectService {
this.accessService = accessService;
this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleService = featureToggleService;
this.favoritesService = favoriteService;
this.privateProjectChecker = privateProjectChecker;
@ -170,7 +163,6 @@ export default class ProjectService {
this.groupService = groupService;
this.eventService = eventService;
this.projectStatsStore = projectStatsStore;
this.lastSeenReadModel = lastSeenReadModel;
this.logger = config.getLogger('services/project-service.js');
this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise;
@ -267,7 +259,7 @@ export default class ProjectService {
return data;
}
async updateProject(updatedProject: IProject, user: User): Promise<void> {
async updateProject(updatedProject: IProject, user: IUser): Promise<void> {
const preData = await this.projectStore.get(updatedProject.id);
await this.projectStore.update(updatedProject);
@ -283,7 +275,7 @@ export default class ProjectService {
async updateProjectEnterpriseSettings(
updatedProject: IProjectEnterpriseSettingsUpdate,
user: User,
user: IUser,
): Promise<void> {
const preData = await this.projectStore.get(updatedProject.id);
@ -330,7 +322,7 @@ export default class ProjectService {
async changeProject(
newProjectId: string,
featureName: string,
user: User,
user: IUser,
currentProjectId: string,
): Promise<any> {
const feature = await this.featureToggleStore.get(featureName);
@ -372,7 +364,7 @@ export default class ProjectService {
return updatedFeature;
}
async deleteProject(id: string, user: User): Promise<void> {
async deleteProject(id: string, user: IUser): Promise<void> {
if (id === DEFAULT_PROJECT) {
throw new InvalidOperationError(
'You can not delete the default project!',
@ -508,6 +500,11 @@ export default class ProjectService {
userId,
);
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.removeUserAccess(projectId, userId);
await this.eventService.storeEvent(
@ -532,6 +529,11 @@ export default class ProjectService {
groupId,
);
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.removeGroupAccess(projectId, groupId);
await this.eventService.storeEvent(
@ -598,6 +600,8 @@ export default class ProjectService {
undefined,
);
await this.validateAtLeastOneOwner(projectId, role);
await this.accessService.removeGroupFromRole(
group.id,
role.id,
@ -675,28 +679,39 @@ export default class ProjectService {
async setRolesForUser(
projectId: string,
userId: number,
roles: number[],
newRoles: number[],
createdByUserName: string,
): Promise<void> {
const existingRoles = await this.accessService.getProjectRolesForUser(
const currentRoles = await this.accessService.getProjectRolesForUser(
projectId,
userId,
);
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
const hasOwnerRole = includes(currentRoles, ownerRole);
const isRemovingOwnerRole = !includes(newRoles, ownerRole);
if (hasOwnerRole && isRemovingOwnerRole) {
await this.validateAtLeastOneOwner(projectId, ownerRole);
}
await this.accessService.setProjectRolesForUser(
projectId,
userId,
roles,
newRoles,
);
await this.eventService.storeEvent(
new ProjectAccessUserRolesUpdated({
project: projectId,
createdBy: createdByUserName,
data: {
roles,
roles: newRoles,
userId,
},
preData: {
roles: existingRoles,
roles: currentRoles,
userId,
},
}),
@ -706,17 +721,28 @@ export default class ProjectService {
async setRolesForGroup(
projectId: string,
groupId: number,
roles: number[],
newRoles: number[],
createdBy: string,
): Promise<void> {
const existingRoles = await this.accessService.getProjectRolesForGroup(
const currentRoles = await this.accessService.getProjectRolesForGroup(
projectId,
groupId,
);
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
const hasOwnerRole = includes(currentRoles, ownerRole);
const isRemovingOwnerRole = !includes(newRoles, ownerRole);
if (hasOwnerRole && isRemovingOwnerRole) {
await this.validateAtLeastOneOwner(projectId, ownerRole);
}
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.setProjectRolesForGroup(
projectId,
groupId,
roles,
newRoles,
createdBy,
);
await this.eventService.storeEvent(
@ -724,11 +750,11 @@ export default class ProjectService {
project: projectId,
createdBy,
data: {
roles,
roles: newRoles,
groupId,
},
preData: {
roles: existingRoles,
roles: currentRoles,
groupId,
},
}),
@ -1091,7 +1117,7 @@ export default class ProjectService {
return {
stats: projectStats,
name: project.name,
description: project.description,
description: project.description!,
mode: project.mode,
featureLimit: project.featureLimit,
featureNaming: project.featureNaming,

View File

@ -146,9 +146,9 @@ export const SERVICE_ACCOUNT_DELETED = 'service-account-deleted' as const;
export const FEATURE_POTENTIALLY_STALE_ON =
'feature-potentially-stale-on' as const;
export const MESSAGE_BANNER_CREATED = 'message-banner-created' as const;
export const MESSAGE_BANNER_UPDATED = 'message-banner-updated' as const;
export const MESSAGE_BANNER_DELETED = 'message-banner-deleted' as const;
export const BANNER_CREATED = 'banner-created' as const;
export const BANNER_UPDATED = 'banner-updated' as const;
export const BANNER_DELETED = 'banner-deleted' as const;
export const IEventTypes = [
APPLICATION_CREATED,
@ -263,9 +263,9 @@ export const IEventTypes = [
FEATURE_DEPENDENCY_ADDED,
FEATURE_DEPENDENCY_REMOVED,
FEATURE_DEPENDENCIES_REMOVED,
MESSAGE_BANNER_CREATED,
MESSAGE_BANNER_UPDATED,
MESSAGE_BANNER_DELETED,
BANNER_CREATED,
BANNER_UPDATED,
BANNER_DELETED,
] as const;
export type IEventType = typeof IEventTypes[number];

View File

@ -34,8 +34,7 @@ export type IFlagKey =
| 'datadogJsonTemplate'
| 'disableMetrics'
| 'useLastSeenRefactor'
| 'internalMessageBanners'
| 'internalMessageBanner'
| 'banners'
| 'separateAdminClientApi'
| 'disableEnvsOnRevive'
| 'playgroundImprovements';
@ -162,8 +161,8 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_USE_LAST_SEEN_REFACTOR,
false,
),
internalMessageBanners: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_INTERNAL_MESSAGE_BANNERS,
banners: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_BANNERS,
false,
),
separateAdminClientApi: parseEnvVarBoolean(

View File

@ -0,0 +1,9 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(`ALTER TABLE message_banners RENAME TO banners`, cb);
};
exports.down = function (db, cb) {
db.runSql(`ALTER TABLE banners RENAME TO message_banners`, cb);
};

View File

@ -47,6 +47,7 @@ process.nextTick(async () => {
datadogJsonTemplate: true,
dependentFeatures: true,
useLastSeenRefactor: true,
disableEnvsOnRevive: true,
separateAdminClientApi: true,
playgroundImprovements: true,
},

View File

@ -17,8 +17,10 @@ import {
createFeatureToggleService,
createProjectService,
} from '../../../lib/features';
import { IGroup, IUnleashStores } from 'lib/types';
import { User } from 'lib/server-impl';
let stores;
let stores: IUnleashStores;
let db: ITestDb;
let projectService: ProjectService;
@ -26,7 +28,8 @@ let accessService: AccessService;
let eventService: EventService;
let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService;
let user;
let user: User; // many methods in this test use User instead of IUser
let group: IGroup;
const isProjectUser = async (
userId: number,
@ -41,13 +44,17 @@ const isProjectUser = async (
beforeAll(async () => {
db = await dbInit('project_service_serial', getLogger);
stores = db.stores;
// @ts-ignore return type IUser type missing generateImageUrl
user = await stores.userStore.insert({
name: 'Some Name',
email: 'test@getunleash.io',
});
group = await stores.groupStore.create({
name: 'aTestGroup',
description: '',
});
const config = createTestConfig({
getLogger,
// @ts-ignore
experimental: {
flags: { privateProjects: true },
},
@ -164,9 +171,6 @@ test('should not be able to delete project with toggles', async () => {
await projectService.createProject(project, user);
await stores.featureToggleStore.create(project.id, {
name: 'test-project-delete',
project: project.id,
enabled: false,
defaultStickiness: 'default',
});
try {
@ -491,31 +495,6 @@ test('should remove user from the project', async () => {
expect(memberUsers).toHaveLength(0);
});
test('should not remove user from the project', async () => {
const project = {
id: 'remove-users-not-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const roles = await stores.roleStore.getRolesForProject(project.id);
const ownerRole = roles.find((r) => r.name === RoleName.OWNER);
await expect(async () => {
await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
test('should not change project if feature toggle project does not match current project id', async () => {
const project = {
id: 'test-change-project',
@ -528,7 +507,11 @@ test('should not change project if feature toggle project does not match current
const toggle = { name: 'test-toggle' };
await projectService.createProject(project, user);
await featureToggleService.createFeatureToggle(project.id, toggle, user);
await featureToggleService.createFeatureToggle(
project.id,
toggle,
user.email,
);
try {
await projectService.changeProject(
@ -555,7 +538,11 @@ test('should return 404 if no project is found with the project id', async () =>
const toggle = { name: 'test-toggle-2' };
await projectService.createProject(project, user);
await featureToggleService.createFeatureToggle(project.id, toggle, user);
await featureToggleService.createFeatureToggle(
project.id,
toggle,
user.email,
);
try {
await projectService.changeProject(
@ -594,7 +581,11 @@ test('should fail if user is not authorized', async () => {
await projectService.createProject(project, user);
await projectService.createProject(projectDestination, projectAdmin1);
await featureToggleService.createFeatureToggle(project.id, toggle, user);
await featureToggleService.createFeatureToggle(
project.id,
toggle,
user.email,
);
try {
await projectService.changeProject(
@ -626,7 +617,11 @@ test('should change project when checks pass', async () => {
await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user);
await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
await featureToggleService.createFeatureToggle(
projectA.id,
toggle,
user.email,
);
await projectService.changeProject(
projectB.id,
toggle.name,
@ -656,7 +651,11 @@ test('changing project should emit event even if user does not have a username s
const toggle = { name: randomId() };
await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user);
await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
await featureToggleService.createFeatureToggle(
projectA.id,
toggle,
user.email,
);
const eventsBeforeChange = await stores.eventStore.getEvents();
await projectService.changeProject(
projectB.id,
@ -686,7 +685,11 @@ test('should require equal project environments to move features', async () => {
await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user);
await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
await featureToggleService.createFeatureToggle(
projectA.id,
toggle,
user.email,
);
await stores.environmentStore.create(environment);
await environmentService.addEnvironmentToProject(
environment.name,
@ -1013,40 +1016,180 @@ test('should able to assign role without existing members', async () => {
expect(testUsers).toHaveLength(1);
});
test('should not update role for user on project when she is the owner', async () => {
const project = {
id: 'update-users-not-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
describe('ensure project has at least one owner', () => {
test('should not remove user from the project', async () => {
const project = {
id: 'remove-users-not-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const projectMember1 = await stores.userStore.insert({
name: 'Some Member',
email: 'update991@getunleash.io',
const roles = await stores.roleStore.getRolesForProject(project.id);
const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!;
await expect(async () => {
await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.removeUserAccess(project.id, user.id, 'test');
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
test('should not update role for user on project when she is the owner', async () => {
const project = {
id: 'update-users-not-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
const projectMember1 = await stores.userStore.insert({
name: 'Some Member',
email: 'update991@getunleash.io',
});
await expect(async () => {
await projectService.changeRole(
const memberRole = await stores.roleStore.getRoleByName(
RoleName.MEMBER,
);
await projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
'test',
);
await expect(async () => {
await projectService.changeRole(
project.id,
memberRole.id,
user.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.setRolesForUser(
project.id,
user.id,
[memberRole.id],
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
async function projectWithGroupOwner(projectId: string) {
const project = {
id: projectId,
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user);
const roles = await stores.roleStore.getRolesForProject(project.id);
const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!;
await projectService.addGroup(
project.id,
ownerRole.id,
group.id,
'test',
);
// this should be fine, leaving the group as the only owner
// note group has zero members, but it still acts as an owner
await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
return {
project,
group,
ownerRole,
};
}
test('should not remove group from the project', async () => {
const { project, group, ownerRole } = await projectWithGroupOwner(
'remove-group-not-allowed',
);
await expect(async () => {
await projectService.removeGroup(
project.id,
ownerRole.id,
group.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.removeGroupAccess(
project.id,
group.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
test('should not update role for group on project when she is the owner', async () => {
const { project, group } = await projectWithGroupOwner(
'update-group-not-allowed',
);
const memberRole = await stores.roleStore.getRoleByName(
RoleName.MEMBER,
);
await expect(async () => {
await projectService.changeGroupRole(
project.id,
memberRole.id,
group.id,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.setRolesForGroup(
project.id,
group.id,
[memberRole.id],
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
});
test('Should allow bulk update of group permissions', async () => {
@ -1056,7 +1199,7 @@ test('Should allow bulk update of group permissions', async () => {
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const groupStore = stores.groupStore;
const user1 = await stores.userStore.insert({
@ -1124,7 +1267,7 @@ test('Should allow bulk update of only groups', async () => {
};
const groupStore = stores.groupStore;
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const group1 = await groupStore.create({
name: 'ViewersOnly',
@ -1158,7 +1301,7 @@ test('Should allow permutations of roles, groups and users when adding a new acc
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const group1 = await stores.groupStore.create({
name: 'permutation-group-1',
@ -1211,11 +1354,13 @@ test('Should allow permutations of roles, groups and users when adding a new acc
const { users, groups } = await projectService.getAccessToProject(
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
expect(users).toHaveLength(2);
expect(users).toHaveLength(3); // the 2 added plus the one that created the project
expect(groups).toHaveLength(2);
expect(users[0].roles).toStrictEqual([role1.id, role2.id]);
expect(users[0].roles).toStrictEqual([ownerRole.id]);
expect(users[1].roles).toStrictEqual([role1.id, role2.id]);
expect(groups[0].roles).toStrictEqual([role1.id, role2.id]);
});
@ -1232,13 +1377,9 @@ test('should only count active feature toggles for project', async () => {
await stores.featureToggleStore.create(project.id, {
name: 'only-active-t1',
project: project.id,
enabled: false,
});
await stores.featureToggleStore.create(project.id, {
name: 'only-active-t2',
project: project.id,
enabled: false,
});
await featureToggleService.archiveToggle('only-active-t2', user);
@ -1261,8 +1402,6 @@ test('should list projects with all features archived', async () => {
await stores.featureToggleStore.create(project.id, {
name: 'archived-toggle',
project: project.id,
enabled: false,
});
await featureToggleService.archiveToggle('archived-toggle', user);
@ -1294,7 +1433,7 @@ test('should calculate average time to production', async () => {
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const toggles = [
{ name: 'average-prod-time' },
@ -1309,7 +1448,7 @@ test('should calculate average time to production', async () => {
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user,
user.email,
);
}),
);
@ -1360,7 +1499,7 @@ test('should calculate average time to production ignoring some items', async ()
tags: [],
});
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
await stores.environmentStore.create({
name: 'customEnv',
type: 'development',
@ -1369,7 +1508,11 @@ test('should calculate average time to production ignoring some items', async ()
// actual toggle we take for calculations
const toggle = { name: 'main-toggle' };
await featureToggleService.createFeatureToggle(project.id, toggle, user);
await featureToggleService.createFeatureToggle(
project.id,
toggle,
user.email,
);
await updateFeature(toggle.name, {
created_at: subDays(new Date(), 20),
});
@ -1384,7 +1527,11 @@ test('should calculate average time to production ignoring some items', async ()
// ignore toggles enabled in non-prod envs
const devToggle = { name: 'dev-toggle' };
await featureToggleService.createFeatureToggle(project.id, devToggle, user);
await featureToggleService.createFeatureToggle(
project.id,
devToggle,
user.email,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent({
...makeEvent(devToggle.name),
@ -1397,7 +1544,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle(
'default',
otherProjectToggle,
user,
user.email,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
@ -1408,7 +1555,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle(
project.id,
nonReleaseToggle,
user,
user.email,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
@ -1419,7 +1566,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle(
project.id,
previouslyDeleteToggle,
user,
user.email,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
@ -1441,7 +1588,7 @@ test('should get correct amount of features created in current and past window',
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const toggles = [
{ name: 'features-created' },
@ -1455,7 +1602,7 @@ test('should get correct amount of features created in current and past window',
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user,
user.email,
);
}),
);
@ -1478,7 +1625,7 @@ test('should get correct amount of features archived in current and past window'
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const toggles = [
{ name: 'features-archived' },
@ -1492,7 +1639,7 @@ test('should get correct amount of features archived in current and past window'
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user,
user.email,
);
}),
);
@ -1529,7 +1676,7 @@ test('should get correct amount of project members for current and past window',
defaultStickiness: 'default',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const users = [
{ name: 'memberOne', email: 'memberOne@getunleash.io' },
@ -1556,7 +1703,7 @@ test('should get correct amount of project members for current and past window',
);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.projectMembersAddedCurrentWindow).toBe(5);
expect(result.updates.projectMembersAddedCurrentWindow).toBe(6); // 5 members + 1 owner
expect(result.updates.projectActivityCurrentWindow).toBe(6);
expect(result.updates.projectActivityPastWindow).toBe(0);
});
@ -1569,7 +1716,7 @@ test('should return average time to production per toggle', async () => {
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const toggles = [
{ name: 'average-prod-time-pt', subdays: 7 },
@ -1584,7 +1731,7 @@ test('should return average time to production per toggle', async () => {
return featureToggleService.createFeatureToggle(
project.id,
toggle,
user,
user.email,
);
}),
);
@ -1633,8 +1780,8 @@ test('should return average time to production per toggle for a specific project
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user.id);
await projectService.createProject(project2, user.id);
await projectService.createProject(project1, user);
await projectService.createProject(project2, user);
const togglesProject1 = [
{ name: 'average-prod-time-pt-10', subdays: 7 },
@ -1652,7 +1799,7 @@ test('should return average time to production per toggle for a specific project
return featureToggleService.createFeatureToggle(
project1.id,
toggle,
user,
user.email,
);
}),
);
@ -1662,7 +1809,7 @@ test('should return average time to production per toggle for a specific project
return featureToggleService.createFeatureToggle(
project2.id,
toggle,
user,
user.email,
);
}),
);
@ -1726,7 +1873,7 @@ test('should return average time to production per toggle and include archived t
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user.id);
await projectService.createProject(project1, user);
const togglesProject1 = [
{ name: 'average-prod-time-pta-10', subdays: 7 },
@ -1739,7 +1886,7 @@ test('should return average time to production per toggle and include archived t
return featureToggleService.createFeatureToggle(
project1.id,
toggle,
user,
user.email,
);
}),
);
@ -1790,7 +1937,7 @@ describe('feature flag naming patterns', () => {
featureNaming,
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
await projectService.updateProjectEnterpriseSettings(project, user);
@ -1804,7 +1951,7 @@ describe('feature flag naming patterns', () => {
...project,
featureNaming: { pattern: newPattern },
},
user.id,
user,
);
const updatedProject = await projectService.getProject(project.id);
@ -1822,13 +1969,10 @@ test('deleting a project with archived toggles should result in any remaining ar
};
const toggleName = 'archived-and-deleted';
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
await stores.featureToggleStore.create(project.id, {
name: toggleName,
project: project.id,
enabled: false,
defaultStickiness: 'default',
});
await stores.featureToggleStore.archive(toggleName);
@ -1836,7 +1980,7 @@ test('deleting a project with archived toggles should result in any remaining ar
// bring the project back again, previously this would allow those archived toggles to be resurrected
// we now expect them to be deleted correctly
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
const toggles = await stores.featureToggleStore.getAll({
project: project.id,
@ -1852,6 +1996,6 @@ test('deleting a project with no archived toggles should not result in an error'
name: 'project-with-nothing',
};
await projectService.createProject(project, user.id);
await projectService.createProject(project, user);
await projectService.deleteProject(project.id, user);
});