mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-23 00:16:25 +01:00
Merge branch 'main' of https://github.com/Unleash/unleash
This commit is contained in:
commit
d212917fd0
frontend/src
component
App.tsx
banners
Banner
externalBanners
internalBanners
changeRequest
common/Sticky
demo/DemoBanner
feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel
layout/MainLayout/DraftBanner
maintenance
messageBanners
playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList
PlaygroundResultFeatureStrategyList.test.tsxPlaygroundResultFeatureStrategyList.tsx
StrategyList
StrategyItem
FeatureStrategyItem.tsx
playgroundResultStrategyLists.tsxStrategyExecution
hooks
index.tsxinterfaces
setupTests.tssrc
lib
__snapshots__
addons
features
services
types
migrations
server-dev.tstest/e2e/services
@ -20,8 +20,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import MaintenanceBanner from './maintenance/MaintenanceBanner';
|
import MaintenanceBanner from './maintenance/MaintenanceBanner';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { InitialRedirect } from './InitialRedirect';
|
import { InitialRedirect } from './InitialRedirect';
|
||||||
import { InternalMessageBanners } from './messageBanners/internalMessageBanners/InternalMessageBanners';
|
import { InternalBanners } from './banners/internalBanners/InternalBanners';
|
||||||
import { ExternalMessageBanners } from './messageBanners/externalMessageBanners/ExternalMessageBanners';
|
import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
|
||||||
|
|
||||||
const StyledContainer = styled('div')(() => ({
|
const StyledContainer = styled('div')(() => ({
|
||||||
'& ul': {
|
'& ul': {
|
||||||
@ -65,8 +65,8 @@ export const App = () => {
|
|||||||
)}
|
)}
|
||||||
show={<MaintenanceBanner />}
|
show={<MaintenanceBanner />}
|
||||||
/>
|
/>
|
||||||
<ExternalMessageBanners />
|
<ExternalBanners />
|
||||||
<InternalMessageBanners />
|
<InternalBanners />
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<ToastRenderer />
|
<ToastRenderer />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
@ -7,33 +7,26 @@ import {
|
|||||||
import { styled, Icon, Link } from '@mui/material';
|
import { styled, Icon, Link } from '@mui/material';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
|
import { BannerDialog } from './BannerDialog/BannerDialog';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
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', {
|
const StyledBar = styled('aside', {
|
||||||
shouldForwardProp: (prop) => prop !== 'variant' && prop !== 'sticky',
|
shouldForwardProp: (prop) => prop !== 'variant',
|
||||||
})<{ variant: BannerVariant; sticky?: boolean }>(
|
})<{ variant: BannerVariant }>(({ theme, variant }) => ({
|
||||||
({ theme, variant, sticky }) => ({
|
display: 'flex',
|
||||||
position: sticky ? 'sticky' : 'relative',
|
alignItems: 'center',
|
||||||
zIndex: 1,
|
justifyContent: 'center',
|
||||||
display: 'flex',
|
padding: theme.spacing(1),
|
||||||
alignItems: 'center',
|
gap: theme.spacing(1),
|
||||||
justifyContent: 'center',
|
borderBottom: '1px solid',
|
||||||
padding: theme.spacing(1),
|
borderColor: theme.palette[variant].border,
|
||||||
gap: theme.spacing(1),
|
background: theme.palette[variant].light,
|
||||||
borderBottom: '1px solid',
|
color: theme.palette[variant].dark,
|
||||||
borderColor: theme.palette[variant].border,
|
fontSize: theme.fontSizes.smallBody,
|
||||||
background: theme.palette[variant].light,
|
}));
|
||||||
color: theme.palette[variant].dark,
|
|
||||||
fontSize: theme.fontSizes.smallBody,
|
|
||||||
...(sticky && {
|
|
||||||
top: 0,
|
|
||||||
zIndex: theme.zIndex.sticky - 100,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const StyledIcon = styled('div', {
|
const StyledIcon = styled('div', {
|
||||||
shouldForwardProp: (prop) => prop !== 'variant',
|
shouldForwardProp: (prop) => prop !== 'variant',
|
||||||
@ -43,11 +36,11 @@ const StyledIcon = styled('div', {
|
|||||||
color: theme.palette[variant].main,
|
color: theme.palette[variant].main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IMessageBannerProps {
|
interface IBannerProps {
|
||||||
messageBanner: IMessageBanner;
|
banner: IBanner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
|
export const Banner = ({ banner }: IBannerProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -60,10 +53,10 @@ export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
|
|||||||
plausibleEvent,
|
plausibleEvent,
|
||||||
dialogTitle,
|
dialogTitle,
|
||||||
dialog,
|
dialog,
|
||||||
} = messageBanner;
|
} = banner;
|
||||||
|
|
||||||
return (
|
const bannerBar = (
|
||||||
<StyledBar variant={variant} sticky={sticky}>
|
<StyledBar variant={variant}>
|
||||||
<StyledIcon variant={variant}>
|
<StyledIcon variant={variant}>
|
||||||
<BannerIcon icon={icon} variant={variant} />
|
<BannerIcon icon={icon} variant={variant} />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
@ -75,15 +68,21 @@ export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
|
|||||||
>
|
>
|
||||||
{linkText}
|
{linkText}
|
||||||
</BannerButton>
|
</BannerButton>
|
||||||
<MessageBannerDialog
|
<BannerDialog
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={dialogTitle || linkText}
|
title={dialogTitle || linkText}
|
||||||
>
|
>
|
||||||
{dialog!}
|
{dialog!}
|
||||||
</MessageBannerDialog>
|
</BannerDialog>
|
||||||
</StyledBar>
|
</StyledBar>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (sticky) {
|
||||||
|
return <Sticky>{bannerBar}</Sticky>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerBar;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VariantIcons = {
|
const VariantIcons = {
|
||||||
@ -127,7 +126,7 @@ const BannerButton = ({
|
|||||||
|
|
||||||
const trackEvent = () => {
|
const trackEvent = () => {
|
||||||
if (!plausibleEvent) return;
|
if (!plausibleEvent) return;
|
||||||
tracker.trackEvent('message_banner', {
|
tracker.trackEvent('banner', {
|
||||||
props: { event: plausibleEvent },
|
props: { event: plausibleEvent },
|
||||||
});
|
});
|
||||||
};
|
};
|
@ -8,19 +8,19 @@ const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IMessageBannerDialogProps {
|
interface IBannerDialogProps {
|
||||||
title: string;
|
title: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
children: string;
|
children: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageBannerDialog = ({
|
export const BannerDialog = ({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}: IMessageBannerDialogProps) => {
|
}: IBannerDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialogue
|
<Dialogue
|
||||||
title={title}
|
title={title}
|
@ -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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -8,6 +8,7 @@ import { AccessProvider } from '../providers/AccessProvider/AccessProvider';
|
|||||||
import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
||||||
import { testServerRoute, testServerSetup } from '../../utils/testServer';
|
import { testServerRoute, testServerSetup } from '../../utils/testServer';
|
||||||
import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer';
|
import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer';
|
||||||
|
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||||
|
|
||||||
const server = testServerSetup();
|
const server = testServerSetup();
|
||||||
|
|
||||||
@ -227,12 +228,16 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AnnouncerProvider>
|
<AnnouncerProvider>
|
||||||
<Routes>
|
<StickyProvider>
|
||||||
<Route
|
<Routes>
|
||||||
path={pathTemplate}
|
<Route
|
||||||
element={<MainLayout>{children}</MainLayout>}
|
path={pathTemplate}
|
||||||
/>
|
element={
|
||||||
</Routes>
|
<MainLayout>{children}</MainLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</StickyProvider>
|
||||||
</AnnouncerProvider>
|
</AnnouncerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
|
@ -10,6 +10,7 @@ import { FC } from 'react';
|
|||||||
import { IPermission } from '../../interfaces/user';
|
import { IPermission } from '../../interfaces/user';
|
||||||
import { SWRConfig } from 'swr';
|
import { SWRConfig } from 'swr';
|
||||||
import { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm';
|
import { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm';
|
||||||
|
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||||
|
|
||||||
const server = testServerSetup();
|
const server = testServerSetup();
|
||||||
|
|
||||||
@ -186,9 +187,14 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
|
|||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AnnouncerProvider>
|
<AnnouncerProvider>
|
||||||
<Routes>
|
<StickyProvider>
|
||||||
<Route path={pathTemplate} element={children} />
|
<Routes>
|
||||||
</Routes>
|
<Route
|
||||||
|
path={pathTemplate}
|
||||||
|
element={children}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</StickyProvider>
|
||||||
</AnnouncerProvider>
|
</AnnouncerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
|
112
frontend/src/component/common/Sticky/Sticky.test.tsx
Normal file
112
frontend/src/component/common/Sticky/Sticky.test.tsx
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
80
frontend/src/component/common/Sticky/Sticky.tsx
Normal file
80
frontend/src/component/common/Sticky/Sticky.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
12
frontend/src/component/common/Sticky/StickyContext.tsx
Normal file
12
frontend/src/component/common/Sticky/StickyContext.tsx
Normal 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,
|
||||||
|
);
|
160
frontend/src/component/common/Sticky/StickyProvider.test.tsx
Normal file
160
frontend/src/component/common/Sticky/StickyProvider.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
133
frontend/src/component/common/Sticky/StickyProvider.tsx
Normal file
133
frontend/src/component/common/Sticky/StickyProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,9 +1,8 @@
|
|||||||
import { Button, styled } from '@mui/material';
|
import { Button, styled } from '@mui/material';
|
||||||
|
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
|
||||||
const StyledBanner = styled('div')(({ theme }) => ({
|
const StyledBanner = styled(Sticky)(({ theme }) => ({
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
zIndex: theme.zIndex.sticky,
|
zIndex: theme.zIndex.sticky,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
|
@ -5,9 +5,9 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|||||||
import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails';
|
import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails';
|
||||||
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
|
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
|
||||||
import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags';
|
import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags';
|
||||||
|
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||||
|
|
||||||
const StyledContainer = styled('div')(({ theme }) => ({
|
const StyledContainer = styled(Sticky)(({ theme }) => ({
|
||||||
position: 'sticky',
|
|
||||||
top: theme.spacing(2),
|
top: theme.spacing(2),
|
||||||
borderRadius: theme.shape.borderRadiusLarge,
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
@ -5,6 +5,7 @@ import { ChangeRequestSidebar } from 'component/changeRequest/ChangeRequestSideb
|
|||||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||||
import { IChangeRequest } from 'component/changeRequest/changeRequest.types';
|
import { IChangeRequest } from 'component/changeRequest/changeRequest.types';
|
||||||
import { changesCount } from 'component/changeRequest/changesCount';
|
import { changesCount } from 'component/changeRequest/changesCount';
|
||||||
|
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||||
|
|
||||||
interface IDraftBannerProps {
|
interface IDraftBannerProps {
|
||||||
project: string;
|
project: string;
|
||||||
@ -98,10 +99,7 @@ const DraftBannerContent: FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StickyBanner = styled(Box)(({ theme }) => ({
|
const StickyBanner = styled(Sticky)(({ theme }) => ({
|
||||||
position: 'sticky',
|
|
||||||
top: -1,
|
|
||||||
zIndex: 250 /* has to lower than header.zIndex and higher than body.zIndex */,
|
|
||||||
borderTop: `1px solid ${theme.palette.warning.border}`,
|
borderTop: `1px solid ${theme.palette.warning.border}`,
|
||||||
borderBottom: `1px solid ${theme.palette.warning.border}`,
|
borderBottom: `1px solid ${theme.palette.warning.border}`,
|
||||||
color: theme.palette.warning.contrastText,
|
color: theme.palette.warning.contrastText,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { ErrorOutlineRounded } from '@mui/icons-material';
|
import { ErrorOutlineRounded } from '@mui/icons-material';
|
||||||
|
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||||
|
|
||||||
const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
|
const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
@ -8,7 +9,7 @@ const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
|
|||||||
marginRight: theme.spacing(1),
|
marginRight: theme.spacing(1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledDiv = styled('div')(({ theme }) => ({
|
const StyledDiv = styled(Sticky)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
fontSize: theme.fontSizes.smallBody,
|
fontSize: theme.fontSizes.smallBody,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -18,8 +19,6 @@ const StyledDiv = styled('div')(({ theme }) => ({
|
|||||||
height: '65px',
|
height: '65px',
|
||||||
borderBottom: `1px solid ${theme.palette.error.border}`,
|
borderBottom: `1px solid ${theme.palette.error.border}`,
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
zIndex: theme.zIndex.sticky - 100,
|
zIndex: theme.zIndex.sticky - 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -3,6 +3,7 @@ import { render } from 'utils/testRenderer';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
|
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
|
||||||
import { PlaygroundResultFeatureStrategyList } from './PlaygroundResultFeatureStrategyList';
|
import { PlaygroundResultFeatureStrategyList } from './PlaygroundResultFeatureStrategyList';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
const testCases = [
|
const testCases = [
|
||||||
{
|
{
|
||||||
@ -62,8 +63,72 @@ const testCases = [
|
|||||||
expectedText:
|
expectedText:
|
||||||
'If environment was enabled, then this feature toggle would be TRUE with strategies evaluated like so:',
|
'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 }) => {
|
testCases.forEach(({ name, feature, expectedText }) => {
|
||||||
test(name, async () => {
|
test(name, async () => {
|
||||||
render(
|
render(
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
|
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
|
||||||
import { Alert } from '@mui/material';
|
import { Alert } from '@mui/material';
|
||||||
|
import { useUiFlag } from '../../../../../../hooks/useUiFlag';
|
||||||
|
|
||||||
interface PlaygroundResultFeatureStrategyListProps {
|
interface PlaygroundResultFeatureStrategyListProps {
|
||||||
feature: PlaygroundFeatureSchema;
|
feature: PlaygroundFeatureSchema;
|
||||||
@ -15,6 +16,17 @@ export const PlaygroundResultFeatureStrategyList = ({
|
|||||||
feature,
|
feature,
|
||||||
input,
|
input,
|
||||||
}: PlaygroundResultFeatureStrategyListProps) => {
|
}: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -39,10 +51,28 @@ export const PlaygroundResultFeatureStrategyList = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
elseShow={
|
elseShow={
|
||||||
<PlaygroundResultStrategyLists
|
<>
|
||||||
strategies={feature?.strategies?.data || []}
|
<PlaygroundResultStrategyLists
|
||||||
input={input}
|
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.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -4,6 +4,8 @@ import { PlaygroundStrategySchema, PlaygroundRequestSchema } from 'openapi';
|
|||||||
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
|
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
|
||||||
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
|
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
|
||||||
import { objectId } from 'utils/objectId';
|
import { objectId } from 'utils/objectId';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { DisabledStrategyExecution } from './StrategyExecution/DisabledStrategyExecution';
|
||||||
|
|
||||||
interface IFeatureStrategyItemProps {
|
interface IFeatureStrategyItemProps {
|
||||||
strategy: PlaygroundStrategySchema;
|
strategy: PlaygroundStrategySchema;
|
||||||
@ -19,7 +21,8 @@ export const FeatureStrategyItem = ({
|
|||||||
const { result } = strategy;
|
const { result } = strategy;
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const label =
|
const label =
|
||||||
result.evaluationStatus === 'incomplete'
|
result.evaluationStatus === 'incomplete' ||
|
||||||
|
result.evaluationStatus === 'unevaluated'
|
||||||
? 'Unevaluated'
|
? 'Unevaluated'
|
||||||
: result.enabled
|
: result.enabled
|
||||||
? 'True'
|
? 'True'
|
||||||
@ -28,9 +31,10 @@ export const FeatureStrategyItem = ({
|
|||||||
return (
|
return (
|
||||||
<StrategyItemContainer
|
<StrategyItemContainer
|
||||||
style={{
|
style={{
|
||||||
borderColor: result.enabled
|
borderColor:
|
||||||
? theme.palette.success.main
|
result.enabled && result.evaluationStatus === 'complete'
|
||||||
: 'none',
|
? theme.palette.success.main
|
||||||
|
: 'none',
|
||||||
}}
|
}}
|
||||||
strategy={{ ...strategy, id: `${objectId(strategy)}` }}
|
strategy={{ ...strategy, id: `${objectId(strategy)}` }}
|
||||||
orderNumber={index + 1}
|
orderNumber={index + 1}
|
||||||
@ -42,10 +46,21 @@ export const FeatureStrategyItem = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StrategyExecution
|
<ConditionallyRender
|
||||||
strategyResult={strategy}
|
condition={Boolean(strategy.disabled)}
|
||||||
input={input}
|
show={
|
||||||
percentageFill={theme.palette.background.elevation2}
|
<DisabledStrategyExecution
|
||||||
|
strategyResult={strategy}
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StrategyExecution
|
||||||
|
strategyResult={strategy}
|
||||||
|
input={input}
|
||||||
|
percentageFill={theme.palette.background.elevation2}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</StrategyItemContainer>
|
</StrategyItemContainer>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -10,6 +10,8 @@ import { CustomStrategyParams } from './CustomStrategyParams/CustomStrategyParam
|
|||||||
import { formattedStrategyNames } from 'utils/strategyNames';
|
import { formattedStrategyNames } from 'utils/strategyNames';
|
||||||
import { StyledBoxSummary } from './StrategyExecution.styles';
|
import { StyledBoxSummary } from './StrategyExecution.styles';
|
||||||
import { Badge } from 'component/common/Badge/Badge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import { ConstraintExecutionWithoutResults } from './ConstraintExecution/ConstraintExecutionWithoutResults';
|
||||||
|
import { SegmentExecutionWithoutResult } from './SegmentExecution/SegmentExecutionWithoutResult';
|
||||||
|
|
||||||
interface IStrategyExecutionProps {
|
interface IStrategyExecutionProps {
|
||||||
strategyResult: PlaygroundStrategySchema;
|
strategyResult: PlaygroundStrategySchema;
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
|
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
|
||||||
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
||||||
|
import { useUiFlag } from '../../../../../../../hooks/useUiFlag';
|
||||||
|
|
||||||
const StyledAlertWrapper = styled('div')(({ theme }) => ({
|
const StyledAlertWrapper = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -31,20 +32,38 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
|
|||||||
interface PlaygroundResultStrategyListProps {
|
interface PlaygroundResultStrategyListProps {
|
||||||
strategies: PlaygroundStrategySchema[];
|
strategies: PlaygroundStrategySchema[];
|
||||||
input?: PlaygroundRequestSchema;
|
input?: PlaygroundRequestSchema;
|
||||||
|
titlePrefix?: string;
|
||||||
|
infoText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StyledSubtitle = styled(Typography)(({ theme }) => ({
|
||||||
|
margin: theme.spacing(2, 1, 2, 0),
|
||||||
|
color: 'text.secondary',
|
||||||
|
}));
|
||||||
|
|
||||||
export const PlaygroundResultStrategyLists = ({
|
export const PlaygroundResultStrategyLists = ({
|
||||||
strategies,
|
strategies,
|
||||||
input,
|
input,
|
||||||
|
titlePrefix,
|
||||||
|
infoText,
|
||||||
}: PlaygroundResultStrategyListProps) => (
|
}: PlaygroundResultStrategyListProps) => (
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={strategies.length > 0}
|
condition={strategies.length > 0}
|
||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<Typography
|
<StyledSubtitle variant={'subtitle1'}>{`${
|
||||||
variant={'subtitle1'}
|
titlePrefix
|
||||||
sx={{ mt: 2, ml: 1, mb: 2, color: 'text.secondary' }}
|
? titlePrefix.concat(' strategies')
|
||||||
>{`Strategies (${strategies?.length})`}</Typography>
|
: 'Strategies'
|
||||||
|
} (${strategies?.length})`}</StyledSubtitle>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(infoText)}
|
||||||
|
show={
|
||||||
|
<StyledSubtitle variant={'subtitle2'}>
|
||||||
|
{infoText}
|
||||||
|
</StyledSubtitle>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
{strategies?.map((strategy, index) => (
|
{strategies?.map((strategy, index) => (
|
||||||
<Fragment key={strategy.id}>
|
<Fragment key={strategy.id}>
|
||||||
@ -91,6 +110,17 @@ export const WrappedPlaygroundResultStrategyList = ({
|
|||||||
feature,
|
feature,
|
||||||
input,
|
input,
|
||||||
}: IWrappedPlaygroundResultStrategyListProps) => {
|
}: 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 (
|
return (
|
||||||
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
|
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
|
||||||
<StyledAlert severity={'info'} color={'warning'}>
|
<StyledAlert severity={'info'} color={'warning'}>
|
||||||
@ -100,10 +130,26 @@ export const WrappedPlaygroundResultStrategyList = ({
|
|||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
<StyledListWrapper sx={{ p: 2.5 }}>
|
<StyledListWrapper sx={{ p: 2.5 }}>
|
||||||
<PlaygroundResultStrategyLists
|
<PlaygroundResultStrategyLists
|
||||||
strategies={feature.strategies?.data || []}
|
strategies={enabledStrategies || []}
|
||||||
input={input}
|
input={input}
|
||||||
|
titlePrefix={showDisabledStrategies ? 'Enabled' : ''}
|
||||||
/>
|
/>
|
||||||
</StyledListWrapper>
|
</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>
|
</StyledAlertWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,27 +1,22 @@
|
|||||||
import { IInternalMessageBanner } from 'interfaces/messageBanner';
|
import { IInternalBanner } from 'interfaces/banner';
|
||||||
import useAPI from '../useApi/useApi';
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
const ENDPOINT = 'api/admin/message-banners';
|
const ENDPOINT = 'api/admin/banners';
|
||||||
|
|
||||||
type AddOrUpdateMessageBanner = Omit<
|
type AddOrUpdateBanner = Omit<IInternalBanner, 'id' | 'createdAt'>;
|
||||||
IInternalMessageBanner,
|
|
||||||
'id' | 'createdAt'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const useMessageBannersApi = () => {
|
export const useBannersApi = () => {
|
||||||
const { loading, makeRequest, createRequest, errors } = useAPI({
|
const { loading, makeRequest, createRequest, errors } = useAPI({
|
||||||
propagateErrors: true,
|
propagateErrors: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addMessageBanner = async (
|
const addBanner = async (banner: AddOrUpdateBanner) => {
|
||||||
messageBanner: AddOrUpdateMessageBanner,
|
const requestId = 'addBanner';
|
||||||
) => {
|
|
||||||
const requestId = 'addMessageBanner';
|
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
ENDPOINT,
|
ENDPOINT,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(messageBanner),
|
body: JSON.stringify(banner),
|
||||||
},
|
},
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
@ -30,16 +25,16 @@ export const useMessageBannersApi = () => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMessageBanner = async (
|
const updateBanner = async (
|
||||||
messageBannerId: number,
|
bannerId: number,
|
||||||
messageBanner: AddOrUpdateMessageBanner,
|
banner: AddOrUpdateBanner,
|
||||||
) => {
|
) => {
|
||||||
const requestId = 'updateMessageBanner';
|
const requestId = 'updateBanner';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
`${ENDPOINT}/${messageBannerId}`,
|
`${ENDPOINT}/${bannerId}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(messageBanner),
|
body: JSON.stringify(banner),
|
||||||
},
|
},
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
@ -47,10 +42,10 @@ export const useMessageBannersApi = () => {
|
|||||||
await makeRequest(req.caller, req.id);
|
await makeRequest(req.caller, req.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMessageBanner = async (messageBannerId: number) => {
|
const removeBanner = async (bannerId: number) => {
|
||||||
const requestId = 'removeMessageBanner';
|
const requestId = 'removeBanner';
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
`${ENDPOINT}/${messageBannerId}`,
|
`${ENDPOINT}/${bannerId}`,
|
||||||
{ method: 'DELETE' },
|
{ method: 'DELETE' },
|
||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
@ -59,9 +54,9 @@ export const useMessageBannersApi = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addMessageBanner,
|
addBanner,
|
||||||
updateMessageBanner,
|
updateBanner,
|
||||||
removeMessageBanner,
|
removeBanner,
|
||||||
errors,
|
errors,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
@ -4,25 +4,24 @@ import handleErrorResponses from '../httpErrorResponseHandler';
|
|||||||
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
||||||
import useUiConfig from '../useUiConfig/useUiConfig';
|
import useUiConfig from '../useUiConfig/useUiConfig';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
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 { isEnterprise } = useUiConfig();
|
||||||
const internalMessageBanners = useUiFlag('internalMessageBanners');
|
const bannersEnabled = useUiFlag('banners');
|
||||||
|
|
||||||
const { data, error, mutate } = useConditionalSWR(
|
const { data, error, mutate } = useConditionalSWR(
|
||||||
isEnterprise() && internalMessageBanners,
|
isEnterprise() && bannersEnabled,
|
||||||
{ messageBanners: [] },
|
{ banners: [] },
|
||||||
formatApiPath(ENDPOINT),
|
formatApiPath(ENDPOINT),
|
||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
messageBanners: (data?.messageBanners ??
|
banners: (data?.banners ?? []) as IInternalBanner[],
|
||||||
[]) as IInternalMessageBanner[],
|
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
refetch: () => mutate(),
|
refetch: () => mutate(),
|
||||||
error,
|
error,
|
||||||
@ -33,6 +32,6 @@ export const useMessageBanners = () => {
|
|||||||
|
|
||||||
const fetcher = (path: string) => {
|
const fetcher = (path: string) => {
|
||||||
return fetch(path)
|
return fetch(path)
|
||||||
.then(handleErrorResponses('Message Banners'))
|
.then(handleErrorResponses('Banners'))
|
||||||
.then((res) => res.json());
|
.then((res) => res.json());
|
||||||
};
|
};
|
@ -15,7 +15,7 @@ export type CustomEvents =
|
|||||||
| 'change_request'
|
| 'change_request'
|
||||||
| 'favorite'
|
| 'favorite'
|
||||||
| 'maintenance'
|
| 'maintenance'
|
||||||
| 'message_banner'
|
| 'banner'
|
||||||
| 'hidden_environment'
|
| 'hidden_environment'
|
||||||
| 'project_overview'
|
| 'project_overview'
|
||||||
| 'suggest_tags'
|
| 'suggest_tags'
|
||||||
|
@ -13,6 +13,7 @@ import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/Feedb
|
|||||||
import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
||||||
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
|
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
|
||||||
import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer';
|
import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer';
|
||||||
|
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||||
|
|
||||||
window.global ||= window;
|
window.global ||= window;
|
||||||
|
|
||||||
@ -23,10 +24,12 @@ ReactDOM.render(
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AnnouncerProvider>
|
<AnnouncerProvider>
|
||||||
<FeedbackCESProvider>
|
<FeedbackCESProvider>
|
||||||
<InstanceStatus>
|
<StickyProvider>
|
||||||
<ScrollTop />
|
<InstanceStatus>
|
||||||
<App />
|
<ScrollTop />
|
||||||
</InstanceStatus>
|
<App />
|
||||||
|
</InstanceStatus>
|
||||||
|
</StickyProvider>
|
||||||
</FeedbackCESProvider>
|
</FeedbackCESProvider>
|
||||||
</AnnouncerProvider>
|
</AnnouncerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export type BannerVariant = 'warning' | 'info' | 'error' | 'success';
|
export type BannerVariant = 'warning' | 'info' | 'error' | 'success';
|
||||||
|
|
||||||
export interface IMessageBanner {
|
export interface IBanner {
|
||||||
message: string;
|
message: string;
|
||||||
variant?: BannerVariant;
|
variant?: BannerVariant;
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
@ -12,7 +12,7 @@ export interface IMessageBanner {
|
|||||||
dialog?: string;
|
dialog?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInternalMessageBanner extends IMessageBanner {
|
export interface IInternalBanner extends IBanner {
|
||||||
id: number;
|
id: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
@ -47,6 +47,7 @@ export type UiFlags = {
|
|||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
maintenanceMode?: boolean;
|
maintenanceMode?: boolean;
|
||||||
messageBanner?: Variant;
|
messageBanner?: Variant;
|
||||||
|
banner?: Variant;
|
||||||
featuresExportImport?: boolean;
|
featuresExportImport?: boolean;
|
||||||
caseInsensitiveInOperators?: boolean;
|
caseInsensitiveInOperators?: boolean;
|
||||||
proPlanAutoCharge?: boolean;
|
proPlanAutoCharge?: boolean;
|
||||||
@ -69,7 +70,7 @@ export type UiFlags = {
|
|||||||
accessOverview?: boolean;
|
accessOverview?: boolean;
|
||||||
datadogJsonTemplate?: boolean;
|
datadogJsonTemplate?: boolean;
|
||||||
dependentFeatures?: boolean;
|
dependentFeatures?: boolean;
|
||||||
internalMessageBanners?: boolean;
|
banners?: boolean;
|
||||||
disableEnvsOnRevive?: boolean;
|
disableEnvsOnRevive?: boolean;
|
||||||
playgroundImprovements?: boolean;
|
playgroundImprovements?: boolean;
|
||||||
};
|
};
|
||||||
|
@ -2,4 +2,14 @@ import '@testing-library/jest-dom';
|
|||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
import 'regenerator-runtime';
|
import 'regenerator-runtime';
|
||||||
|
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.ResizeObserver) {
|
||||||
|
window.ResizeObserver = ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
process.env.TZ = 'UTC';
|
process.env.TZ = 'UTC';
|
||||||
|
@ -77,6 +77,7 @@ exports[`should create default config 1`] = `
|
|||||||
"flags": {
|
"flags": {
|
||||||
"accessOverview": false,
|
"accessOverview": false,
|
||||||
"anonymiseEventLog": false,
|
"anonymiseEventLog": false,
|
||||||
|
"banners": false,
|
||||||
"caseInsensitiveInOperators": false,
|
"caseInsensitiveInOperators": false,
|
||||||
"customRootRolesKillSwitch": false,
|
"customRootRolesKillSwitch": false,
|
||||||
"datadogJsonTemplate": false,
|
"datadogJsonTemplate": false,
|
||||||
@ -93,7 +94,6 @@ exports[`should create default config 1`] = `
|
|||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
"filterInvalidClientMetrics": false,
|
"filterInvalidClientMetrics": false,
|
||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
"internalMessageBanners": false,
|
|
||||||
"lastSeenByEnvironment": false,
|
"lastSeenByEnvironment": false,
|
||||||
"maintenanceMode": false,
|
"maintenanceMode": false,
|
||||||
"messageBanner": {
|
"messageBanner": {
|
||||||
@ -122,6 +122,7 @@ exports[`should create default config 1`] = `
|
|||||||
"experiments": {
|
"experiments": {
|
||||||
"accessOverview": false,
|
"accessOverview": false,
|
||||||
"anonymiseEventLog": false,
|
"anonymiseEventLog": false,
|
||||||
|
"banners": false,
|
||||||
"caseInsensitiveInOperators": false,
|
"caseInsensitiveInOperators": false,
|
||||||
"customRootRolesKillSwitch": false,
|
"customRootRolesKillSwitch": false,
|
||||||
"datadogJsonTemplate": false,
|
"datadogJsonTemplate": false,
|
||||||
@ -138,7 +139,6 @@ exports[`should create default config 1`] = `
|
|||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
"filterInvalidClientMetrics": false,
|
"filterInvalidClientMetrics": false,
|
||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
"internalMessageBanners": false,
|
|
||||||
"lastSeenByEnvironment": false,
|
"lastSeenByEnvironment": false,
|
||||||
"maintenanceMode": false,
|
"maintenanceMode": false,
|
||||||
"messageBanner": {
|
"messageBanner": {
|
||||||
|
@ -41,9 +41,9 @@ import {
|
|||||||
GROUP_UPDATED,
|
GROUP_UPDATED,
|
||||||
IConstraint,
|
IConstraint,
|
||||||
IEvent,
|
IEvent,
|
||||||
MESSAGE_BANNER_CREATED,
|
BANNER_CREATED,
|
||||||
MESSAGE_BANNER_DELETED,
|
BANNER_DELETED,
|
||||||
MESSAGE_BANNER_UPDATED,
|
BANNER_UPDATED,
|
||||||
PROJECT_CREATED,
|
PROJECT_CREATED,
|
||||||
PROJECT_DELETED,
|
PROJECT_DELETED,
|
||||||
SEGMENT_CREATED,
|
SEGMENT_CREATED,
|
||||||
@ -232,16 +232,16 @@ const EVENT_MAP: Record<string, IEventData> = {
|
|||||||
action: '*{{user}}* updated group *{{event.preData.name}}*',
|
action: '*{{user}}* updated group *{{event.preData.name}}*',
|
||||||
path: '/admin/groups',
|
path: '/admin/groups',
|
||||||
},
|
},
|
||||||
[MESSAGE_BANNER_CREATED]: {
|
[BANNER_CREATED]: {
|
||||||
action: '*{{user}}* created message banner *{{event.data.message}}*',
|
action: '*{{user}}* created banner *{{event.data.message}}*',
|
||||||
path: '/admin/message-banners',
|
path: '/admin/message-banners',
|
||||||
},
|
},
|
||||||
[MESSAGE_BANNER_DELETED]: {
|
[BANNER_DELETED]: {
|
||||||
action: '*{{user}}* deleted message banner *{{event.preData.message}}*',
|
action: '*{{user}}* deleted banner *{{event.preData.message}}*',
|
||||||
path: '/admin/message-banners',
|
path: '/admin/message-banners',
|
||||||
},
|
},
|
||||||
[MESSAGE_BANNER_UPDATED]: {
|
[BANNER_UPDATED]: {
|
||||||
action: '*{{user}}* updated message banner *{{event.preData.message}}*',
|
action: '*{{user}}* updated banner *{{event.preData.message}}*',
|
||||||
path: '/admin/message-banners',
|
path: '/admin/message-banners',
|
||||||
},
|
},
|
||||||
[PROJECT_CREATED]: {
|
[PROJECT_CREATED]: {
|
||||||
|
@ -49,9 +49,9 @@ import {
|
|||||||
SERVICE_ACCOUNT_DELETED,
|
SERVICE_ACCOUNT_DELETED,
|
||||||
SERVICE_ACCOUNT_UPDATED,
|
SERVICE_ACCOUNT_UPDATED,
|
||||||
GROUP_DELETED,
|
GROUP_DELETED,
|
||||||
MESSAGE_BANNER_CREATED,
|
BANNER_CREATED,
|
||||||
MESSAGE_BANNER_UPDATED,
|
BANNER_UPDATED,
|
||||||
MESSAGE_BANNER_DELETED,
|
BANNER_DELETED,
|
||||||
} from '../types/events';
|
} from '../types/events';
|
||||||
import { IAddonDefinition } from '../types/model';
|
import { IAddonDefinition } from '../types/model';
|
||||||
|
|
||||||
@ -127,9 +127,9 @@ const slackAppDefinition: IAddonDefinition = {
|
|||||||
GROUP_CREATED,
|
GROUP_CREATED,
|
||||||
GROUP_DELETED,
|
GROUP_DELETED,
|
||||||
GROUP_UPDATED,
|
GROUP_UPDATED,
|
||||||
MESSAGE_BANNER_CREATED,
|
BANNER_CREATED,
|
||||||
MESSAGE_BANNER_UPDATED,
|
BANNER_UPDATED,
|
||||||
MESSAGE_BANNER_DELETED,
|
BANNER_DELETED,
|
||||||
PROJECT_CREATED,
|
PROJECT_CREATED,
|
||||||
PROJECT_DELETED,
|
PROJECT_DELETED,
|
||||||
SEGMENT_CREATED,
|
SEGMENT_CREATED,
|
||||||
|
@ -96,18 +96,26 @@ export class DependentFeaturesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [children, parentExists, sameProject] = await Promise.all([
|
const [grandchildren, grandparents, parentExists, sameProject] =
|
||||||
this.dependentFeaturesReadModel.getChildren([child]),
|
await Promise.all([
|
||||||
this.featuresReadModel.featureExists(parent),
|
this.dependentFeaturesReadModel.getChildren([child]),
|
||||||
this.featuresReadModel.featuresInTheSameProject(child, parent),
|
this.dependentFeaturesReadModel.getParents(parent),
|
||||||
]);
|
this.featuresReadModel.featureExists(parent),
|
||||||
|
this.featuresReadModel.featuresInTheSameProject(child, parent),
|
||||||
|
]);
|
||||||
|
|
||||||
if (children.length > 0) {
|
if (grandchildren.length > 0) {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
'Transitive dependency detected. Cannot add a dependency to the feature that other features depend on.',
|
'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) {
|
if (!parentExists) {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
`No active feature ${parent} exists`,
|
`No active feature ${parent} exists`,
|
||||||
|
@ -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 grandparent = uuidv4();
|
||||||
const parent = uuidv4();
|
const parent = uuidv4();
|
||||||
const child = 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 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 parent = uuidv4();
|
||||||
const child = uuidv4();
|
const child = uuidv4();
|
||||||
await app.createFeature(child);
|
await app.createFeature(child);
|
||||||
|
@ -198,6 +198,10 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
builder.addSelectColumn('df.enabled as parent_enabled');
|
builder.addSelectColumn('df.enabled as parent_enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (featureQuery?.project) {
|
||||||
|
builder.forProject(featureQuery.project);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await builder.internalQuery.select(
|
const rows = await builder.internalQuery.select(
|
||||||
builder.getSelectColumns(),
|
builder.getSelectColumns(),
|
||||||
);
|
);
|
||||||
|
@ -129,7 +129,11 @@ export class FeatureToggleListBuilder {
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
forProject = (project: string[]) => {
|
||||||
|
this.internalQuery.whereIn('features.project', project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
import dbInit from '../../../../test/e2e/helpers/database-init';
|
import dbInit from '../../../../test/e2e/helpers/database-init';
|
||||||
import getLogger from '../../../../test/fixtures/no-logger';
|
import getLogger from '../../../../test/fixtures/no-logger';
|
||||||
import { FeatureToggleDTO, IFeatureToggleStore } from '../../../types';
|
import {
|
||||||
|
FeatureToggleDTO,
|
||||||
|
IFeatureToggleStore,
|
||||||
|
IProjectStore,
|
||||||
|
} from '../../../types';
|
||||||
|
|
||||||
let stores;
|
let stores;
|
||||||
let db;
|
let db;
|
||||||
let featureToggleStore: IFeatureToggleStore;
|
let featureToggleStore: IFeatureToggleStore;
|
||||||
|
let projectStore: IProjectStore;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
getLogger.setMuteError(true);
|
getLogger.setMuteError(true);
|
||||||
db = await dbInit('feature_toggle_store_serial', getLogger);
|
db = await dbInit('feature_toggle_store_serial', getLogger);
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
featureToggleStore = stores.featureToggleStore;
|
featureToggleStore = stores.featureToggleStore;
|
||||||
|
projectStore = stores.projectStore;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -301,5 +307,24 @@ describe('potentially_stale marking', () => {
|
|||||||
|
|
||||||
expect(potentiallyStale).toBeFalsy();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -101,7 +101,7 @@ export class PlaygroundService {
|
|||||||
): Promise<AdvancedPlaygroundFeatureEvaluationResult[]> {
|
): Promise<AdvancedPlaygroundFeatureEvaluationResult[]> {
|
||||||
const segments = await this.segmentService.getActive();
|
const segments = await this.segmentService.getActive();
|
||||||
|
|
||||||
let filteredProjects: typeof projects;
|
let filteredProjects: typeof projects = projects;
|
||||||
if (this.flagResolver.isEnabled('privateProjects')) {
|
if (this.flagResolver.isEnabled('privateProjects')) {
|
||||||
const projectAccess =
|
const projectAccess =
|
||||||
await this.privateProjectChecker.getUserAccessibleProjects(
|
await this.privateProjectChecker.getUserAccessibleProjects(
|
||||||
|
@ -14,7 +14,6 @@ import FakeGroupStore from '../../../test/fixtures/fake-group-store';
|
|||||||
import FakeEventStore from '../../../test/fixtures/fake-event-store';
|
import FakeEventStore from '../../../test/fixtures/fake-event-store';
|
||||||
import ProjectStore from '../../db/project-store';
|
import ProjectStore from '../../db/project-store';
|
||||||
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
|
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
|
||||||
import FeatureTypeStore from '../../db/feature-type-store';
|
|
||||||
import { FeatureEnvironmentStore } from '../../db/feature-environment-store';
|
import { FeatureEnvironmentStore } from '../../db/feature-environment-store';
|
||||||
import ProjectStatsStore from '../../db/project-stats-store';
|
import ProjectStatsStore from '../../db/project-stats-store';
|
||||||
import {
|
import {
|
||||||
@ -29,7 +28,6 @@ import { FavoriteFeaturesStore } from '../../db/favorite-features-store';
|
|||||||
import { FavoriteProjectsStore } from '../../db/favorite-projects-store';
|
import { FavoriteProjectsStore } from '../../db/favorite-projects-store';
|
||||||
import FakeProjectStore from '../../../test/fixtures/fake-project-store';
|
import FakeProjectStore from '../../../test/fixtures/fake-project-store';
|
||||||
import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-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 FakeEnvironmentStore from '../../../test/fixtures/fake-environment-store';
|
||||||
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
|
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
|
||||||
import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store';
|
import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store';
|
||||||
@ -41,8 +39,6 @@ import {
|
|||||||
createPrivateProjectChecker,
|
createPrivateProjectChecker,
|
||||||
} from '../private-project/createPrivateProjectChecker';
|
} from '../private-project/createPrivateProjectChecker';
|
||||||
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
|
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 = (
|
export const createProjectService = (
|
||||||
db: Db,
|
db: Db,
|
||||||
@ -63,7 +59,6 @@ export const createProjectService = (
|
|||||||
getLogger,
|
getLogger,
|
||||||
flagResolver,
|
flagResolver,
|
||||||
);
|
);
|
||||||
const featureTypeStore = new FeatureTypeStore(db, getLogger);
|
|
||||||
const accountStore = new AccountStore(db, getLogger);
|
const accountStore = new AccountStore(db, getLogger);
|
||||||
const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
|
const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
|
||||||
const featureEnvironmentStore = new FeatureEnvironmentStore(
|
const featureEnvironmentStore = new FeatureEnvironmentStore(
|
||||||
@ -106,14 +101,12 @@ export const createProjectService = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const privateProjectChecker = createPrivateProjectChecker(db, config);
|
const privateProjectChecker = createPrivateProjectChecker(db, config);
|
||||||
const lastSeenReadModel = new LastSeenAtReadModel(db);
|
|
||||||
|
|
||||||
return new ProjectService(
|
return new ProjectService(
|
||||||
{
|
{
|
||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
featureToggleStore,
|
featureToggleStore,
|
||||||
featureTypeStore,
|
|
||||||
environmentStore,
|
environmentStore,
|
||||||
featureEnvironmentStore,
|
featureEnvironmentStore,
|
||||||
accountStore,
|
accountStore,
|
||||||
@ -126,7 +119,6 @@ export const createProjectService = (
|
|||||||
favoriteService,
|
favoriteService,
|
||||||
eventService,
|
eventService,
|
||||||
privateProjectChecker,
|
privateProjectChecker,
|
||||||
lastSeenReadModel,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -138,7 +130,6 @@ export const createFakeProjectService = (
|
|||||||
const projectStore = new FakeProjectStore();
|
const projectStore = new FakeProjectStore();
|
||||||
const groupStore = new FakeGroupStore();
|
const groupStore = new FakeGroupStore();
|
||||||
const featureToggleStore = new FakeFeatureToggleStore();
|
const featureToggleStore = new FakeFeatureToggleStore();
|
||||||
const featureTypeStore = new FakeFeatureTypeStore();
|
|
||||||
const accountStore = new FakeAccountStore();
|
const accountStore = new FakeAccountStore();
|
||||||
const environmentStore = new FakeEnvironmentStore();
|
const environmentStore = new FakeEnvironmentStore();
|
||||||
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
|
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
|
||||||
@ -169,14 +160,12 @@ export const createFakeProjectService = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const privateProjectChecker = createFakePrivateProjectChecker();
|
const privateProjectChecker = createFakePrivateProjectChecker();
|
||||||
const fakeLastSeenReadModel = new FakeLastSeenReadModel();
|
|
||||||
|
|
||||||
return new ProjectService(
|
return new ProjectService(
|
||||||
{
|
{
|
||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
featureToggleStore,
|
featureToggleStore,
|
||||||
featureTypeStore,
|
|
||||||
environmentStore,
|
environmentStore,
|
||||||
featureEnvironmentStore,
|
featureEnvironmentStore,
|
||||||
accountStore,
|
accountStore,
|
||||||
@ -189,6 +178,5 @@ export const createFakeProjectService = (
|
|||||||
favoriteService,
|
favoriteService,
|
||||||
eventService,
|
eventService,
|
||||||
privateProjectChecker,
|
privateProjectChecker,
|
||||||
fakeLastSeenReadModel,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -568,7 +568,7 @@ export class AccessService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeDefaultProjectRoles(
|
async removeDefaultProjectRoles(
|
||||||
owner: User,
|
owner: IUser,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.info(`Removing project roles for ${projectId}`);
|
this.logger.info(`Removing project roles for ${projectId}`);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
import { ValidationError } from 'joi';
|
import { ValidationError } from 'joi';
|
||||||
import User, { IUser } from '../types/user';
|
import { IUser } from '../types/user';
|
||||||
import { AccessService, AccessWithRoles } from './access-service';
|
import { AccessService, AccessWithRoles } from './access-service';
|
||||||
import NameExistsError from '../error/name-exists-error';
|
import NameExistsError from '../error/name-exists-error';
|
||||||
import InvalidOperationError from '../error/invalid-operation-error';
|
import InvalidOperationError from '../error/invalid-operation-error';
|
||||||
@ -15,7 +15,6 @@ import {
|
|||||||
IEventStore,
|
IEventStore,
|
||||||
IFeatureEnvironmentStore,
|
IFeatureEnvironmentStore,
|
||||||
IFeatureToggleStore,
|
IFeatureToggleStore,
|
||||||
IFeatureTypeStore,
|
|
||||||
IProject,
|
IProject,
|
||||||
IProjectOverview,
|
IProjectOverview,
|
||||||
IProjectWithCount,
|
IProjectWithCount,
|
||||||
@ -65,8 +64,6 @@ import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
|||||||
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
||||||
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
|
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
|
||||||
import EventService from './event-service';
|
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';
|
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
||||||
|
|
||||||
@ -89,6 +86,10 @@ interface ICalculateStatus {
|
|||||||
updates: IProjectStats;
|
updates: IProjectStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function includes(list: number[], { id }: { id: number }): boolean {
|
||||||
|
return list.some((l) => l === id);
|
||||||
|
}
|
||||||
|
|
||||||
export default class ProjectService {
|
export default class ProjectService {
|
||||||
private projectStore: IProjectStore;
|
private projectStore: IProjectStore;
|
||||||
|
|
||||||
@ -98,8 +99,6 @@ export default class ProjectService {
|
|||||||
|
|
||||||
private featureToggleStore: IFeatureToggleStore;
|
private featureToggleStore: IFeatureToggleStore;
|
||||||
|
|
||||||
private featureTypeStore: IFeatureTypeStore;
|
|
||||||
|
|
||||||
private featureEnvironmentStore: IFeatureEnvironmentStore;
|
private featureEnvironmentStore: IFeatureEnvironmentStore;
|
||||||
|
|
||||||
private environmentStore: IEnvironmentStore;
|
private environmentStore: IEnvironmentStore;
|
||||||
@ -120,8 +119,6 @@ export default class ProjectService {
|
|||||||
|
|
||||||
private projectStatsStore: IProjectStatsStore;
|
private projectStatsStore: IProjectStatsStore;
|
||||||
|
|
||||||
private lastSeenReadModel: ILastSeenReadModel;
|
|
||||||
|
|
||||||
private flagResolver: IFlagResolver;
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private isEnterprise: boolean;
|
private isEnterprise: boolean;
|
||||||
@ -131,7 +128,6 @@ export default class ProjectService {
|
|||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
featureToggleStore,
|
featureToggleStore,
|
||||||
featureTypeStore,
|
|
||||||
environmentStore,
|
environmentStore,
|
||||||
featureEnvironmentStore,
|
featureEnvironmentStore,
|
||||||
accountStore,
|
accountStore,
|
||||||
@ -141,7 +137,6 @@ export default class ProjectService {
|
|||||||
| 'projectStore'
|
| 'projectStore'
|
||||||
| 'eventStore'
|
| 'eventStore'
|
||||||
| 'featureToggleStore'
|
| 'featureToggleStore'
|
||||||
| 'featureTypeStore'
|
|
||||||
| 'environmentStore'
|
| 'environmentStore'
|
||||||
| 'featureEnvironmentStore'
|
| 'featureEnvironmentStore'
|
||||||
| 'accountStore'
|
| 'accountStore'
|
||||||
@ -154,7 +149,6 @@ export default class ProjectService {
|
|||||||
favoriteService: FavoritesService,
|
favoriteService: FavoritesService,
|
||||||
eventService: EventService,
|
eventService: EventService,
|
||||||
privateProjectChecker: IPrivateProjectChecker,
|
privateProjectChecker: IPrivateProjectChecker,
|
||||||
lastSeenReadModel: ILastSeenReadModel,
|
|
||||||
) {
|
) {
|
||||||
this.projectStore = projectStore;
|
this.projectStore = projectStore;
|
||||||
this.environmentStore = environmentStore;
|
this.environmentStore = environmentStore;
|
||||||
@ -162,7 +156,6 @@ export default class ProjectService {
|
|||||||
this.accessService = accessService;
|
this.accessService = accessService;
|
||||||
this.eventStore = eventStore;
|
this.eventStore = eventStore;
|
||||||
this.featureToggleStore = featureToggleStore;
|
this.featureToggleStore = featureToggleStore;
|
||||||
this.featureTypeStore = featureTypeStore;
|
|
||||||
this.featureToggleService = featureToggleService;
|
this.featureToggleService = featureToggleService;
|
||||||
this.favoritesService = favoriteService;
|
this.favoritesService = favoriteService;
|
||||||
this.privateProjectChecker = privateProjectChecker;
|
this.privateProjectChecker = privateProjectChecker;
|
||||||
@ -170,7 +163,6 @@ export default class ProjectService {
|
|||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.eventService = eventService;
|
this.eventService = eventService;
|
||||||
this.projectStatsStore = projectStatsStore;
|
this.projectStatsStore = projectStatsStore;
|
||||||
this.lastSeenReadModel = lastSeenReadModel;
|
|
||||||
this.logger = config.getLogger('services/project-service.js');
|
this.logger = config.getLogger('services/project-service.js');
|
||||||
this.flagResolver = config.flagResolver;
|
this.flagResolver = config.flagResolver;
|
||||||
this.isEnterprise = config.isEnterprise;
|
this.isEnterprise = config.isEnterprise;
|
||||||
@ -267,7 +259,7 @@ export default class ProjectService {
|
|||||||
return data;
|
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);
|
const preData = await this.projectStore.get(updatedProject.id);
|
||||||
|
|
||||||
await this.projectStore.update(updatedProject);
|
await this.projectStore.update(updatedProject);
|
||||||
@ -283,7 +275,7 @@ export default class ProjectService {
|
|||||||
|
|
||||||
async updateProjectEnterpriseSettings(
|
async updateProjectEnterpriseSettings(
|
||||||
updatedProject: IProjectEnterpriseSettingsUpdate,
|
updatedProject: IProjectEnterpriseSettingsUpdate,
|
||||||
user: User,
|
user: IUser,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const preData = await this.projectStore.get(updatedProject.id);
|
const preData = await this.projectStore.get(updatedProject.id);
|
||||||
|
|
||||||
@ -330,7 +322,7 @@ export default class ProjectService {
|
|||||||
async changeProject(
|
async changeProject(
|
||||||
newProjectId: string,
|
newProjectId: string,
|
||||||
featureName: string,
|
featureName: string,
|
||||||
user: User,
|
user: IUser,
|
||||||
currentProjectId: string,
|
currentProjectId: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const feature = await this.featureToggleStore.get(featureName);
|
const feature = await this.featureToggleStore.get(featureName);
|
||||||
@ -372,7 +364,7 @@ export default class ProjectService {
|
|||||||
return updatedFeature;
|
return updatedFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProject(id: string, user: User): Promise<void> {
|
async deleteProject(id: string, user: IUser): Promise<void> {
|
||||||
if (id === DEFAULT_PROJECT) {
|
if (id === DEFAULT_PROJECT) {
|
||||||
throw new InvalidOperationError(
|
throw new InvalidOperationError(
|
||||||
'You can not delete the default project!',
|
'You can not delete the default project!',
|
||||||
@ -508,6 +500,11 @@ export default class ProjectService {
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ownerRole = await this.accessService.getRoleByName(
|
||||||
|
RoleName.OWNER,
|
||||||
|
);
|
||||||
|
await this.validateAtLeastOneOwner(projectId, ownerRole);
|
||||||
|
|
||||||
await this.accessService.removeUserAccess(projectId, userId);
|
await this.accessService.removeUserAccess(projectId, userId);
|
||||||
|
|
||||||
await this.eventService.storeEvent(
|
await this.eventService.storeEvent(
|
||||||
@ -532,6 +529,11 @@ export default class ProjectService {
|
|||||||
groupId,
|
groupId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ownerRole = await this.accessService.getRoleByName(
|
||||||
|
RoleName.OWNER,
|
||||||
|
);
|
||||||
|
await this.validateAtLeastOneOwner(projectId, ownerRole);
|
||||||
|
|
||||||
await this.accessService.removeGroupAccess(projectId, groupId);
|
await this.accessService.removeGroupAccess(projectId, groupId);
|
||||||
|
|
||||||
await this.eventService.storeEvent(
|
await this.eventService.storeEvent(
|
||||||
@ -598,6 +600,8 @@ export default class ProjectService {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.validateAtLeastOneOwner(projectId, role);
|
||||||
|
|
||||||
await this.accessService.removeGroupFromRole(
|
await this.accessService.removeGroupFromRole(
|
||||||
group.id,
|
group.id,
|
||||||
role.id,
|
role.id,
|
||||||
@ -675,28 +679,39 @@ export default class ProjectService {
|
|||||||
async setRolesForUser(
|
async setRolesForUser(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
userId: number,
|
userId: number,
|
||||||
roles: number[],
|
newRoles: number[],
|
||||||
createdByUserName: string,
|
createdByUserName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existingRoles = await this.accessService.getProjectRolesForUser(
|
const currentRoles = await this.accessService.getProjectRolesForUser(
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
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(
|
await this.accessService.setProjectRolesForUser(
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
roles,
|
newRoles,
|
||||||
);
|
);
|
||||||
await this.eventService.storeEvent(
|
await this.eventService.storeEvent(
|
||||||
new ProjectAccessUserRolesUpdated({
|
new ProjectAccessUserRolesUpdated({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
createdBy: createdByUserName,
|
createdBy: createdByUserName,
|
||||||
data: {
|
data: {
|
||||||
roles,
|
roles: newRoles,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
preData: {
|
preData: {
|
||||||
roles: existingRoles,
|
roles: currentRoles,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -706,17 +721,28 @@ export default class ProjectService {
|
|||||||
async setRolesForGroup(
|
async setRolesForGroup(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
groupId: number,
|
groupId: number,
|
||||||
roles: number[],
|
newRoles: number[],
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existingRoles = await this.accessService.getProjectRolesForGroup(
|
const currentRoles = await this.accessService.getProjectRolesForGroup(
|
||||||
projectId,
|
projectId,
|
||||||
groupId,
|
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(
|
await this.accessService.setProjectRolesForGroup(
|
||||||
projectId,
|
projectId,
|
||||||
groupId,
|
groupId,
|
||||||
roles,
|
newRoles,
|
||||||
createdBy,
|
createdBy,
|
||||||
);
|
);
|
||||||
await this.eventService.storeEvent(
|
await this.eventService.storeEvent(
|
||||||
@ -724,11 +750,11 @@ export default class ProjectService {
|
|||||||
project: projectId,
|
project: projectId,
|
||||||
createdBy,
|
createdBy,
|
||||||
data: {
|
data: {
|
||||||
roles,
|
roles: newRoles,
|
||||||
groupId,
|
groupId,
|
||||||
},
|
},
|
||||||
preData: {
|
preData: {
|
||||||
roles: existingRoles,
|
roles: currentRoles,
|
||||||
groupId,
|
groupId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -1091,7 +1117,7 @@ export default class ProjectService {
|
|||||||
return {
|
return {
|
||||||
stats: projectStats,
|
stats: projectStats,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description,
|
description: project.description!,
|
||||||
mode: project.mode,
|
mode: project.mode,
|
||||||
featureLimit: project.featureLimit,
|
featureLimit: project.featureLimit,
|
||||||
featureNaming: project.featureNaming,
|
featureNaming: project.featureNaming,
|
||||||
|
@ -146,9 +146,9 @@ export const SERVICE_ACCOUNT_DELETED = 'service-account-deleted' as const;
|
|||||||
export const FEATURE_POTENTIALLY_STALE_ON =
|
export const FEATURE_POTENTIALLY_STALE_ON =
|
||||||
'feature-potentially-stale-on' as const;
|
'feature-potentially-stale-on' as const;
|
||||||
|
|
||||||
export const MESSAGE_BANNER_CREATED = 'message-banner-created' as const;
|
export const BANNER_CREATED = 'banner-created' as const;
|
||||||
export const MESSAGE_BANNER_UPDATED = 'message-banner-updated' as const;
|
export const BANNER_UPDATED = 'banner-updated' as const;
|
||||||
export const MESSAGE_BANNER_DELETED = 'message-banner-deleted' as const;
|
export const BANNER_DELETED = 'banner-deleted' as const;
|
||||||
|
|
||||||
export const IEventTypes = [
|
export const IEventTypes = [
|
||||||
APPLICATION_CREATED,
|
APPLICATION_CREATED,
|
||||||
@ -263,9 +263,9 @@ export const IEventTypes = [
|
|||||||
FEATURE_DEPENDENCY_ADDED,
|
FEATURE_DEPENDENCY_ADDED,
|
||||||
FEATURE_DEPENDENCY_REMOVED,
|
FEATURE_DEPENDENCY_REMOVED,
|
||||||
FEATURE_DEPENDENCIES_REMOVED,
|
FEATURE_DEPENDENCIES_REMOVED,
|
||||||
MESSAGE_BANNER_CREATED,
|
BANNER_CREATED,
|
||||||
MESSAGE_BANNER_UPDATED,
|
BANNER_UPDATED,
|
||||||
MESSAGE_BANNER_DELETED,
|
BANNER_DELETED,
|
||||||
] as const;
|
] as const;
|
||||||
export type IEventType = typeof IEventTypes[number];
|
export type IEventType = typeof IEventTypes[number];
|
||||||
|
|
||||||
|
@ -34,8 +34,7 @@ export type IFlagKey =
|
|||||||
| 'datadogJsonTemplate'
|
| 'datadogJsonTemplate'
|
||||||
| 'disableMetrics'
|
| 'disableMetrics'
|
||||||
| 'useLastSeenRefactor'
|
| 'useLastSeenRefactor'
|
||||||
| 'internalMessageBanners'
|
| 'banners'
|
||||||
| 'internalMessageBanner'
|
|
||||||
| 'separateAdminClientApi'
|
| 'separateAdminClientApi'
|
||||||
| 'disableEnvsOnRevive'
|
| 'disableEnvsOnRevive'
|
||||||
| 'playgroundImprovements';
|
| 'playgroundImprovements';
|
||||||
@ -162,8 +161,8 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_USE_LAST_SEEN_REFACTOR,
|
process.env.UNLEASH_EXPERIMENTAL_USE_LAST_SEEN_REFACTOR,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
internalMessageBanners: parseEnvVarBoolean(
|
banners: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_INTERNAL_MESSAGE_BANNERS,
|
process.env.UNLEASH_EXPERIMENTAL_BANNERS,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
separateAdminClientApi: parseEnvVarBoolean(
|
separateAdminClientApi: parseEnvVarBoolean(
|
||||||
|
@ -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);
|
||||||
|
};
|
@ -47,6 +47,7 @@ process.nextTick(async () => {
|
|||||||
datadogJsonTemplate: true,
|
datadogJsonTemplate: true,
|
||||||
dependentFeatures: true,
|
dependentFeatures: true,
|
||||||
useLastSeenRefactor: true,
|
useLastSeenRefactor: true,
|
||||||
|
disableEnvsOnRevive: true,
|
||||||
separateAdminClientApi: true,
|
separateAdminClientApi: true,
|
||||||
playgroundImprovements: true,
|
playgroundImprovements: true,
|
||||||
},
|
},
|
||||||
|
@ -17,8 +17,10 @@ import {
|
|||||||
createFeatureToggleService,
|
createFeatureToggleService,
|
||||||
createProjectService,
|
createProjectService,
|
||||||
} from '../../../lib/features';
|
} from '../../../lib/features';
|
||||||
|
import { IGroup, IUnleashStores } from 'lib/types';
|
||||||
|
import { User } from 'lib/server-impl';
|
||||||
|
|
||||||
let stores;
|
let stores: IUnleashStores;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
|
|
||||||
let projectService: ProjectService;
|
let projectService: ProjectService;
|
||||||
@ -26,7 +28,8 @@ let accessService: AccessService;
|
|||||||
let eventService: EventService;
|
let eventService: EventService;
|
||||||
let environmentService: EnvironmentService;
|
let environmentService: EnvironmentService;
|
||||||
let featureToggleService: FeatureToggleService;
|
let featureToggleService: FeatureToggleService;
|
||||||
let user;
|
let user: User; // many methods in this test use User instead of IUser
|
||||||
|
let group: IGroup;
|
||||||
|
|
||||||
const isProjectUser = async (
|
const isProjectUser = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
@ -41,13 +44,17 @@ const isProjectUser = async (
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('project_service_serial', getLogger);
|
db = await dbInit('project_service_serial', getLogger);
|
||||||
stores = db.stores;
|
stores = db.stores;
|
||||||
|
// @ts-ignore return type IUser type missing generateImageUrl
|
||||||
user = await stores.userStore.insert({
|
user = await stores.userStore.insert({
|
||||||
name: 'Some Name',
|
name: 'Some Name',
|
||||||
email: 'test@getunleash.io',
|
email: 'test@getunleash.io',
|
||||||
});
|
});
|
||||||
|
group = await stores.groupStore.create({
|
||||||
|
name: 'aTestGroup',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
getLogger,
|
getLogger,
|
||||||
// @ts-ignore
|
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: { privateProjects: true },
|
flags: { privateProjects: true },
|
||||||
},
|
},
|
||||||
@ -164,9 +171,6 @@ test('should not be able to delete project with toggles', async () => {
|
|||||||
await projectService.createProject(project, user);
|
await projectService.createProject(project, user);
|
||||||
await stores.featureToggleStore.create(project.id, {
|
await stores.featureToggleStore.create(project.id, {
|
||||||
name: 'test-project-delete',
|
name: 'test-project-delete',
|
||||||
project: project.id,
|
|
||||||
enabled: false,
|
|
||||||
defaultStickiness: 'default',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -491,31 +495,6 @@ test('should remove user from the project', async () => {
|
|||||||
expect(memberUsers).toHaveLength(0);
|
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 () => {
|
test('should not change project if feature toggle project does not match current project id', async () => {
|
||||||
const project = {
|
const project = {
|
||||||
id: 'test-change-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' };
|
const toggle = { name: 'test-toggle' };
|
||||||
|
|
||||||
await projectService.createProject(project, user);
|
await projectService.createProject(project, user);
|
||||||
await featureToggleService.createFeatureToggle(project.id, toggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectService.changeProject(
|
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' };
|
const toggle = { name: 'test-toggle-2' };
|
||||||
|
|
||||||
await projectService.createProject(project, user);
|
await projectService.createProject(project, user);
|
||||||
await featureToggleService.createFeatureToggle(project.id, toggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectService.changeProject(
|
await projectService.changeProject(
|
||||||
@ -594,7 +581,11 @@ test('should fail if user is not authorized', async () => {
|
|||||||
|
|
||||||
await projectService.createProject(project, user);
|
await projectService.createProject(project, user);
|
||||||
await projectService.createProject(projectDestination, projectAdmin1);
|
await projectService.createProject(projectDestination, projectAdmin1);
|
||||||
await featureToggleService.createFeatureToggle(project.id, toggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectService.changeProject(
|
await projectService.changeProject(
|
||||||
@ -626,7 +617,11 @@ test('should change project when checks pass', async () => {
|
|||||||
|
|
||||||
await projectService.createProject(projectA, user);
|
await projectService.createProject(projectA, user);
|
||||||
await projectService.createProject(projectB, user);
|
await projectService.createProject(projectB, user);
|
||||||
await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
projectA.id,
|
||||||
|
toggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
await projectService.changeProject(
|
await projectService.changeProject(
|
||||||
projectB.id,
|
projectB.id,
|
||||||
toggle.name,
|
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() };
|
const toggle = { name: randomId() };
|
||||||
await projectService.createProject(projectA, user);
|
await projectService.createProject(projectA, user);
|
||||||
await projectService.createProject(projectB, 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();
|
const eventsBeforeChange = await stores.eventStore.getEvents();
|
||||||
await projectService.changeProject(
|
await projectService.changeProject(
|
||||||
projectB.id,
|
projectB.id,
|
||||||
@ -686,7 +685,11 @@ test('should require equal project environments to move features', async () => {
|
|||||||
|
|
||||||
await projectService.createProject(projectA, user);
|
await projectService.createProject(projectA, user);
|
||||||
await projectService.createProject(projectB, 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 stores.environmentStore.create(environment);
|
||||||
await environmentService.addEnvironmentToProject(
|
await environmentService.addEnvironmentToProject(
|
||||||
environment.name,
|
environment.name,
|
||||||
@ -1013,40 +1016,180 @@ test('should able to assign role without existing members', async () => {
|
|||||||
expect(testUsers).toHaveLength(1);
|
expect(testUsers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not update role for user on project when she is the owner', async () => {
|
describe('ensure project has at least one owner', () => {
|
||||||
const project = {
|
test('should not remove user from the project', async () => {
|
||||||
id: 'update-users-not-allowed',
|
const project = {
|
||||||
name: 'New project',
|
id: 'remove-users-not-allowed',
|
||||||
description: 'Blah',
|
name: 'New project',
|
||||||
mode: 'open' as const,
|
description: 'Blah',
|
||||||
defaultStickiness: 'clientId',
|
mode: 'open' as const,
|
||||||
};
|
defaultStickiness: 'clientId',
|
||||||
await projectService.createProject(project, user);
|
};
|
||||||
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const projectMember1 = await stores.userStore.insert({
|
const roles = await stores.roleStore.getRolesForProject(project.id);
|
||||||
name: 'Some Member',
|
const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!;
|
||||||
email: 'update991@getunleash.io',
|
|
||||||
|
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(
|
const projectMember1 = await stores.userStore.insert({
|
||||||
project.id,
|
name: 'Some Member',
|
||||||
memberRole.id,
|
email: 'update991@getunleash.io',
|
||||||
projectMember1.id,
|
});
|
||||||
'test',
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(async () => {
|
const memberRole = await stores.roleStore.getRoleByName(
|
||||||
await projectService.changeRole(
|
RoleName.MEMBER,
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectService.addUser(
|
||||||
project.id,
|
project.id,
|
||||||
memberRole.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,
|
user.id,
|
||||||
'test',
|
'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 () => {
|
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,
|
mode: 'open' as const,
|
||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
const groupStore = stores.groupStore;
|
const groupStore = stores.groupStore;
|
||||||
|
|
||||||
const user1 = await stores.userStore.insert({
|
const user1 = await stores.userStore.insert({
|
||||||
@ -1124,7 +1267,7 @@ test('Should allow bulk update of only groups', async () => {
|
|||||||
};
|
};
|
||||||
const groupStore = stores.groupStore;
|
const groupStore = stores.groupStore;
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const group1 = await groupStore.create({
|
const group1 = await groupStore.create({
|
||||||
name: 'ViewersOnly',
|
name: 'ViewersOnly',
|
||||||
@ -1158,7 +1301,7 @@ test('Should allow permutations of roles, groups and users when adding a new acc
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const group1 = await stores.groupStore.create({
|
const group1 = await stores.groupStore.create({
|
||||||
name: 'permutation-group-1',
|
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(
|
const { users, groups } = await projectService.getAccessToProject(
|
||||||
project.id,
|
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(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]);
|
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, {
|
await stores.featureToggleStore.create(project.id, {
|
||||||
name: 'only-active-t1',
|
name: 'only-active-t1',
|
||||||
project: project.id,
|
|
||||||
enabled: false,
|
|
||||||
});
|
});
|
||||||
await stores.featureToggleStore.create(project.id, {
|
await stores.featureToggleStore.create(project.id, {
|
||||||
name: 'only-active-t2',
|
name: 'only-active-t2',
|
||||||
project: project.id,
|
|
||||||
enabled: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await featureToggleService.archiveToggle('only-active-t2', user);
|
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, {
|
await stores.featureToggleStore.create(project.id, {
|
||||||
name: 'archived-toggle',
|
name: 'archived-toggle',
|
||||||
project: project.id,
|
|
||||||
enabled: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await featureToggleService.archiveToggle('archived-toggle', user);
|
await featureToggleService.archiveToggle('archived-toggle', user);
|
||||||
@ -1294,7 +1433,7 @@ test('should calculate average time to production', async () => {
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
{ name: 'average-prod-time' },
|
{ name: 'average-prod-time' },
|
||||||
@ -1309,7 +1448,7 @@ test('should calculate average time to production', async () => {
|
|||||||
return featureToggleService.createFeatureToggle(
|
return featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1360,7 +1499,7 @@ test('should calculate average time to production ignoring some items', async ()
|
|||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
await stores.environmentStore.create({
|
await stores.environmentStore.create({
|
||||||
name: 'customEnv',
|
name: 'customEnv',
|
||||||
type: 'development',
|
type: 'development',
|
||||||
@ -1369,7 +1508,11 @@ test('should calculate average time to production ignoring some items', async ()
|
|||||||
|
|
||||||
// actual toggle we take for calculations
|
// actual toggle we take for calculations
|
||||||
const toggle = { name: 'main-toggle' };
|
const toggle = { name: 'main-toggle' };
|
||||||
await featureToggleService.createFeatureToggle(project.id, toggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
await updateFeature(toggle.name, {
|
await updateFeature(toggle.name, {
|
||||||
created_at: subDays(new Date(), 20),
|
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
|
// ignore toggles enabled in non-prod envs
|
||||||
const devToggle = { name: 'dev-toggle' };
|
const devToggle = { name: 'dev-toggle' };
|
||||||
await featureToggleService.createFeatureToggle(project.id, devToggle, user);
|
await featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
devToggle,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
await eventService.storeEvent(
|
await eventService.storeEvent(
|
||||||
new FeatureEnvironmentEvent({
|
new FeatureEnvironmentEvent({
|
||||||
...makeEvent(devToggle.name),
|
...makeEvent(devToggle.name),
|
||||||
@ -1397,7 +1544,7 @@ test('should calculate average time to production ignoring some items', async ()
|
|||||||
await featureToggleService.createFeatureToggle(
|
await featureToggleService.createFeatureToggle(
|
||||||
'default',
|
'default',
|
||||||
otherProjectToggle,
|
otherProjectToggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
await eventService.storeEvent(
|
await eventService.storeEvent(
|
||||||
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
|
new FeatureEnvironmentEvent(makeEvent(otherProjectToggle.name)),
|
||||||
@ -1408,7 +1555,7 @@ test('should calculate average time to production ignoring some items', async ()
|
|||||||
await featureToggleService.createFeatureToggle(
|
await featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
nonReleaseToggle,
|
nonReleaseToggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
await eventService.storeEvent(
|
await eventService.storeEvent(
|
||||||
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
|
new FeatureEnvironmentEvent(makeEvent(nonReleaseToggle.name)),
|
||||||
@ -1419,7 +1566,7 @@ test('should calculate average time to production ignoring some items', async ()
|
|||||||
await featureToggleService.createFeatureToggle(
|
await featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
previouslyDeleteToggle,
|
previouslyDeleteToggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
await eventService.storeEvent(
|
await eventService.storeEvent(
|
||||||
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
|
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteToggle.name)),
|
||||||
@ -1441,7 +1588,7 @@ test('should get correct amount of features created in current and past window',
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
{ name: 'features-created' },
|
{ name: 'features-created' },
|
||||||
@ -1455,7 +1602,7 @@ test('should get correct amount of features created in current and past window',
|
|||||||
return featureToggleService.createFeatureToggle(
|
return featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1478,7 +1625,7 @@ test('should get correct amount of features archived in current and past window'
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
{ name: 'features-archived' },
|
{ name: 'features-archived' },
|
||||||
@ -1492,7 +1639,7 @@ test('should get correct amount of features archived in current and past window'
|
|||||||
return featureToggleService.createFeatureToggle(
|
return featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1529,7 +1676,7 @@ test('should get correct amount of project members for current and past window',
|
|||||||
defaultStickiness: 'default',
|
defaultStickiness: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const users = [
|
const users = [
|
||||||
{ name: 'memberOne', email: 'memberOne@getunleash.io' },
|
{ 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);
|
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.projectActivityCurrentWindow).toBe(6);
|
||||||
expect(result.updates.projectActivityPastWindow).toBe(0);
|
expect(result.updates.projectActivityPastWindow).toBe(0);
|
||||||
});
|
});
|
||||||
@ -1569,7 +1716,7 @@ test('should return average time to production per toggle', async () => {
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
{ name: 'average-prod-time-pt', subdays: 7 },
|
{ name: 'average-prod-time-pt', subdays: 7 },
|
||||||
@ -1584,7 +1731,7 @@ test('should return average time to production per toggle', async () => {
|
|||||||
return featureToggleService.createFeatureToggle(
|
return featureToggleService.createFeatureToggle(
|
||||||
project.id,
|
project.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1633,8 +1780,8 @@ test('should return average time to production per toggle for a specific project
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project1, user.id);
|
await projectService.createProject(project1, user);
|
||||||
await projectService.createProject(project2, user.id);
|
await projectService.createProject(project2, user);
|
||||||
|
|
||||||
const togglesProject1 = [
|
const togglesProject1 = [
|
||||||
{ name: 'average-prod-time-pt-10', subdays: 7 },
|
{ 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(
|
return featureToggleService.createFeatureToggle(
|
||||||
project1.id,
|
project1.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1662,7 +1809,7 @@ test('should return average time to production per toggle for a specific project
|
|||||||
return featureToggleService.createFeatureToggle(
|
return featureToggleService.createFeatureToggle(
|
||||||
project2.id,
|
project2.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1726,7 +1873,7 @@ test('should return average time to production per toggle and include archived t
|
|||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project1, user.id);
|
await projectService.createProject(project1, user);
|
||||||
|
|
||||||
const togglesProject1 = [
|
const togglesProject1 = [
|
||||||
{ name: 'average-prod-time-pta-10', subdays: 7 },
|
{ 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(
|
return featureToggleService.createFeatureToggle(
|
||||||
project1.id,
|
project1.id,
|
||||||
toggle,
|
toggle,
|
||||||
user,
|
user.email,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -1790,7 +1937,7 @@ describe('feature flag naming patterns', () => {
|
|||||||
featureNaming,
|
featureNaming,
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
await projectService.updateProjectEnterpriseSettings(project, user);
|
await projectService.updateProjectEnterpriseSettings(project, user);
|
||||||
|
|
||||||
@ -1804,7 +1951,7 @@ describe('feature flag naming patterns', () => {
|
|||||||
...project,
|
...project,
|
||||||
featureNaming: { pattern: newPattern },
|
featureNaming: { pattern: newPattern },
|
||||||
},
|
},
|
||||||
user.id,
|
user,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedProject = await projectService.getProject(project.id);
|
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';
|
const toggleName = 'archived-and-deleted';
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
|
|
||||||
await stores.featureToggleStore.create(project.id, {
|
await stores.featureToggleStore.create(project.id, {
|
||||||
name: toggleName,
|
name: toggleName,
|
||||||
project: project.id,
|
|
||||||
enabled: false,
|
|
||||||
defaultStickiness: 'default',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await stores.featureToggleStore.archive(toggleName);
|
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
|
// bring the project back again, previously this would allow those archived toggles to be resurrected
|
||||||
// we now expect them to be deleted correctly
|
// 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({
|
const toggles = await stores.featureToggleStore.getAll({
|
||||||
project: project.id,
|
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',
|
name: 'project-with-nothing',
|
||||||
};
|
};
|
||||||
|
|
||||||
await projectService.createProject(project, user.id);
|
await projectService.createProject(project, user);
|
||||||
await projectService.deleteProject(project.id, user);
|
await projectService.deleteProject(project.id, user);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user