diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index df60309aa047..538095dfb306 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,5 +17,5 @@ jobs: uses: actions/dependency-review-action@v4 with: fail-on-severity: moderate - allow-licenses: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, CC0-1.0, Unlicense, BlueOak-1.0.0, CC-BY-4.0 + allow-licenses: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, CC0-1.0, Unlicense, BlueOak-1.0.0, CC-BY-4.0, Artistic-2.0 comment-summary-in-pr: always diff --git a/frontend/cypress/integration/demo/demo.spec.ts b/frontend/cypress/integration/demo/demo.spec.ts index f40fb4e2e35b..2a3b5e0fe663 100644 --- a/frontend/cypress/integration/demo/demo.spec.ts +++ b/frontend/cypress/integration/demo/demo.spec.ts @@ -40,6 +40,7 @@ describe('demo', () => { res.body.flags = { ...res.body.flags, demo: true, + flagOverviewRedesign: true, }; } }); diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index d65c017b93d8..d52c7ecd5516 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -1,6 +1,7 @@ /// describe('feature', () => { + const baseUrl = Cypress.config().baseUrl; const randomId = String(Math.random()).split('.')[1]; const featureToggleName = `unleash-e2e-${randomId}`; const projectName = `unleash-e2e-project-${randomId}`; @@ -35,6 +36,19 @@ describe('feature', () => { beforeEach(() => { cy.login_UI(); cy.visit('/features'); + + cy.intercept('GET', `${baseUrl}/api/admin/ui-config`, (req) => { + req.headers['cache-control'] = + 'no-cache, no-store, must-revalidate'; + req.on('response', (res) => { + if (res.body) { + res.body.flags = { + ...res.body.flags, + flagOverviewRedesign: true, + }; + } + }); + }); }); it('can create a feature flag', () => { diff --git a/frontend/cypress/support/UI.ts b/frontend/cypress/support/UI.ts index d9801a494364..64ba45d62b2a 100644 --- a/frontend/cypress/support/UI.ts +++ b/frontend/cypress/support/UI.ts @@ -227,7 +227,6 @@ export const deleteFeatureStrategy_UI = ( }, ).as('deleteUserStrategy'); cy.visit(`/projects/${project}/features/${featureToggleName}`); - cy.get('[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]').click(); cy.get('[data-testid=STRATEGY_REMOVE_MENU_BTN]').first().click(); cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').first().click(); if (!shouldWait) return cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); diff --git a/frontend/package.json b/frontend/package.json index e3de68e65b5a..cc9dce3453a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ "test:snapshot": "NODE_OPTIONS=\"${NODE_OPTIONS:-0} --no-experimental-fetch\" yarn test -u", "test:watch": "NODE_OPTIONS=\"${NODE_OPTIONS:-0} --no-experimental-fetch\" vitest watch", "lint:material:icons": "./check-imports.rc", - "lint": "biome lint src --apply", + "lint": "biome lint src --write", "lint:check": "biome check src", "fmt": "biome format src --write", "fmt:check": "biome check src", diff --git a/frontend/src/assets/img/releaseTemplates.svg b/frontend/src/assets/img/releaseTemplates.svg new file mode 100644 index 000000000000..775dc81b56ae --- /dev/null +++ b/frontend/src/assets/img/releaseTemplates.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/component/common/Highlight/Highlight.tsx b/frontend/src/component/common/Highlight/Highlight.tsx index b4e66197f977..e977fc3e7bf2 100644 --- a/frontend/src/component/common/Highlight/Highlight.tsx +++ b/frontend/src/component/common/Highlight/Highlight.tsx @@ -1,5 +1,5 @@ import { alpha, styled } from '@mui/material'; -import type { ReactNode } from 'react'; +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; import { useHighlightContext } from './HighlightContext'; import type { HighlightKey } from './HighlightProvider'; @@ -27,17 +27,23 @@ const StyledHighlight = styled('div', { }, })); -interface IHighlightProps { +interface IHighlightProps extends HTMLAttributes { highlightKey: HighlightKey; children: ReactNode; } -export const Highlight = ({ highlightKey, children }: IHighlightProps) => { - const { isHighlighted } = useHighlightContext(); +export const Highlight = forwardRef( + ({ highlightKey, children, ...props }, ref) => { + const { isHighlighted } = useHighlightContext(); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); diff --git a/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx index 14f3758a2d63..6ac27e89d39a 100644 --- a/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx +++ b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx @@ -1,4 +1,5 @@ import { Button, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; const StyledTab = styled(Button)<{ selected: boolean }>( ({ theme, selected }) => ({ @@ -17,7 +18,8 @@ const StyledTab = styled(Button)<{ selected: boolean }>( transition: 'background-color 0.2s ease', color: theme.palette.text.primary, textAlign: 'left', - padding: theme.spacing(2, 4), + padding: theme.spacing(0, 2), + gap: theme.spacing(1), fontSize: theme.fontSizes.bodySize, fontWeight: selected ? theme.fontWeight.bold @@ -41,27 +43,53 @@ const StyledTab = styled(Button)<{ selected: boolean }>( }), ); +const StyledTabLabel = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), +})); + +const StyledTabDescription = styled('div')(({ theme }) => ({ + fontWeight: theme.fontWeight.medium, + fontSize: theme.fontSizes.smallBody, + color: theme.palette.text.secondary, +})); + interface IVerticalTabProps { label: string; + description?: string; selected?: boolean; onClick: () => void; - icon?: React.ReactNode; + startIcon?: React.ReactNode; + endIcon?: React.ReactNode; } export const VerticalTab = ({ label, + description, selected, onClick, - icon, + startIcon, + endIcon, }: IVerticalTabProps) => ( - {label} - {icon} + {startIcon} + + {label} + {description} + } + /> + + {endIcon} ); diff --git a/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx index 85d05d8d8ee3..b40707445de4 100644 --- a/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx +++ b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material'; import { VerticalTab } from './VerticalTab/VerticalTab'; +import type { HTMLAttributes } from 'react'; const StyledTabPage = styled('div')(({ theme }) => ({ display: 'flex', @@ -15,11 +16,13 @@ const StyledTabPageContent = styled('div')(() => ({ flexDirection: 'column', })); -const StyledTabs = styled('div')(({ theme }) => ({ +const StyledTabs = styled('div', { + shouldForwardProp: (prop) => prop !== 'fullWidth', +})<{ fullWidth?: boolean }>(({ theme, fullWidth }) => ({ display: 'flex', flexDirection: 'column', gap: theme.spacing(1), - width: theme.spacing(30), + width: fullWidth ? '100%' : theme.spacing(30), flexShrink: 0, [theme.breakpoints.down('xl')]: { width: '100%', @@ -29,16 +32,19 @@ const StyledTabs = styled('div')(({ theme }) => ({ export interface ITab { id: string; label: string; + description?: string; path?: string; hidden?: boolean; - icon?: React.ReactNode; + startIcon?: React.ReactNode; + endIcon?: React.ReactNode; } -interface IVerticalTabsProps { +interface IVerticalTabsProps + extends Omit, 'onChange'> { tabs: ITab[]; value: string; onChange: (tab: ITab) => void; - children: React.ReactNode; + children?: React.ReactNode; } export const VerticalTabs = ({ @@ -46,21 +52,33 @@ export const VerticalTabs = ({ value, onChange, children, -}: IVerticalTabsProps) => ( - - - {tabs - .filter((tab) => !tab.hidden) - .map((tab) => ( - onChange(tab)} - icon={tab.icon} - /> - ))} - - {children} - -); + ...props +}: IVerticalTabsProps) => { + const verticalTabs = tabs + .filter((tab) => !tab.hidden) + .map((tab) => ( + onChange(tab)} + startIcon={tab.startIcon} + endIcon={tab.endIcon} + /> + )); + + if (!children) { + return ( + + {verticalTabs} + + ); + } + return ( + + {verticalTabs} + {children} + + ); +}; diff --git a/frontend/src/component/demo/demo-topics.tsx b/frontend/src/component/demo/demo-topics.tsx index 9ef80f17dc5f..3fed931dfa66 100644 --- a/frontend/src/component/demo/demo-topics.tsx +++ b/frontend/src/component/demo/demo-topics.tsx @@ -131,7 +131,7 @@ export const TOPICS: ITutorialTopic[] = [ }, { href: `/projects/${PROJECT}/features/demoApp.step2`, - target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button`, + target: 'button[data-testid="ADD_STRATEGY_BUTTON"]', content: ( Add a new strategy to this environment by using this @@ -363,9 +363,10 @@ export const TOPICS: ITutorialTopic[] = [ strategies by using the arrow button. ), + optional: true, }, { - target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"].Mui-expanded a[data-testid="STRATEGY_EDIT-flexibleRollout"]`, + target: `a[data-testid="STRATEGY_EDIT-flexibleRollout"]`, content: ( Edit the existing gradual rollout strategy by using the @@ -471,7 +472,7 @@ export const TOPICS: ITutorialTopic[] = [ }, { href: `/projects/${PROJECT}/features/demoApp.step4`, - target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button`, + target: 'button[data-testid="ADD_STRATEGY_BUTTON"]', content: ( Add a new strategy to this environment by using this diff --git a/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx b/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx index cb5cbc0a5a2c..e29ee1171804 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx @@ -96,7 +96,7 @@ export const EventTimelineHeader = ({ 0} show={() => ( ({ alignItems: 'center', })); -const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>( - ({ theme, enabled }) => ({ - fontWeight: enabled ? 'bold' : 'normal', - }), -); +const EnvironmentTypography = styled(Typography, { + shouldForwardProp: (prop) => prop !== 'enabled', +})<{ enabled: boolean }>(({ enabled }) => ({ + fontWeight: enabled ? 'bold' : 'normal', +})); const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({ marginRight: theme.spacing(0.5), diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx index 6bc5083bbb7d..4d17eb5682df 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx @@ -80,6 +80,7 @@ export const FeatureStrategyMenu = ({ return ( event.stopPropagation()}> { const theme = useTheme(); - const featureSearchFeedback = useUiFlag('featureSearchFeedback'); const { trackEvent } = usePlausibleTracker(); const { environments } = useEnvironments(); const enabledEnvironments = environments @@ -68,17 +56,6 @@ export const FeatureToggleListTable: VFC = () => { const { setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); - const variant = - featureSearchFeedback !== false - ? (featureSearchFeedback?.name ?? '') - : ''; - - const { openFeedback } = useFeedback( - feedbackCategory, - 'automatic', - variant, - ); - const { features, total, @@ -269,15 +246,6 @@ export const FeatureToggleListTable: VFC = () => { return null; } - const createFeedbackContext = () => { - openFeedback({ - title: 'How easy was it to use search and filters?', - positiveLabel: 'What do you like most about search and filters?', - areasForImprovementsLabel: - 'What should be improved in search and filters page?', - }); - }; - return ( { setShowExportDialog(true)} /> - {featureSearchFeedback !== false && - featureSearchFeedback?.enabled && ( - <> - - - - - - } - /> - - } - onClick={ - createFeedbackContext - } - > - Provide feedback - - } - />{' '} - - } - onClick={ - createFeedbackContext - } - variant='outlined' - > - Provide feedback - - } - /> - - )} } > diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index 3d6b7a0f1468..29cacdbbe74e 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -1,4 +1,4 @@ -import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; +import NewFeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; import { Route, Routes, useNavigate } from 'react-router-dom'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; @@ -8,12 +8,17 @@ import { } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; -import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; +import { FeatureOverviewSidePanel as NewFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments'; import { styled } from '@mui/material'; import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useLastViewedFlags } from 'hooks/useLastViewedFlags'; +import { useUiFlag } from 'hooks/useUiFlag'; +import OldFeatureOverviewMetaData from './FeatureOverviewMetaData/OldFeatureOverviewMetaData'; +import { OldFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { NewFeatureOverviewEnvironment } from './NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment'; const StyledContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -45,18 +50,40 @@ const FeatureOverview = () => { useEffect(() => { setLastViewed({ featureId, projectId }); }, [featureId]); + const [environmentId, setEnvironmentId] = useState(''); + + const flagOverviewRedesign = useUiFlag('flagOverviewRedesign'); + const FeatureOverviewMetaData = flagOverviewRedesign + ? NewFeatureOverviewMetaData + : OldFeatureOverviewMetaData; + const FeatureOverviewSidePanel = flagOverviewRedesign ? ( + + ) : ( + + ); return (
- + {FeatureOverviewSidePanel}
- + + } + elseShow={} + /> ({ + height: theme.spacing(3.5), + width: theme.spacing(3.5), +})); + const StyledPopover = styled(Popover)(({ theme }) => ({ borderRadius: theme.shape.borderRadiusLarge, padding: theme.spacing(1, 1.5), })); -export const DependencyActions: FC<{ +interface IDependencyActionsProps { feature: string; onEdit: () => void; onDelete: () => void; -}> = ({ feature, onEdit, onDelete }) => { +} + +export const DependencyActions = ({ + feature, + onEdit, + onDelete, +}: IDependencyActionsProps) => { const id = `dependency-${feature}-actions`; const menuId = `${id}-menu`; @@ -42,8 +53,7 @@ export const DependencyActions: FC<{ return ( - - + ({ + '&&&': { + fontSize: theme.fontSizes.smallBody, + lineHeight: 1, + margin: 0, + }, +})); const useDeleteDependency = (project: string, featureId: string) => { const { trackEvent } = usePlausibleTracker(); @@ -83,7 +92,11 @@ const useDeleteDependency = (project: string, featureId: string) => { return deleteDependency; }; -export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => { +interface IDependencyRowProps { + feature: IFeatureToggle; +} + +export const DependencyRow = ({ feature }: IDependencyRowProps) => { const [showDependencyDialogue, setShowDependencyDialogue] = useState(false); const canAddParentDependency = Boolean(feature.project) && @@ -103,55 +116,54 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => { - - Dependency: - { - setShowDependencyDialogue(true); - }} - sx={(theme) => ({ - marginBottom: theme.spacing(0.4), - })} - > - Add parent feature - - - + + + Dependency: + + { + setShowDependencyDialogue(true); + }} + > + Add parent feature + + } /> - - Dependency: + + + Dependency: + + {feature.dependencies[0]?.feature} - - - setShowDependencyDialogue(true) - } - onDelete={deleteDependency} - /> - } - /> - + + setShowDependencyDialogue(true) + } + onDelete={deleteDependency} + /> + } + /> + + } /> = ({ feature }) => { hasParentDependency && !feature.dependencies[0]?.enabled } show={ - - - Dependency value: - disabled - - + + + Dependency value: + + disabled + } /> = ({ feature }) => { Boolean(feature.dependencies[0]?.variants?.length) } show={ - - - Dependency value: - - - + + + Dependency value: + + + } /> - - Children: - - - + + + Children: + + + } /> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx index fc3421697d38..ba8c1656391b 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx @@ -1,262 +1,201 @@ -import { Box, capitalize, styled } from '@mui/material'; -import { Link, useNavigate } from 'react-router-dom'; +import { capitalize, styled } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import Edit from '@mui/icons-material/Edit'; -import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; -import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { useState } from 'react'; import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; -import { StyledDetail } from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; import { formatDateYMD } from 'utils/formatDate'; import { parseISO } from 'date-fns'; -import { FeatureEnvironmentSeen } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; import { DependencyRow } from './DependencyRow'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { useShowDependentFeatures } from './useShowDependentFeatures'; -import type { ILastSeenEnvironments } from 'interfaces/featureToggle'; import { FeatureLifecycle } from '../FeatureLifecycle/FeatureLifecycle'; import { MarkCompletedDialogue } from '../FeatureLifecycle/MarkCompletedDialogue'; import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import { TagRow } from './TagRow'; -const StyledContainer = styled('div')(({ theme }) => ({ +const StyledMetaDataContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(3), borderRadius: theme.shape.borderRadiusLarge, backgroundColor: theme.palette.background.paper, display: 'flex', flexDirection: 'column', - maxWidth: '350px', - minWidth: '350px', - marginRight: theme.spacing(2), + gap: theme.spacing(2), + width: '350px', [theme.breakpoints.down(1000)]: { width: '100%', - maxWidth: 'none', - minWidth: 'auto', }, })); -const StyledPaddingContainerTop = styled('div')({ - padding: '1.5rem 1.5rem 0 1.5rem', -}); - -const StyledMetaDataHeader = styled('div')({ +const StyledMetaDataHeader = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', -}); - -const StyledHeader = styled('h2')(({ theme }) => ({ - fontSize: theme.fontSizes.mainHeader, - fontWeight: 'normal', - margin: 0, + gap: theme.spacing(2), + '& > svg': { + height: theme.spacing(5), + width: theme.spacing(5), + padding: theme.spacing(0.5), + backgroundColor: theme.palette.background.alternative, + fill: theme.palette.primary.contrastText, + borderRadius: theme.shape.borderRadiusMedium, + }, + '& > h2': { + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + }, })); -const StyledBody = styled('div')(({ theme }) => ({ - margin: theme.spacing(2, 0), +const StyledBody = styled('div')({ display: 'flex', flexDirection: 'column', - fontSize: theme.fontSizes.smallBody, -})); - -const BodyItemWithIcon = styled('div')(({ theme }) => ({})); - -const SpacedBodyItem = styled('div')(({ theme }) => ({ - display: 'flex', - justifyContent: 'space-between', - padding: theme.spacing(1, 0), -})); +}); -const StyledDescriptionContainer = styled('div')(({ theme }) => ({ +export const StyledMetaDataItem = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', + minHeight: theme.spacing(4.25), + fontSize: theme.fontSizes.smallBody, })); -const StyledDetailsContainer = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', +export const StyledMetaDataItemLabel = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginRight: theme.spacing(1), })); -const StyledDescription = styled('p')({ - wordBreak: 'break-word', +const StyledMetaDataItemText = styled('span')({ + overflowWrap: 'anywhere', }); -const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ - margin: theme.spacing(1), +export const StyledMetaDataItemValue = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), })); -export const StyledLabel = styled('span')(({ theme }) => ({ - color: theme.palette.text.secondary, - marginRight: theme.spacing(1), +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + height: theme.spacing(3.5), + width: theme.spacing(3.5), })); const FeatureOverviewMetaData = () => { const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const { feature, refetchFeature } = useFeature(projectId, featureId); - const { project, description, type } = feature; + + const { locationSettings } = useLocationSettings(); const navigate = useNavigate(); - const [showDelDialog, setShowDelDialog] = useState(false); - const [showMarkCompletedDialogue, setShowMarkCompletedDialogue] = + + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [markCompletedDialogueOpen, setMarkCompletedDialogueOpen] = useState(false); - const { locationSettings } = useLocationSettings(); - const showDependentFeatures = useShowDependentFeatures(feature.project); + const { project, description, type } = feature; - const lastSeenEnvironments: ILastSeenEnvironments[] = - feature.environments?.map((env) => ({ - name: env.name, - lastSeenAt: env.lastSeenAt, - enabled: env.enabled, - yes: env.yes, - no: env.no, - })); + const showDependentFeatures = useShowDependentFeatures(project); - const IconComponent = getFeatureTypeIcons(type); + const FlagTypeIcon = getFeatureTypeIcons(type); return ( - - + <> + - ({ - marginRight: theme.spacing(2), - height: '40px', - width: '40px', - padding: theme.spacing(0.5), - backgroundColor: - theme.palette.background.alternative, - fill: theme.palette.primary.contrastText, - borderRadius: `${theme.shape.borderRadiusMedium}px`, - })} - />{' '} - {capitalize(type || '')} toggle + +

{capitalize(type || '')} flag

+ + + {description} + + + } + /> - - Project: - {project} - + + + Project: + + + {project} + + - Lifecycle: + + + Lifecycle: + setShowDelDialog(true)} + onArchive={() => setArchiveDialogOpen(true)} onComplete={() => - setShowMarkCompletedDialogue(true) + setMarkCompletedDialogueOpen(true) } onUncomplete={refetchFeature} /> - + } /> - - - Description: - - - {description} - - - - - - - } - elseShow={ -
- - No description.{' '} - - - - -
- } - /> - - - - Created at: - - {formatDateYMD( - parseISO(feature.createdAt), - locationSettings.locale, - )} - - - - - - + + + Created at: + + + {formatDateYMD( + parseISO(feature.createdAt), + locationSettings.locale, + )} + + ( - - - - Created by: - {feature.createdBy?.name} - + + + Created by: + + + + {feature.createdBy?.name} + - - + + )} /> } /> +
-
+ 0} show={ setShowDelDialog(false)} + isOpen={archiveDialogOpen} + onClose={() => setArchiveDialogOpen(false)} /> } elseShow={ { navigate(`/projects/${projectId}`); }} - onClose={() => setShowDelDialog(false)} + onClose={() => setArchiveDialogOpen(false)} projectId={projectId} featureIds={[featureId]} /> @@ -266,15 +205,15 @@ const FeatureOverviewMetaData = () => { condition={Boolean(feature.project)} show={ } /> -
+ ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx new file mode 100644 index 000000000000..0e294a04c5ca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx @@ -0,0 +1,104 @@ +import type React from 'react'; +import { type FC, useState } from 'react'; +import { + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + styled, + Tooltip, + Typography, + Box, +} from '@mui/material'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import MoreVert from '@mui/icons-material/MoreVert'; + +const StyledPopover = styled(Popover)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1, 1.5), +})); + +export const OldDependencyActions: FC<{ + feature: string; + onEdit: () => void; + onDelete: () => void; +}> = ({ feature, onEdit, onDelete }) => { + const id = `dependency-${feature}-actions`; + const menuId = `${id}-menu`; + + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const openActions = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const closeActions = () => { + setAnchorEl(null); + }; + + return ( + + + + + + + + + { + onEdit(); + closeActions(); + }} + > + + + + + Edit + + + + { + onDelete(); + closeActions(); + }} + > + + + + + Delete + + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx new file mode 100644 index 000000000000..b27acdf87907 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx @@ -0,0 +1,219 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { AddDependencyDialogue } from 'component/feature/Dependencies/AddDependencyDialogue'; +import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { type FC, useState } from 'react'; +import { + FlexRow, + StyledDetail, + StyledLabel, + StyledLink, +} from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; +import { OldDependencyActions } from './OldDependencyActions'; +import { useDependentFeaturesApi } from 'hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { ChildrenTooltip } from './ChildrenTooltip'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { UPDATE_FEATURE_DEPENDENCY } from 'component/providers/AccessProvider/permissions'; +import { useCheckProjectAccess } from 'hooks/useHasAccess'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import useToast from 'hooks/useToast'; +import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { VariantsTooltip } from './VariantsTooltip'; + +const useDeleteDependency = (project: string, featureId: string) => { + const { trackEvent } = usePlausibleTracker(); + const { addChange } = useChangeRequestApi(); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(project); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(project, featureId); + const environment = useHighestPermissionChangeRequestEnvironment(project)(); + const { isChangeRequestConfiguredInAnyEnv } = + useChangeRequestsEnabled(project); + const { removeDependencies } = useDependentFeaturesApi(project); + + const handleAddChange = async () => { + if (!environment) { + console.error('No change request environment'); + return; + } + await addChange(project, environment, [ + { + action: 'deleteDependency', + feature: featureId, + payload: undefined, + }, + ]); + }; + + const deleteDependency = async () => { + try { + if (isChangeRequestConfiguredInAnyEnv()) { + await handleAddChange(); + trackEvent('dependent_features', { + props: { + eventType: 'delete dependency added to change request', + }, + }); + setToastData({ + text: `${featureId} dependency will be removed`, + type: 'success', + title: 'Change added to a draft', + }); + await refetchChangeRequests(); + } else { + await removeDependencies(featureId); + trackEvent('dependent_features', { + props: { + eventType: 'dependency removed', + }, + }); + setToastData({ title: 'Dependency removed', type: 'success' }); + await refetchFeature(); + } + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + return deleteDependency; +}; + +export const OldDependencyRow: FC<{ feature: IFeatureToggle }> = ({ + feature, +}) => { + const [showDependencyDialogue, setShowDependencyDialogue] = useState(false); + const canAddParentDependency = + Boolean(feature.project) && + feature.dependencies.length === 0 && + feature.children.length === 0; + const hasParentDependency = + Boolean(feature.project) && Boolean(feature.dependencies.length > 0); + const hasChildren = Boolean(feature.project) && feature.children.length > 0; + const environment = useHighestPermissionChangeRequestEnvironment( + feature.project, + )(); + const checkAccess = useCheckProjectAccess(feature.project); + const deleteDependency = useDeleteDependency(feature.project, feature.name); + + return ( + <> + + + Dependency: + { + setShowDependencyDialogue(true); + }} + sx={(theme) => ({ + marginBottom: theme.spacing(0.4), + })} + > + Add parent feature + + + + } + /> + + + Dependency: + + {feature.dependencies[0]?.feature} + + + + setShowDependencyDialogue(true) + } + onDelete={deleteDependency} + /> + } + /> + + } + /> + + + Dependency value: + disabled + + + } + /> + + + Dependency value: + + + + } + /> + + + Children: + + + + } + /> + + setShowDependencyDialogue(false)} + showDependencyDialogue={showDependencyDialogue} + /> + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx new file mode 100644 index 000000000000..a6e86dedbce4 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx @@ -0,0 +1,281 @@ +import { Box, capitalize, styled } from '@mui/material'; +import { Link, useNavigate } from 'react-router-dom'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import Edit from '@mui/icons-material/Edit'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; +import { useState } from 'react'; +import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; +import { StyledDetail } from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; +import { formatDateYMD } from 'utils/formatDate'; +import { parseISO } from 'date-fns'; +import { FeatureEnvironmentSeen } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; +import { OldDependencyRow } from './OldDependencyRow'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { useShowDependentFeatures } from './useShowDependentFeatures'; +import type { ILastSeenEnvironments } from 'interfaces/featureToggle'; +import { FeatureLifecycle } from '../FeatureLifecycle/FeatureLifecycle'; +import { MarkCompletedDialogue } from '../FeatureLifecycle/MarkCompletedDialogue'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const StyledContainer = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + maxWidth: '350px', + minWidth: '350px', + marginRight: theme.spacing(2), + [theme.breakpoints.down(1000)]: { + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, +})); + +const StyledPaddingContainerTop = styled('div')({ + padding: '1.5rem 1.5rem 0 1.5rem', +}); + +const StyledMetaDataHeader = styled('div')({ + display: 'flex', + alignItems: 'center', +}); + +const StyledHeader = styled('h2')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + margin: 0, +})); + +const StyledBody = styled('div')(({ theme }) => ({ + margin: theme.spacing(2, 0), + display: 'flex', + flexDirection: 'column', + fontSize: theme.fontSizes.smallBody, +})); + +const BodyItemWithIcon = styled('div')(({ theme }) => ({})); + +const SpacedBodyItem = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 0), +})); + +const StyledDescriptionContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledDetailsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledDescription = styled('p')({ + wordBreak: 'break-word', +}); + +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + margin: theme.spacing(1), +})); + +export const StyledLabel = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginRight: theme.spacing(1), +})); + +const OldFeatureOverviewMetaData = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature, refetchFeature } = useFeature(projectId, featureId); + const { project, description, type } = feature; + const navigate = useNavigate(); + const [showDelDialog, setShowDelDialog] = useState(false); + const [showMarkCompletedDialogue, setShowMarkCompletedDialogue] = + useState(false); + + const { locationSettings } = useLocationSettings(); + const showDependentFeatures = useShowDependentFeatures(feature.project); + + const lastSeenEnvironments: ILastSeenEnvironments[] = + feature.environments?.map((env) => ({ + name: env.name, + lastSeenAt: env.lastSeenAt, + enabled: env.enabled, + yes: env.yes, + no: env.no, + })); + + const IconComponent = getFeatureTypeIcons(type); + + return ( + + + + ({ + marginRight: theme.spacing(2), + height: '40px', + width: '40px', + padding: theme.spacing(0.5), + backgroundColor: + theme.palette.background.alternative, + fill: theme.palette.primary.contrastText, + borderRadius: `${theme.shape.borderRadiusMedium}px`, + })} + />{' '} + {capitalize(type || '')} toggle + + + + Project: + {project} + + + Lifecycle: + setShowDelDialog(true)} + onComplete={() => + setShowMarkCompletedDialogue(true) + } + onUncomplete={refetchFeature} + /> + + } + /> + + + Description: + + + {description} + + + + + + + } + elseShow={ +
+ + No description.{' '} + + + + +
+ } + /> + + + + Created at: + + {formatDateYMD( + parseISO(feature.createdAt), + locationSettings.locale, + )} + + + + + + + ( + + + + Created by: + {feature.createdBy?.name} + + + + + )} + /> + } + /> +
+
+ 0} + show={ + setShowDelDialog(false)} + /> + } + elseShow={ + { + navigate(`/projects/${projectId}`); + }} + onClose={() => setShowDelDialog(false)} + projectId={projectId} + featureIds={[featureId]} + /> + } + /> + + } + /> +
+ ); +}; + +export default OldFeatureOverviewMetaData; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx new file mode 100644 index 000000000000..583b771110e9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx @@ -0,0 +1,203 @@ +import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { useContext, useState } from 'react'; +import { Chip, styled, Tooltip } from '@mui/material'; +import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags'; +import Add from '@mui/icons-material/Add'; +import ClearIcon from '@mui/icons-material/Clear'; +import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import type { ITag } from 'interfaces/tags'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + StyledMetaDataItem, + StyledMetaDataItemLabel, +} from './FeatureOverviewMetaData'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; + +const StyledPermissionButton = styled(PermissionButton)(({ theme }) => ({ + '&&&': { + fontSize: theme.fontSizes.smallBody, + lineHeight: 1, + margin: 0, + }, +})); + +const StyledTagRow = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'start', + minHeight: theme.spacing(4.25), + lineHeight: theme.spacing(4.25), + fontSize: theme.fontSizes.smallBody, + justifyContent: 'start', +})); + +const StyledTagContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flex: 1, + overflow: 'hidden', + gap: theme.spacing(1), + flexWrap: 'wrap', + marginTop: theme.spacing(0.75), +})); + +const StyledChip = styled(Chip)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + overflowWrap: 'anywhere', + backgroundColor: theme.palette.neutral.light, + color: theme.palette.neutral.dark, + '&&& > svg': { + color: theme.palette.neutral.dark, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const StyledAddedTag = styled(StyledChip)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.light, + color: theme.palette.secondary.dark, + '&&& > svg': { + color: theme.palette.secondary.dark, + fontSize: theme.fontSizes.smallBody, + }, +})); + +interface IFeatureOverviewSidePanelTagsProps { + feature: IFeatureToggle; +} + +export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => { + const { tags, refetch } = useFeatureTags(feature.name); + const { deleteTagFromFeature } = useFeatureApi(); + + const [manageTagsOpen, setManageTagsOpen] = useState(false); + const [removeTagOpen, setRemoveTagOpen] = useState(false); + const [selectedTag, setSelectedTag] = useState(); + + const { setToastData, setToastApiError } = useToast(); + const { hasAccess } = useContext(AccessContext); + const canUpdateTags = hasAccess(UPDATE_FEATURE, feature.project); + + const handleRemove = async () => { + if (!selectedTag) return; + try { + await deleteTagFromFeature( + feature.name, + selectedTag.type, + selectedTag.value, + ); + refetch(); + setToastData({ + type: 'success', + title: 'Tag removed', + text: 'Successfully removed tag', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + <> + + Tags: + { + setManageTagsOpen(true); + }} + > + Add tag + + + } + elseShow={ + + Tags: + + {tags.map((tag) => { + const tagLabel = `${tag.type}:${tag.value}`; + return ( + 35 ? tagLabel : '' + } + arrow + > + + + + } + onDelete={ + canUpdateTags + ? () => { + setRemoveTagOpen( + true, + ); + setSelectedTag(tag); + } + : undefined + } + /> + + ); + })} + } + label='Add tag' + size='small' + onClick={() => setManageTagsOpen(true)} + /> + } + /> + + + } + /> + + { + setRemoveTagOpen(false); + setSelectedTag(undefined); + }} + onClick={() => { + setRemoveTagOpen(false); + handleRemove(); + setSelectedTag(undefined); + }} + title='Remove tag' + > + You are about to remove tag:{' '} + + {selectedTag?.type}:{selectedTag?.value} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx index adfdab56780d..aa323e82a5e8 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx @@ -1,88 +1,83 @@ -import { Box, Divider, styled } from '@mui/material'; -import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { Box, styled } from '@mui/material'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches'; -import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags'; import { Sticky } from 'component/common/Sticky/Sticky'; +import { + type ITab, + VerticalTabs, +} from 'component/common/VerticalTabs/VerticalTabs'; +import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon'; +import { useEffect } from 'react'; const StyledContainer = styled(Box)(({ theme }) => ({ - top: theme.spacing(2), + margin: theme.spacing(2), + marginLeft: 0, + padding: theme.spacing(3), borderRadius: theme.shape.borderRadiusLarge, backgroundColor: theme.palette.background.paper, display: 'flex', flexDirection: 'column', - maxWidth: '350px', - minWidth: '350px', - marginRight: '1rem', - marginTop: '1rem', + gap: theme.spacing(2), + width: '350px', [theme.breakpoints.down(1000)]: { - marginBottom: '1rem', width: '100%', - maxWidth: 'none', - minWidth: 'auto', }, })); const StyledHeader = styled('h3')(({ theme }) => ({ display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', fontSize: theme.fontSizes.bodySize, margin: 0, - marginBottom: theme.spacing(3), + marginBottom: theme.spacing(1), +})); - // Make the help icon align with the text. - '& > :last-child': { - position: 'relative', - top: 1, +const StyledVerticalTabs = styled(VerticalTabs)(({ theme }) => ({ + '&&& .selected': { + backgroundColor: theme.palette.neutral.light, }, })); interface IFeatureOverviewSidePanelProps { - hiddenEnvironments: Set; - setHiddenEnvironments: (environment: string) => void; + environmentId: string; + setEnvironmentId: React.Dispatch>; } export const FeatureOverviewSidePanel = ({ - hiddenEnvironments, - setHiddenEnvironments, + environmentId, + setEnvironmentId, }: IFeatureOverviewSidePanelProps) => { const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const { feature } = useFeature(projectId, featureId); const isSticky = feature.environments?.length <= 3; + const tabs: ITab[] = feature.environments.map( + ({ name, enabled, strategies }) => ({ + id: name, + label: name, + description: + strategies.length === 1 + ? '1 strategy' + : `${strategies.length || 'No'} strategies`, + startIcon: , + }), + ); + + useEffect(() => { + if (!environmentId) { + setEnvironmentId(tabs[0]?.id); + } + }, [tabs]); + return ( - - Enabled in environments ( - { - feature.environments.filter( - ({ enabled }) => enabled, - ).length - } - ) - - - } - feature={feature} - hiddenEnvironments={hiddenEnvironments} - setHiddenEnvironments={setHiddenEnvironments} - /> - - - Tags for this feature flag - - } - feature={feature} + + Environments ({feature.environments.length}) + + setEnvironmentId(id)} /> ); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx new file mode 100644 index 000000000000..f26d7811a65c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx @@ -0,0 +1,89 @@ +import { Box, Divider, styled } from '@mui/material'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches'; +import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags'; +import { Sticky } from 'component/common/Sticky/Sticky'; + +const StyledContainer = styled(Box)(({ theme }) => ({ + top: theme.spacing(2), + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + maxWidth: '350px', + minWidth: '350px', + marginRight: '1rem', + marginTop: '1rem', + [theme.breakpoints.down(1000)]: { + marginBottom: '1rem', + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, +})); + +const StyledHeader = styled('h3')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + fontSize: theme.fontSizes.bodySize, + margin: 0, + marginBottom: theme.spacing(3), + + // Make the help icon align with the text. + '& > :last-child': { + position: 'relative', + top: 1, + }, +})); + +interface IFeatureOverviewSidePanelProps { + hiddenEnvironments: Set; + setHiddenEnvironments: (environment: string) => void; +} + +export const OldFeatureOverviewSidePanel = ({ + hiddenEnvironments, + setHiddenEnvironments, +}: IFeatureOverviewSidePanelProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature } = useFeature(projectId, featureId); + const isSticky = feature.environments?.length <= 3; + + return ( + + + Enabled in environments ( + { + feature.environments.filter( + ({ enabled }) => enabled, + ).length + } + ) + + + } + feature={feature} + hiddenEnvironments={hiddenEnvironments} + setHiddenEnvironments={setHiddenEnvironments} + /> + + + Tags for this feature flag + + } + feature={feature} + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx index ed516a54152e..df0a742e5105 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx @@ -88,7 +88,7 @@ export const ManageTagsDialog = ({ open, setOpen }: IManageTagsProps) => { tagsToOptions(tags.filter((tag) => tag.type === tagType.name)), ); } - }, [JSON.stringify(tags), tagType]); + }, [JSON.stringify(tags), tagType, open]); const onCancel = () => { setOpen(false); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx new file mode 100644 index 000000000000..5cea19c55552 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx @@ -0,0 +1,311 @@ +import { + type DragEventHandler, + type RefObject, + useEffect, + useState, +} from 'react'; +import { Alert, Pagination, styled } from '@mui/material'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useToast from 'hooks/useToast'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { StrategyDraggableItem } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem'; +import type { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import usePagination from 'hooks/usePagination'; +import type { IFeatureStrategy } from 'interfaces/strategy'; +import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { useUiFlag } from 'hooks/useUiFlag'; +import isEqual from 'lodash/isEqual'; + +interface IEnvironmentAccordionBodyProps { + isDisabled: boolean; + featureEnvironment?: IFeatureEnvironment; + otherEnvironments?: IFeatureEnvironment['name'][]; +} + +const StyledAccordionBody = styled('div')(({ theme }) => ({ + width: '100%', + position: 'relative', + paddingBottom: theme.spacing(2), +})); + +const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({ + [theme.breakpoints.down(400)]: { + padding: theme.spacing(1), + }, +})); + +export const FeatureOverviewEnvironmentBody = ({ + featureEnvironment, + isDisabled, + otherEnvironments, +}: IEnvironmentAccordionBodyProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { setStrategiesSortOrder } = useFeatureStrategyApi(); + const { addChange } = useChangeRequestApi(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(projectId); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(projectId, featureId); + const manyStrategiesPagination = useUiFlag('manyStrategiesPagination'); + const [strategies, setStrategies] = useState( + featureEnvironment?.strategies || [], + ); + const { trackEvent } = usePlausibleTracker(); + + const [dragItem, setDragItem] = useState<{ + id: string; + index: number; + height: number; + } | null>(null); + + const [isReordering, setIsReordering] = useState(false); + + useEffect(() => { + if (isReordering) { + if (isEqual(featureEnvironment?.strategies, strategies)) { + setIsReordering(false); + } + } else { + setStrategies(featureEnvironment?.strategies || []); + } + }, [featureEnvironment?.strategies]); + + useEffect(() => { + if (strategies.length > 50) { + trackEvent('many-strategies'); + } + }, []); + + if (!featureEnvironment) { + return null; + } + + const pageSize = 20; + const { page, pages, setPageIndex, pageIndex } = + usePagination(strategies, pageSize); + + const onReorder = async (payload: { id: string; sortOrder: number }[]) => { + try { + await setStrategiesSortOrder( + projectId, + featureId, + featureEnvironment.name, + payload, + ); + refetchFeature(); + setToastData({ + title: 'Order of strategies updated', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onChangeRequestReorder = async ( + payload: { id: string; sortOrder: number }[], + ) => { + await addChange(projectId, featureEnvironment.name, { + action: 'reorderStrategy', + feature: featureId, + payload, + }); + + setToastData({ + title: 'Strategy execution order added to draft', + type: 'success', + confetti: true, + }); + refetchChangeRequests(); + }; + + const onStrategyReorder = async ( + payload: { id: string; sortOrder: number }[], + ) => { + try { + if (isChangeRequestConfigured(featureEnvironment.name)) { + await onChangeRequestReorder(payload); + } else { + await onReorder(payload); + } + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onDragStartRef = + ( + ref: RefObject, + index: number, + ): DragEventHandler => + (event) => { + setIsReordering(true); + setDragItem({ + id: strategies[index].id, + index, + height: ref.current?.offsetHeight || 0, + }); + + if (ref?.current) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/html', ref.current.outerHTML); + event.dataTransfer.setDragImage(ref.current, 20, 20); + } + }; + + const onDragOver = + (targetId: string) => + ( + ref: RefObject, + targetIndex: number, + ): DragEventHandler => + (event) => { + if (dragItem === null || ref.current === null) return; + if (dragItem.index === targetIndex || targetId === dragItem.id) + return; + + const { top, bottom } = ref.current.getBoundingClientRect(); + const overTargetTop = event.clientY - top < dragItem.height; + const overTargetBottom = bottom - event.clientY < dragItem.height; + const draggingUp = dragItem.index > targetIndex; + + // prevent oscillating by only reordering if there is sufficient space + if ( + (overTargetTop && draggingUp) || + (overTargetBottom && !draggingUp) + ) { + const newStrategies = [...strategies]; + const movedStrategy = newStrategies.splice( + dragItem.index, + 1, + )[0]; + newStrategies.splice(targetIndex, 0, movedStrategy); + setStrategies(newStrategies); + setDragItem({ + ...dragItem, + index: targetIndex, + }); + } + }; + + const onDragEnd = () => { + setDragItem(null); + onStrategyReorder( + strategies.map((strategy, sortOrder) => ({ + id: strategy.id, + sortOrder, + })), + ); + }; + + const strategiesToDisplay = isReordering + ? strategies + : featureEnvironment.strategies; + + return ( + + + 0 && isDisabled} + show={() => ( + + This environment is disabled, which means that none + of your strategies are executing. + + )} + /> + 0} + show={ + + {strategiesToDisplay.map( + (strategy, index) => ( + + ), + )} + + } + elseShow={ + <> + + We noticed you're using a high number of + activation strategies. To ensure a more + targeted approach, consider leveraging + constraints or segments. + +
+ {page.map((strategy, index) => ( + {}) as any} + onDragOver={(() => {}) as any} + onDragEnd={(() => {}) as any} + /> + ))} +
+ + setPageIndex(page - 1) + } + /> + + } + /> + } + elseShow={ + + } + /> +
+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx new file mode 100644 index 000000000000..aad7cf576976 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx @@ -0,0 +1,51 @@ +import { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch'; +import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import type { IFeatureEnvironment } from 'interfaces/featureToggle'; + +interface IFeatureOverviewEnvironmentToggleProps { + environment: IFeatureEnvironment; +} + +export const FeatureOverviewEnvironmentToggle = ({ + environment: { name, type, strategies, enabled }, +}: IFeatureOverviewEnvironmentToggleProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { refetchFeature } = useFeature(projectId, featureId); + + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + + const { onToggle: onFeatureToggle, modals: featureToggleModals } = + useFeatureToggleSwitch(projectId); + + const onToggle = (newState: boolean, onRollback: () => void) => + onFeatureToggle(newState, { + projectId, + featureId, + environmentName: name, + environmentType: type, + hasStrategies: strategies.length > 0, + hasEnabledStrategies: strategies.some( + (strategy) => !strategy.disabled, + ), + isChangeRequestEnabled: isChangeRequestConfigured(name), + onRollback, + onSuccess: refetchFeature, + }); + + return ( + <> + + {featureToggleModals} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx new file mode 100644 index 000000000000..4c088c9e6a17 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx @@ -0,0 +1,131 @@ +import { Box, styled } from '@mui/material'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics'; +import { getFeatureMetrics } from 'utils/getFeatureMetrics'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { FeatureOverviewEnvironmentBody } from './FeatureOverviewEnvironmentBody'; +import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics'; +import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureOverviewEnvironmentToggle } from './FeatureOverviewEnvironmentToggle'; + +const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({ + padding: theme.spacing(1, 3), + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, +})); + +const StyledFeatureOverviewEnvironmentBody = styled( + FeatureOverviewEnvironmentBody, +)(({ theme }) => ({ + width: '100%', + position: 'relative', + paddingBottom: theme.spacing(2), +})); + +const StyledHeader = styled('div')(({ theme }) => ({ + display: 'flex', + marginBottom: theme.spacing(2), +})); + +const StyledHeaderToggleContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})); + +const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', +})); + +const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + lineHeight: 0.5, + color: theme.palette.text.secondary, +})); + +const StyledHeaderTitle = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + fontWeight: theme.typography.fontWeightBold, +})); + +interface INewFeatureOverviewEnvironmentProps { + environmentId: string; +} + +export const NewFeatureOverviewEnvironment = ({ + environmentId, +}: INewFeatureOverviewEnvironmentProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { metrics } = useFeatureMetrics(projectId, featureId); + const { feature } = useFeature(projectId, featureId); + + const featureMetrics = getFeatureMetrics(feature?.environments, metrics); + const environmentMetric = featureMetrics.find( + ({ environment }) => environment === environmentId, + ); + const featureEnvironment = feature?.environments.find( + ({ name }) => name === environmentId, + ); + + if (!featureEnvironment) + return ( + + + + ); + + return ( + + + + + + + Environment + + {environmentId} + + + + + + name) + .filter((name) => name !== environmentId)} + /> + 0} + show={ + <> + + + + + } + /> + + ); +}; diff --git a/frontend/src/component/filter/AddFilterButton.tsx b/frontend/src/component/filter/AddFilterButton.tsx index 32c1a660379c..4915a92990a0 100644 --- a/frontend/src/component/filter/AddFilterButton.tsx +++ b/frontend/src/component/filter/AddFilterButton.tsx @@ -12,6 +12,7 @@ import { useUiFlag } from 'hooks/useUiFlag'; import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi'; import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; const StyledButton = styled(Button)(({ theme }) => ({ padding: theme.spacing(0, 1.25, 0, 1.25), @@ -46,6 +47,7 @@ export const AddFilterButton = ({ setHiddenOptions, availableFilters, }: IAddFilterButtonProps) => { + const projectId = useOptionalPathParam('projectId'); const simplifyProjectOverview = useUiFlag('simplifyProjectOverview'); const { setSplashSeen } = useSplashApi(); const { splash } = useAuthSplash(); @@ -95,7 +97,7 @@ export const AddFilterButton = ({ }; return (
- {simplifyProjectOverview ? ( + {simplifyProjectOverview && projectId ? ( = { '/admin/cors': CorsIcon, '/admin/billing': BillingIcon, '/history': EventLogIcon, - '/releases-management': LaunchIcon, + '/release-management': LaunchIcon, '/personal': PersonalDashboardIcon, GitHub: GitHubIcon, Documentation: LibraryBooksIcon, diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 3652622dc286..fcd678e3a5e8 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -240,7 +240,7 @@ exports[`returns all baseRoutes 1`] = ` "enterprise", ], }, - "path": "/releases-management", + "path": "/release-management", "title": "Release management", "type": "protected", }, @@ -270,14 +270,6 @@ exports[`returns all baseRoutes 1`] = ` "title": "Environments", "type": "protected", }, - { - "component": [Function], - "flag": "featureSearchFeedbackPosting", - "menu": {}, - "path": "/feedback", - "title": "Feedback", - "type": "protected", - }, { "component": [Function], "menu": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 07090d77c119..6942b5829c81 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -43,12 +43,11 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList'; import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect'; import { Insights } from '../insights/Insights'; -import { FeedbackList } from '../feedbackNew/FeedbackList'; import { Application } from 'component/application/Application'; import { Signals } from 'component/signals/Signals'; import { LazyCreateProject } from '../project/Project/CreateProject/LazyCreateProject'; import { PersonalDashboard } from '../personalDashboard/PersonalDashboard'; -import { ReleaseManagement } from 'component/releases/ReleaseManagement'; +import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement'; export const routes: IRoute[] = [ // Splash @@ -248,7 +247,7 @@ export const routes: IRoute[] = [ menu: { mobile: true, advanced: true }, }, { - path: '/releases-management', + path: '/release-management', title: 'Release management', component: ReleaseManagement, type: 'protected', @@ -279,14 +278,6 @@ export const routes: IRoute[] = [ menu: { mobile: true, advanced: true }, enterprise: true, }, - { - path: '/feedback', - title: 'Feedback', - component: FeedbackList, - type: 'protected', - flag: 'featureSearchFeedbackPosting', - menu: {}, - }, // Tags { diff --git a/frontend/src/component/personalDashboard/MyProjects.tsx b/frontend/src/component/personalDashboard/MyProjects.tsx index 402e12e1ac0d..7f71f5b11519 100644 --- a/frontend/src/component/personalDashboard/MyProjects.tsx +++ b/frontend/src/component/personalDashboard/MyProjects.tsx @@ -194,9 +194,8 @@ export const MyProjects: React.FC<{ const activeProjectStage = personalDashboardProjectDetails.data.onboardingStatus .status ?? 'loading'; - const setupIncomplete = - activeProjectStage === 'onboarding-started' || - activeProjectStage === 'first-flag-created'; + const onboardingStarted = + activeProjectStage === 'onboarding-started'; if (activeProjectStage === 'onboarded') { return [ @@ -214,7 +213,7 @@ export const MyProjects: React.FC<{ } />, ]; - } else if (setupIncomplete) { + } else if (onboardingStarted) { return [ , , diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/FeatureToggleCell/FeatureToggleCell.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/FeatureToggleCell/FeatureToggleCell.tsx index 5c7da94e41d7..983ed00d8587 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/FeatureToggleCell/FeatureToggleCell.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/FeatureToggleCell/FeatureToggleCell.tsx @@ -22,6 +22,12 @@ const StyledSwitchContainer = styled('div', { }), })); +const StyledDiv = styled('div')(({ theme }) => ({ + flexGrow: 0, + ...flexRow, + justifyContent: 'center', +})); + interface IFeatureToggleCellProps { projectId: string; environmentName: string; @@ -90,3 +96,6 @@ export const PlaceholderFeatureToggleCell = () => (
toggle
); +export const ArchivedFeatureToggleCell = () => ( + +); diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx index c74f7b02a031..03a2e4ccee8c 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.test.tsx @@ -40,6 +40,33 @@ const setupApi = () => { ]); }; +test('filters by flag type', async () => { + setupApi(); + + render( + + + } + /> + , + { + route: '/projects/default', + }, + ); + await screen.findByText('featureA'); + const [icon] = await screen.findAllByTestId('feature-type-icon'); + + fireEvent.click(icon); + + await screen.findByText('Flag type'); + await screen.findByText('Operational'); +}); + test('selects project features', async () => { setupApi(); render( @@ -107,32 +134,6 @@ test('filters by tag', async () => { expect(await screen.findAllByText('backend:sdk')).toHaveLength(2); }); -test('filters by flag type', async () => { - setupApi(); - render( - - - } - /> - , - { - route: '/projects/default', - }, - ); - await screen.findByText('featureA'); - const [icon] = await screen.findAllByTestId('feature-type-icon'); - - fireEvent.click(icon); - - await screen.findByText('Flag type'); - await screen.findByText('Operational'); -}); - test('filters by flag author', async () => { setupApi(); render( diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index 50bee4226d99..14b74c6156de 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -26,6 +26,7 @@ import { createColumnHelper, useReactTable } from '@tanstack/react-table'; import { withTableState } from 'utils/withTableState'; import type { FeatureSearchResponseSchema } from 'openapi'; import { + ArchivedFeatureToggleCell, FeatureToggleCell, PlaceholderFeatureToggleCell, } from './FeatureToggleCell/FeatureToggleCell'; @@ -292,6 +293,7 @@ export const ProjectFeatureToggles = ({ return columnHelper.accessor( (row) => ({ + archived: row.archivedAt !== null, featureId: row.name, environment: row.environments?.find( (featureEnvironment) => @@ -317,10 +319,13 @@ export const ProjectFeatureToggles = ({ featureId, environment, someEnabledEnvironmentHasVariants, + archived, } = getValue(); return isPlaceholder ? ( + ) : archived ? ( + ) : ( ReactNode; + labelOverride?: () => ReactNode; } const StyledCounterBadge = styled(CounterBadge)(({ theme }) => ({ @@ -94,9 +94,14 @@ const TabText = styled('span')(({ theme }) => ({ })); const ChangeRequestsLabel = () => { + const simplifyProjectOverview = useUiFlag('simplifyProjectOverview'); const projectId = useRequiredPathParam('projectId'); const { total } = useActionableChangeRequests(projectId); + if (!simplifyProjectOverview) { + return 'Change requests'; + } + return ( Change requests @@ -167,7 +172,7 @@ export const Project = () => { path: `${basePath}/change-requests`, name: 'change-request', isEnterprise: true, - label: simplifyProjectOverview ? ChangeRequestsLabel : undefined, + labelOverride: ChangeRequestsLabel, }, { title: 'Applications', @@ -319,7 +324,13 @@ export const Project = () => { + ) : ( + tab.title + ) + } value={tab.path} onClick={() => { if (tab.title !== 'Flags') { diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx index f4e3cdb2648d..d1357877f24d 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx @@ -1,10 +1,6 @@ -import { PageContent } from 'component/common/PageContent/PageContent'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; import { SegmentTable } from 'component/segments/SegmentTable/SegmentTable'; -import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; import { Route, Routes, useNavigate } from 'react-router-dom'; import { CreateSegment } from 'component/segments/CreateSegment/CreateSegment'; import { EditSegment } from 'component/segments/EditSegment/EditSegment'; @@ -15,22 +11,10 @@ import { useProjectOverviewNameOrId } from 'hooks/api/getters/useProjectOverview export const ProjectSegments = () => { const projectId = useRequiredPathParam('projectId'); const projectName = useProjectOverviewNameOrId(projectId); - const { isOss } = useUiConfig(); const navigate = useNavigate(); usePageTitle(`Project segments – ${projectName}`); - if (isOss()) { - return ( - } - sx={{ justifyContent: 'center' }} - > - - - ); - } - return ( { id: 'api-access', label: 'API access', }, - ...paidTabs({ + { id: 'segments', label: 'Segments', - }), + }, { id: 'environments', label: 'Environments', @@ -68,7 +68,7 @@ export const ProjectSettings = () => { ...paidTabs({ id: 'change-requests', label: 'Change request configuration', - icon: isPro() ? ( + endIcon: isPro() ? ( @@ -80,7 +80,7 @@ export const ProjectSettings = () => { tabs.push({ id: 'actions', label: 'Actions', - icon: isPro() ? ( + endIcon: isPro() ? ( diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx index 5bbb6d5f476c..3e74cfa65da1 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx @@ -5,22 +5,49 @@ import type { ProjectActivitySchema } from '../../../../openapi'; import { styled, Tooltip } from '@mui/material'; const StyledContainer = styled('div')(({ theme }) => ({ - gap: theme.spacing(1), + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: theme.spacing(2), })); +const TitleContainer = styled('h4')({ + margin: 0, + width: '100%', +}); + type Output = { date: string; count: number; level: number }; -export function transformData(inputData: ProjectActivitySchema): Output[] { - const resultMap: Record = {}; +const ensureFullYearData = (data: Output[]): Output[] => { + const today = new Date(); + const oneYearBack = new Date(); + oneYearBack.setFullYear(today.getFullYear() - 1); + + const formattedToday = today.toISOString().split('T')[0]; + const formattedOneYearBack = oneYearBack.toISOString().split('T')[0]; + + const hasToday = data.some((item) => item.date === formattedToday); + const hasOneYearBack = data.some( + (item) => item.date === formattedOneYearBack, + ); + + if (!hasOneYearBack) { + data.unshift({ count: 0, date: formattedOneYearBack, level: 0 }); + } - // Step 1: Count the occurrences of each date - inputData.forEach((item) => { - const formattedDate = new Date(item.date).toISOString().split('T')[0]; - resultMap[formattedDate] = (resultMap[formattedDate] || 0) + 1; - }); + if (!hasToday) { + data.push({ count: 0, date: formattedToday, level: 0 }); + } + + return data; +}; + +const transformData = (inputData: ProjectActivitySchema): Output[] => { + const countArray = inputData.map((item) => item.count); // Step 2: Get all counts, sort them, and find the cut-off values for percentiles - const counts = Object.values(resultMap).sort((a, b) => a - b); + const counts = Object.values(countArray).sort((a, b) => a - b); const percentile = (percent: number) => { const index = Math.floor((percent / 100) * counts.length); @@ -43,14 +70,12 @@ export function transformData(inputData: ProjectActivitySchema): Output[] { }; // Step 4: Convert the map back to an array and assign levels - return Object.entries(resultMap) - .map(([date, count]) => ({ - date, - count, - level: calculateLevel(count), - })) - .reverse(); // Optional: reverse the order if needed -} + return inputData.map(({ date, count }) => ({ + date, + count, + level: calculateLevel(count), + })); +}; export const ProjectActivity = () => { const projectId = useRequiredPathParam('projectId'); @@ -62,15 +87,16 @@ export const ProjectActivity = () => { }; const levelledData = transformData(data.activityCountByDate); + const fullData = ensureFullYearData(levelledData); return ( - + <> {data.activityCountByDate.length > 0 ? ( - <> - Activity in project + + Activity in project ( @@ -81,10 +107,10 @@ export const ProjectActivity = () => { )} /> - + ) : ( No activity )} - + ); }; diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx new file mode 100644 index 000000000000..966b240943b7 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx @@ -0,0 +1,93 @@ +import { useTheme, Typography } from '@mui/material'; +import { styled } from '@mui/system'; +import { Link } from 'react-router-dom'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; + +const HealthContainer = styled('div')(({ theme }) => ({ + backgroundColor: theme.palette.envAccordion.expanded, + padding: theme.spacing(3), + borderRadius: theme.shape.borderRadiusExtraLarge, + minWidth: '300px', + fontSize: theme.spacing(1.75), +})); + +const ChartRow = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', +})); + +const StyledSVG = styled('svg')({ + width: 200, + height: 100, +}); + +const DescriptionText = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +export const ProjectHealth = () => { + const projectId = useRequiredPathParam('projectId'); + const { + data: { averageHealth }, + } = useProjectStatus(projectId); + const { isOss } = useUiConfig(); + const theme = useTheme(); + const radius = 40; + const strokeWidth = 13; + const circumference = 2 * Math.PI * radius; + + const gapLength = 0.3; + const filledLength = 1 - gapLength; + const offset = 0.75 - gapLength / 2; + const healthLength = (averageHealth / 100) * circumference * 0.7; + + return ( + + + + + + + {averageHealth}% + + + + On average, your project health has remained at{' '} + {averageHealth}% the last 4 weeks + + + + Remember to archive your stale feature flags to keep the project + health growing + + {!isOss() && View health over time} + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx new file mode 100644 index 000000000000..972269351186 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectLifecycleSummary.tsx @@ -0,0 +1,177 @@ +import { styled } from '@mui/material'; +import { FeatureLifecycleStageIcon } from 'component/feature/FeatureView/FeatureOverview/FeatureLifecycle/FeatureLifecycleStageIcon'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { Link } from 'react-router-dom'; + +const LifecycleBox = styled('li')(({ theme }) => ({ + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadiusExtraLarge, + border: `2px solid ${theme.palette.divider}`, + width: '180px', + height: '175px', + display: 'flex', + flexFlow: 'column', + justifyContent: 'space-between', +})); + +const Wrapper = styled('ul')(({ theme }) => ({ + display: 'grid', + listStyle: 'none', + gridTemplateColumns: 'repeat(auto-fit, 180px)', + gap: theme.spacing(1), + justifyContent: 'center', +})); + +const Counter = styled('span')({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +}); + +const BigNumber = styled('span')(({ theme }) => ({ + fontSize: `calc(2 * ${theme.typography.body1.fontSize})`, +})); + +const Stats = styled('dl')(({ theme }) => ({ + margin: 0, + fontSize: theme.typography.body2.fontSize, + '& dd': { + margin: 0, + fontWeight: 'bold', + }, +})); + +const NegativeStat = styled('span')(({ theme }) => ({ + color: theme.palette.warning.contrastText, +})); + +const NoData = styled('span')({ + fontWeight: 'normal', +}); + +const LinkNoUnderline = styled(Link)({ + textDecoration: 'none', +}); + +export const ProjectLifecycleSummary = () => { + const projectId = useRequiredPathParam('projectId'); + return ( + + +

+ + 15 + + + flags in initial +

+ +
Avg. time in stage
+
+ 21 days +
+
+
+ +

+ + 3 + + + flags in pre-live +

+ +
Avg. time in stage
+
18 days
+
+
+ +

+ + 2 + + + flags in live +

+ +
Avg. time in stage
+
10 days
+
+
+ +

+ + 6 + + + + + flags + {' '} + in cleanup + +

+ +
Avg. time in stage
+
+ No data +
+
+
+ +

+ + 15 + + + flags in archived +

+ +
This month
+
3 flags archived
+
+
+
+ ); +}; diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectResources.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectResources.tsx index 751728d191f9..78df2231fe58 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectResources.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectResources.tsx @@ -1,24 +1,19 @@ import { Typography, styled } from '@mui/material'; -import { useProjectApiTokens } from 'hooks/api/getters/useProjectApiTokens/useProjectApiTokens'; -import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; -import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { - type ReactNode, - useMemo, - type FC, - type PropsWithChildren, -} from 'react'; +import type { ReactNode, FC, PropsWithChildren } from 'react'; import UsersIcon from '@mui/icons-material/Group'; import { Link } from 'react-router-dom'; import ApiKeyIcon from '@mui/icons-material/Key'; import SegmentsIcon from '@mui/icons-material/DonutLarge'; import ConnectedIcon from '@mui/icons-material/Cable'; +import { useProjectStatus } from 'hooks/api/getters/useProjectStatus/useProjectStatus'; +import useLoading from 'hooks/useLoading'; const Wrapper = styled('article')(({ theme }) => ({ backgroundColor: theme.palette.envAccordion.expanded, padding: theme.spacing(3), borderRadius: theme.shape.borderRadiusExtraLarge, + minWidth: '300px', })); const ProjectResourcesInner = styled('div')(({ theme }) => ({ @@ -88,28 +83,38 @@ const ListItem: FC< {icon} - {children} + {children} {linkText} ); +const useProjectResources = (projectId: string) => { + const { data, loading } = useProjectStatus(projectId); + + const { resources } = data ?? { + resources: { + members: 0, + apiTokens: 0, + connectedEnvironments: 0, + segments: 0, + }, + }; + + return { + resources, + loading, + }; +}; + export const ProjectResources = () => { const projectId = useRequiredPathParam('projectId'); - const { project, loading: loadingProject } = useProjectOverview(projectId); - const { tokens, loading: loadingTokens } = useProjectApiTokens(projectId); - const { segments, loading: loadingSegments } = useSegments(); - // todo: add sdk connections - - const segmentCount = useMemo( - () => - segments?.filter((segment) => segment.project === projectId) - .length ?? 0, - [segments, projectId], - ); + const { resources, loading } = useProjectResources(projectId); + + const loadingRef = useLoading(loading, '[data-loading-resources=true]'); return ( - + Project Resources @@ -120,7 +125,7 @@ export const ProjectResources = () => { linkText='Add members' icon={} > - {project.members} project member(s) + {resources.members} project member(s) { linkText='Add new key' icon={} > - {tokens.length} API key(s) + {resources.apiTokens} API key(s) { linkText='View connections' icon={} > - 1 connected environment(s) + {resources.connectedEnvironments} connected + environment(s) { linkText='Add segments' icon={} > - {segmentCount} project segment(s) + {resources.segments} project segment(s) diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx index 5732824907ae..8357c70350b2 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx @@ -2,10 +2,16 @@ import { styled } from '@mui/material'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { ProjectResources } from './ProjectResources'; import { ProjectActivity } from './ProjectActivity'; +import { ProjectHealth } from './ProjectHealth'; +import { ProjectLifecycleSummary } from './ProjectLifecycleSummary'; const ModalContentContainer = styled('div')(({ theme }) => ({ minHeight: '100vh', backgroundColor: theme.palette.background.default, + padding: theme.spacing(4), + display: 'flex', + flexFlow: 'column', + gap: theme.spacing(4), })); type Props = { @@ -13,12 +19,31 @@ type Props = { close: () => void; }; +const HealthRow = styled('div')(({ theme }) => ({ + display: 'flex', + flexFlow: 'row wrap', + padding: theme.spacing(2), + gap: theme.spacing(2), + '&>*': { + // todo: reconsider this value when the health widget is + // implemented. It may not be right, but it works for the + // placeholder + flex: '30%', + }, +})); + export const ProjectStatusModal = ({ open, close }: Props) => { return ( - + + + + + + + ); diff --git a/frontend/src/component/providers/AccessProvider/permissions.ts b/frontend/src/component/providers/AccessProvider/permissions.ts index 08a081e98a7f..48eea0687135 100644 --- a/frontend/src/component/providers/AccessProvider/permissions.ts +++ b/frontend/src/component/providers/AccessProvider/permissions.ts @@ -48,3 +48,5 @@ export const PROJECT_USER_ACCESS_WRITE = 'PROJECT_USER_ACCESS_WRITE'; export const PROJECT_DEFAULT_STRATEGY_WRITE = 'PROJECT_DEFAULT_STRATEGY_WRITE'; export const PROJECT_CHANGE_REQUEST_WRITE = 'PROJECT_CHANGE_REQUEST_WRITE'; export const PROJECT_SETTINGS_WRITE = 'PROJECT_SETTINGS_WRITE'; + +export const CREATE_RELEASE_TEMPLATE = 'CREATE_RELEASE_TEMPLATE'; diff --git a/frontend/src/component/releases/ReleaseManagement.tsx b/frontend/src/component/releases/ReleaseManagement.tsx deleted file mode 100644 index 929eedca3711..000000000000 --- a/frontend/src/component/releases/ReleaseManagement.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const ReleaseManagement = () => { - return null; -}; diff --git a/frontend/src/component/releases/ReleaseManagement/EmptyTemplatesListMessage.tsx b/frontend/src/component/releases/ReleaseManagement/EmptyTemplatesListMessage.tsx new file mode 100644 index 000000000000..026d6175857c --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/EmptyTemplatesListMessage.tsx @@ -0,0 +1,38 @@ +import { styled } from '@mui/material'; +import { ReactComponent as ReleaseTemplateIcon } from 'assets/img/releaseTemplates.svg'; + +const NoReleaseTemplatesMessage = styled('div')(({ theme }) => ({ + textAlign: 'center', + padding: theme.spacing(1, 0, 0, 0), +})); + +const TemplatesEasierMessage = styled('div')(({ theme }) => ({ + textAlign: 'center', + padding: theme.spacing(1, 0, 9, 0), + color: theme.palette.text.secondary, +})); + +const StyledCenter = styled('div')(({ theme }) => ({ + textAlign: 'center', +})); + +const StyledDiv = styled('div')(({ theme }) => ({ + paddingTop: theme.spacing(5), +})); +export const EmptyTemplatesListMessage = () => { + return ( + + + + + + You have no release templates set up + + + Make the set up of strategies easier for your +
+ teams by creating templates +
+
+ ); +}; diff --git a/frontend/src/component/releases/ReleaseManagement/ReleaseManagement.tsx b/frontend/src/component/releases/ReleaseManagement/ReleaseManagement.tsx new file mode 100644 index 000000000000..5962d7eaabb7 --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/ReleaseManagement.tsx @@ -0,0 +1,56 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { Grid } from '@mui/material'; +import { styles as themeStyles } from 'component/common'; +import { usePageTitle } from 'hooks/usePageTitle'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import Add from '@mui/icons-material/Add'; +import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; +import { CREATE_RELEASE_TEMPLATE } from 'component/providers/AccessProvider/permissions'; +import { useNavigate } from 'react-router-dom'; +import { useReleasePlanTemplates } from 'hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplates'; +import { EmptyTemplatesListMessage } from './EmptyTemplatesListMessage'; +import { ReleasePlanTemplateList } from './ReleasePlanTemplateList'; + +export const ReleaseManagement = () => { + usePageTitle('Release management'); + const navigate = useNavigate(); + const data = useReleasePlanTemplates(); + + return ( + <> + { + navigate( + '/release-management/create-template', + ); + }} + maxWidth='700px' + permission={CREATE_RELEASE_TEMPLATE} + disabled={false} + > + New template + + } + /> + } + > + {data.templates.length > 0 && ( + + + + )} + {data.templates.length === 0 && ( +
+ +
+ )} +
+ + ); +}; diff --git a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx new file mode 100644 index 000000000000..7b155b5e17b7 --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx @@ -0,0 +1,81 @@ +import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; +import { ReactComponent as ReleaseTemplateIcon } from 'assets/img/releaseTemplates.svg'; +import { styled, Typography } from '@mui/material'; +import { ReleasePlanTemplateCardMenu } from './ReleasePlanTemplateCardMenu'; + +const StyledTemplateCard = styled('aside')(({ theme }) => ({ + height: '100%', + '&:hover': { + transition: 'background-color 0.2s ease-in-out', + backgroundColor: theme.palette.neutral.light, + }, + overflow: 'hidden', +})); + +const TemplateCardHeader = styled('div')(({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + padding: theme.spacing(2.5), + borderTopLeftRadius: theme.shape.borderRadiusLarge, + borderTopRightRadius: theme.shape.borderRadiusLarge, +})); + +const TemplateCardBody = styled('div')(({ theme }) => ({ + padding: theme.spacing(1.25), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusLarge, + borderTop: 'none', + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + display: 'flex', + flexDirection: 'column', +})); + +const StyledCenter = styled('div')(({ theme }) => ({ + textAlign: 'center', +})); + +const StyledDiv = styled('div')(({ theme }) => ({ + display: 'flex', +})); + +const StyledCreatedBy = styled(Typography)(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + display: 'flex', + alignItems: 'center', + marginRight: 'auto', +})); + +const StyledMenu = styled('div')(({ theme }) => ({ + marginLeft: theme.spacing(1), + marginTop: theme.spacing(-1), + marginBottom: theme.spacing(-1), + marginRight: theme.spacing(-1), + display: 'flex', + alignItems: 'center', +})); + +export const ReleasePlanTemplateCard = ({ + template, +}: { template: IReleasePlanTemplate }) => { + return ( + + + + + + + +
{template.name}
+ + + Created by {template.createdByUserId} + + e.preventDefault()}> + + + +
+
+ ); +}; diff --git a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx new file mode 100644 index 000000000000..08c279dcba50 --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx @@ -0,0 +1,106 @@ +import { useCallback, useState } from 'react'; +import { + IconButton, + Tooltip, + Menu, + MenuItem, + ListItemText, +} from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; +import { useReleasePlanTemplatesApi } from 'hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi'; +import { useReleasePlanTemplates } from 'hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplates'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { TemplateDeleteDialog } from './TemplateDeleteDialog'; + +export const ReleasePlanTemplateCardMenu = ({ + template, +}: { template: IReleasePlanTemplate }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const { deleteReleasePlanTemplate } = useReleasePlanTemplatesApi(); + const { refetch } = useReleasePlanTemplates(); + const { setToastData, setToastApiError } = useToast(); + const [deleteOpen, setDeleteOpen] = useState(false); + const deleteReleasePlan = useCallback(async () => { + try { + await deleteReleasePlanTemplate(template.id); + refetch(); + setToastData({ + type: 'success', + title: 'Success', + text: 'Release plan template deleted', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }, [setToastApiError, refetch, setToastData, deleteReleasePlanTemplate]); + + const closeMenu = () => { + setIsMenuOpen(false); + setAnchorEl(null); + }; + + const handleMenuClick = (event: React.SyntheticEvent) => { + if (isMenuOpen) { + closeMenu(); + } else { + setAnchorEl(event.currentTarget); + setIsMenuOpen(true); + } + }; + + return ( + <> + + + + + + + { + closeMenu(); + }} + > + Edit template + + { + setDeleteOpen(true); + closeMenu(); + }} + > + Delete template + + + + + ); +}; diff --git a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateList.tsx b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateList.tsx new file mode 100644 index 000000000000..9ae7c1093ca5 --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateList.tsx @@ -0,0 +1,21 @@ +import { Grid } from '@mui/material'; +import { ReleasePlanTemplateCard } from './ReleasePlanTemplateCard'; +import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; + +interface ITemplateList { + templates: IReleasePlanTemplate[]; +} + +export const ReleasePlanTemplateList: React.FC = ({ + templates, +}) => { + return ( + <> + {templates.map((template) => ( + + + + ))} + + ); +}; diff --git a/frontend/src/component/releases/ReleaseManagement/TemplateDeleteDialog.tsx b/frontend/src/component/releases/ReleaseManagement/TemplateDeleteDialog.tsx new file mode 100644 index 000000000000..b08a14bf8533 --- /dev/null +++ b/frontend/src/component/releases/ReleaseManagement/TemplateDeleteDialog.tsx @@ -0,0 +1,34 @@ +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; + +interface ITemplateDeleteDialogProps { + template?: IReleasePlanTemplate; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (template: IReleasePlanTemplate) => void; +} + +export const TemplateDeleteDialog: React.FC = ({ + template, + open, + setOpen, + onConfirm, +}) => { + return ( + onConfirm(template!)} + onClose={() => { + setOpen(false); + }} + > +

+ You are about to delete release plan template:{' '} + {template?.name} +

+
+ ); +}; diff --git a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx new file mode 100644 index 000000000000..a06615264e4e --- /dev/null +++ b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx @@ -0,0 +1,105 @@ +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { ProductivityEmailSubscription } from './ProductivityEmailSubscription'; +import ToastRenderer from '../../../common/ToastRenderer/ToastRenderer'; + +const server = testServerSetup(); + +const setupSubscribeApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + {}, + 'put', + 202, + ); +}; + +const setupUnsubscribeApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + {}, + 'delete', + 202, + ); +}; + +const setupErrorApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + { message: 'user error' }, + 'delete', + 400, + ); +}; + +test('unsubscribe', async () => { + setupUnsubscribeApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + expect(checkbox).toBeChecked(); + + checkbox.click(); + + await screen.findByText('Unsubscribed from productivity report'); + expect(changed).toBe(true); +}); + +test('subscribe', async () => { + setupSubscribeApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + expect(checkbox).not.toBeChecked(); + + checkbox.click(); + + await screen.findByText('Subscribed to productivity report'); + expect(changed).toBe(true); +}); + +test('handle error', async () => { + setupErrorApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + + checkbox.click(); + + await screen.findByText('user error'); + expect(changed).toBe(true); +}); diff --git a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx index 7545d5f64949..e047fab2e769 100644 --- a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx +++ b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx @@ -1,14 +1,14 @@ -import { Box, Switch } from '@mui/material'; +import { Box, FormControlLabel, Switch } from '@mui/material'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useState } from 'react'; +import type { FC } from 'react'; import { useEmailSubscriptionApi } from 'hooks/api/actions/useEmailSubscriptionApi/useEmailSubscriptionApi'; import useToast from 'hooks/useToast'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; -export const ProductivityEmailSubscription = () => { - // TODO: read data from user profile when available - const [receiveProductivityReportEmail, setReceiveProductivityReportEmail] = - useState(false); +export const ProductivityEmailSubscription: FC<{ + status: 'subscribed' | 'unsubscribed'; + onChange: () => void; +}> = ({ status, onChange }) => { const { subscribe, unsubscribe, @@ -19,44 +19,46 @@ export const ProductivityEmailSubscription = () => { return ( - Productivity Email Subscription - { - try { - if (receiveProductivityReportEmail) { - await unsubscribe('productivity-report'); - setToastData({ - title: 'Unsubscribed from productivity report', - type: 'success', - }); - trackEvent('productivity-report', { - props: { - eventType: 'subscribe', - }, - }); - } else { - await subscribe('productivity-report'); - setToastData({ - title: 'Subscribed to productivity report', - type: 'success', - }); - trackEvent('productivity-report', { - props: { - eventType: 'unsubscribe', - }, - }); - } - } catch (error) { - setToastApiError(formatUnknownError(error)); - } + { + try { + if (status === 'subscribed') { + await unsubscribe('productivity-report'); + setToastData({ + title: 'Unsubscribed from productivity report', + type: 'success', + }); + trackEvent('productivity-report', { + props: { + eventType: 'subscribe', + }, + }); + } else { + await subscribe('productivity-report'); + setToastData({ + title: 'Subscribed to productivity report', + type: 'success', + }); + trackEvent('productivity-report', { + props: { + eventType: 'unsubscribe', + }, + }); + } + } catch (error) { + setToastApiError(formatUnknownError(error)); + } - setReceiveProductivityReportEmail( - !receiveProductivityReportEmail, - ); - }} - name='productivity-email' - checked={receiveProductivityReportEmail} - disabled={changingSubscriptionStatus} + onChange(); + }} + name='productivity-email' + checked={status === 'subscribed'} + disabled={changingSubscriptionStatus} + /> + } /> ); diff --git a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx index 5c06f175f13f..814b53bebcc7 100644 --- a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx +++ b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx @@ -87,7 +87,7 @@ interface IProfileTabProps { } export const ProfileTab = ({ user }: IProfileTabProps) => { - const { profile } = useProfile(); + const { profile, refetchProfile } = useProfile(); const navigate = useNavigate(); const { locationSettings, setLocationSettings } = useLocationSettings(); const [currentLocale, setCurrentLocale] = useState(); @@ -223,7 +223,18 @@ export const ProfileTab = ({ user }: IProfileTabProps) => { <> Email Settings - + {profile?.subscriptions && ( + + )} ) : null} diff --git a/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts b/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts new file mode 100644 index 000000000000..355870c94a32 --- /dev/null +++ b/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts @@ -0,0 +1,23 @@ +import useAPI from '../useApi/useApi'; + +export const useReleasePlanTemplatesApi = () => { + const { makeRequest, makeLightRequest, createRequest, errors, loading } = + useAPI({ + propagateErrors: true, + }); + + const deleteReleasePlanTemplate = async (id: string) => { + const path = `api/admin/release-plan-templates/${id}`; + const req = createRequest(path, { + method: 'DELETE', + }); + + return makeRequest(req.caller, req.id); + }; + + return { + deleteReleasePlanTemplate, + }; +}; + +export default useReleasePlanTemplatesApi; diff --git a/frontend/src/hooks/api/getters/useProfile/useProfile.ts b/frontend/src/hooks/api/getters/useProfile/useProfile.ts index e236fb4f1bc6..bbf52fc11a6e 100644 --- a/frontend/src/hooks/api/getters/useProfile/useProfile.ts +++ b/frontend/src/hooks/api/getters/useProfile/useProfile.ts @@ -1,10 +1,10 @@ import useSWR from 'swr'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; -import type { IProfile } from 'interfaces/profile'; +import type { ProfileSchema } from '../../../../openapi'; export interface IUseProfileOutput { - profile?: IProfile; + profile?: ProfileSchema; refetchProfile: () => void; loading: boolean; error?: Error; diff --git a/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts index 967914ce1829..d3a5963f18b4 100644 --- a/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts +++ b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts @@ -6,6 +6,13 @@ const path = (projectId: string) => `api/admin/projects/${projectId}/status`; const placeholderData: ProjectStatusSchema = { activityCountByDate: [], + resources: { + connectedEnvironments: 0, + members: 0, + apiTokens: 0, + segments: 0, + }, + averageHealth: 0, }; export const useProjectStatus = (projectId: string) => { diff --git a/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplates.ts b/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplates.ts new file mode 100644 index 000000000000..3e08a42783df --- /dev/null +++ b/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplates.ts @@ -0,0 +1,41 @@ +import { useContext, useMemo } from 'react'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import AccessContext from 'contexts/AccessContext'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import { useUiFlag } from 'hooks/useUiFlag'; +import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; + +const ENDPOINT = 'api/admin/release-plan-templates'; + +const DEFAULT_DATA: IReleasePlanTemplate[] = []; + +export const useReleasePlanTemplates = () => { + const { isAdmin } = useContext(AccessContext); + const { isEnterprise } = useUiConfig(); + const signalsEnabled = useUiFlag('releasePlans'); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise() && isAdmin && signalsEnabled, + DEFAULT_DATA, + formatApiPath(ENDPOINT), + fetcher, + ); + + return useMemo( + () => ({ + templates: data ?? [], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Release plan templates')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/profile.ts b/frontend/src/interfaces/profile.ts deleted file mode 100644 index 1997ee767523..000000000000 --- a/frontend/src/interfaces/profile.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IRole } from './role'; - -export interface IProfile { - rootRole: IRole; - projects: string[]; -} diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts new file mode 100644 index 000000000000..099a48a48bc6 --- /dev/null +++ b/frontend/src/interfaces/releasePlans.ts @@ -0,0 +1,7 @@ +export interface IReleasePlanTemplate { + id: string; + name: string; + description: string; + createdAt: string; + createdByUserId: number; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 6e7b8c23191a..3f2d542a3664 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -71,12 +71,10 @@ export type UiFlags = { signals?: boolean; automatedActions?: boolean; celebrateUnleash?: boolean; - featureSearchFeedback?: Variant; enableLicense?: boolean; adminTokenKillSwitch?: boolean; feedbackComments?: Variant; showInactiveUsers?: boolean; - featureSearchFeedbackPosting?: boolean; userAccessUIEnabled?: boolean; outdatedSdksBanner?: boolean; estimateTrafficDataCost?: boolean; @@ -94,6 +92,7 @@ export type UiFlags = { 'enterprise-payg'?: boolean; simplifyProjectOverview?: boolean; productivityReportEmail?: boolean; + flagOverviewRedesign?: boolean; }; export interface IVersionInfo { diff --git a/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate401.ts b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate401.ts new file mode 100644 index 000000000000..7c344a04d672 --- /dev/null +++ b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddMilestoneToReleasePlanTemplate401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate403.ts b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate403.ts new file mode 100644 index 000000000000..2cd301cceb1b --- /dev/null +++ b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddMilestoneToReleasePlanTemplate403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate404.ts b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate404.ts new file mode 100644 index 000000000000..043010932af4 --- /dev/null +++ b/frontend/src/openapi/models/addMilestoneToReleasePlanTemplate404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddMilestoneToReleasePlanTemplate404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/addStrategyToMilestone401.ts b/frontend/src/openapi/models/addStrategyToMilestone401.ts new file mode 100644 index 000000000000..7e04e64dad4d --- /dev/null +++ b/frontend/src/openapi/models/addStrategyToMilestone401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddStrategyToMilestone401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/addStrategyToMilestone403.ts b/frontend/src/openapi/models/addStrategyToMilestone403.ts new file mode 100644 index 000000000000..c0ae3e1ba2fe --- /dev/null +++ b/frontend/src/openapi/models/addStrategyToMilestone403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddStrategyToMilestone403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/addStrategyToMilestone404.ts b/frontend/src/openapi/models/addStrategyToMilestone404.ts new file mode 100644 index 000000000000..ffc37e81fa30 --- /dev/null +++ b/frontend/src/openapi/models/addStrategyToMilestone404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type AddStrategyToMilestone404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/deleteReleasePlanTemplate401.ts b/frontend/src/openapi/models/deleteReleasePlanTemplate401.ts new file mode 100644 index 000000000000..4a91de8af29d --- /dev/null +++ b/frontend/src/openapi/models/deleteReleasePlanTemplate401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type DeleteReleasePlanTemplate401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/deleteReleasePlanTemplate403.ts b/frontend/src/openapi/models/deleteReleasePlanTemplate403.ts new file mode 100644 index 000000000000..77ff78edc38f --- /dev/null +++ b/frontend/src/openapi/models/deleteReleasePlanTemplate403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type DeleteReleasePlanTemplate403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/deprecatedSearchEventsSchemaType.ts b/frontend/src/openapi/models/deprecatedSearchEventsSchemaType.ts index 5997615e661b..a146c92ddb66 100644 --- a/frontend/src/openapi/models/deprecatedSearchEventsSchemaType.ts +++ b/frontend/src/openapi/models/deprecatedSearchEventsSchemaType.ts @@ -162,4 +162,8 @@ export const DeprecatedSearchEventsSchemaType = { 'actions-created': 'actions-created', 'actions-updated': 'actions-updated', 'actions-deleted': 'actions-deleted', + 'release-plan-template-created': 'release-plan-template-created', + 'release-plan-template-updated': 'release-plan-template-updated', + 'release-plan-template-deleted': 'release-plan-template-deleted', + 'user-preference-updated': 'user-preference-updated', } as const; diff --git a/frontend/src/openapi/models/eventSchemaType.ts b/frontend/src/openapi/models/eventSchemaType.ts index 99cce4516a58..2bd61fc10703 100644 --- a/frontend/src/openapi/models/eventSchemaType.ts +++ b/frontend/src/openapi/models/eventSchemaType.ts @@ -162,4 +162,8 @@ export const EventSchemaType = { 'actions-created': 'actions-created', 'actions-updated': 'actions-updated', 'actions-deleted': 'actions-deleted', + 'release-plan-template-created': 'release-plan-template-created', + 'release-plan-template-updated': 'release-plan-template-updated', + 'release-plan-template-deleted': 'release-plan-template-deleted', + 'user-preference-updated': 'user-preference-updated', } as const; diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 0349b125a6b6..8e28d287be63 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -47,6 +47,9 @@ export * from './addFeatureDependency404'; export * from './addFeatureStrategy401'; export * from './addFeatureStrategy403'; export * from './addFeatureStrategy404'; +export * from './addMilestoneToReleasePlanTemplate401'; +export * from './addMilestoneToReleasePlanTemplate403'; +export * from './addMilestoneToReleasePlanTemplate404'; export * from './addPublicSignupTokenUser400'; export * from './addPublicSignupTokenUser409'; export * from './addRoleAccessToProject400'; @@ -57,6 +60,9 @@ export * from './addRoleAccessToProject415'; export * from './addRoleToUser401'; export * from './addRoleToUser403'; export * from './addRoleToUser404'; +export * from './addStrategyToMilestone401'; +export * from './addStrategyToMilestone403'; +export * from './addStrategyToMilestone404'; export * from './addTag400'; export * from './addTag401'; export * from './addTag403'; @@ -504,6 +510,8 @@ export * from './deleteProjectApiToken400'; export * from './deleteProjectApiToken401'; export * from './deleteProjectApiToken403'; export * from './deleteProjectApiToken404'; +export * from './deleteReleasePlanTemplate401'; +export * from './deleteReleasePlanTemplate403'; export * from './deleteRole400'; export * from './deleteRole401'; export * from './deleteRole403'; @@ -1022,6 +1030,7 @@ export * from './projectSettingsSchemaDefaultStickiness'; export * from './projectSettingsSchemaMode'; export * from './projectStatsSchema'; export * from './projectStatusSchema'; +export * from './projectStatusSchemaResources'; export * from './projectUsersSchema'; export * from './projectsSchema'; export * from './provideFeedbackSchema'; @@ -1047,6 +1056,9 @@ export * from './releasePlanSchema'; export * from './releasePlanSchemaDiscriminator'; export * from './releasePlanTemplateSchema'; export * from './releasePlanTemplateSchemaDiscriminator'; +export * from './remoteMilestoneStrategy401'; +export * from './remoteMilestoneStrategy403'; +export * from './remoteMilestoneStrategy404'; export * from './removeEnvironment400'; export * from './removeEnvironment401'; export * from './removeEnvironmentFromProject400'; @@ -1059,6 +1071,9 @@ export * from './removeFavoriteProject404'; export * from './removeGroupAccess401'; export * from './removeGroupAccess403'; export * from './removeGroupAccess404'; +export * from './removeReleasePlanMilestone401'; +export * from './removeReleasePlanMilestone403'; +export * from './removeReleasePlanMilestone404'; export * from './removeRoleForUser401'; export * from './removeRoleForUser403'; export * from './removeRoleForUser404'; @@ -1302,6 +1317,9 @@ export * from './updateLicense400'; export * from './updateLicense401'; export * from './updateLicense403'; export * from './updateLicense415'; +export * from './updateMilestoneStrategy401'; +export * from './updateMilestoneStrategy403'; +export * from './updateMilestoneStrategy404'; export * from './updateProject400'; export * from './updateProject401'; export * from './updateProject403'; @@ -1319,6 +1337,14 @@ export * from './updateProjectSchemaMode'; export * from './updatePublicSignupToken400'; export * from './updatePublicSignupToken401'; export * from './updatePublicSignupToken403'; +export * from './updateReleasePlanMilestoneSchema'; +export * from './updateReleasePlanMilestoneStrategySchema'; +export * from './updateReleasePlanTemplate401'; +export * from './updateReleasePlanTemplate403'; +export * from './updateReleasePlanTemplateMilestone401'; +export * from './updateReleasePlanTemplateMilestone403'; +export * from './updateReleasePlanTemplateMilestone404'; +export * from './updateReleasePlanTemplateSchema'; export * from './updateRole400'; export * from './updateRole401'; export * from './updateRole403'; diff --git a/frontend/src/openapi/models/profileSchema.ts b/frontend/src/openapi/models/profileSchema.ts index a06de78c3a64..9d57d2ff8253 100644 --- a/frontend/src/openapi/models/profileSchema.ts +++ b/frontend/src/openapi/models/profileSchema.ts @@ -15,4 +15,6 @@ export interface ProfileSchema { /** Which projects this user is a member of */ projects: string[]; rootRole: RoleSchema; + /** Which email subscriptions this user is subscribed to */ + subscriptions: string[]; } diff --git a/frontend/src/openapi/models/projectStatusSchema.ts b/frontend/src/openapi/models/projectStatusSchema.ts index 3b9f9c87e86d..0794d97f1cde 100644 --- a/frontend/src/openapi/models/projectStatusSchema.ts +++ b/frontend/src/openapi/models/projectStatusSchema.ts @@ -4,6 +4,7 @@ * See `gen:api` script in package.json */ import type { ProjectActivitySchema } from './projectActivitySchema'; +import type { ProjectStatusSchemaResources } from './projectStatusSchemaResources'; /** * Schema representing the overall status of a project, including an array of activity records. Each record in the activity array contains a date and a count, providing a snapshot of the project’s activity level over time. @@ -11,4 +12,11 @@ import type { ProjectActivitySchema } from './projectActivitySchema'; export interface ProjectStatusSchema { /** Array of activity records with date and count, representing the project’s daily activity statistics. */ activityCountByDate: ProjectActivitySchema; + /** + * The average health score over the last 4 weeks, indicating whether features are stale or active. + * @minimum 0 + */ + averageHealth: number; + /** Key resources within the project */ + resources: ProjectStatusSchemaResources; } diff --git a/frontend/src/openapi/models/projectStatusSchemaResources.ts b/frontend/src/openapi/models/projectStatusSchemaResources.ts new file mode 100644 index 000000000000..bb028781dd35 --- /dev/null +++ b/frontend/src/openapi/models/projectStatusSchemaResources.ts @@ -0,0 +1,31 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +/** + * Key resources within the project + */ +export type ProjectStatusSchemaResources = { + /** + * The number of API tokens created specifically for this project. + * @minimum 0 + */ + apiTokens: number; + /** + * The number of environments that have received SDK traffic in this project. + * @minimum 0 + */ + connectedEnvironments: number; + /** + * The number of users who have been granted roles in this project. Does not include users who have access via groups. + * @minimum 0 + */ + members: number; + /** + * The number of segments that are scoped to this project. + * @minimum 0 + */ + segments: number; +}; diff --git a/frontend/src/openapi/models/releasePlanMilestoneStrategySchema.ts b/frontend/src/openapi/models/releasePlanMilestoneStrategySchema.ts index e3ca1171921e..f9dbf1bca484 100644 --- a/frontend/src/openapi/models/releasePlanMilestoneStrategySchema.ts +++ b/frontend/src/openapi/models/releasePlanMilestoneStrategySchema.ts @@ -31,7 +31,7 @@ export interface ReleasePlanMilestoneStrategySchema { * A descriptive title for the strategy * @nullable */ - title: string | null; + title?: string | null; /** Strategy level variants */ variants?: CreateStrategyVariantSchema[]; } diff --git a/frontend/src/openapi/models/remoteMilestoneStrategy401.ts b/frontend/src/openapi/models/remoteMilestoneStrategy401.ts new file mode 100644 index 000000000000..0701ea62ef47 --- /dev/null +++ b/frontend/src/openapi/models/remoteMilestoneStrategy401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoteMilestoneStrategy401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/remoteMilestoneStrategy403.ts b/frontend/src/openapi/models/remoteMilestoneStrategy403.ts new file mode 100644 index 000000000000..c3cae9b44e5e --- /dev/null +++ b/frontend/src/openapi/models/remoteMilestoneStrategy403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoteMilestoneStrategy403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/remoteMilestoneStrategy404.ts b/frontend/src/openapi/models/remoteMilestoneStrategy404.ts new file mode 100644 index 000000000000..89a4fb8c6b12 --- /dev/null +++ b/frontend/src/openapi/models/remoteMilestoneStrategy404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoteMilestoneStrategy404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/removeReleasePlanMilestone401.ts b/frontend/src/openapi/models/removeReleasePlanMilestone401.ts new file mode 100644 index 000000000000..1aa432dd40ec --- /dev/null +++ b/frontend/src/openapi/models/removeReleasePlanMilestone401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoveReleasePlanMilestone401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/removeReleasePlanMilestone403.ts b/frontend/src/openapi/models/removeReleasePlanMilestone403.ts new file mode 100644 index 000000000000..8a2963a9d18f --- /dev/null +++ b/frontend/src/openapi/models/removeReleasePlanMilestone403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoveReleasePlanMilestone403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/removeReleasePlanMilestone404.ts b/frontend/src/openapi/models/removeReleasePlanMilestone404.ts new file mode 100644 index 000000000000..4c87bd767839 --- /dev/null +++ b/frontend/src/openapi/models/removeReleasePlanMilestone404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type RemoveReleasePlanMilestone404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateMilestoneStrategy401.ts b/frontend/src/openapi/models/updateMilestoneStrategy401.ts new file mode 100644 index 000000000000..a3a05e3d93b6 --- /dev/null +++ b/frontend/src/openapi/models/updateMilestoneStrategy401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateMilestoneStrategy401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateMilestoneStrategy403.ts b/frontend/src/openapi/models/updateMilestoneStrategy403.ts new file mode 100644 index 000000000000..2408bd30025c --- /dev/null +++ b/frontend/src/openapi/models/updateMilestoneStrategy403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateMilestoneStrategy403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateMilestoneStrategy404.ts b/frontend/src/openapi/models/updateMilestoneStrategy404.ts new file mode 100644 index 000000000000..02c0d06b34d8 --- /dev/null +++ b/frontend/src/openapi/models/updateMilestoneStrategy404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateMilestoneStrategy404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanMilestoneSchema.ts b/frontend/src/openapi/models/updateReleasePlanMilestoneSchema.ts new file mode 100644 index 000000000000..d66e577b57c2 --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanMilestoneSchema.ts @@ -0,0 +1,20 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ +import type { UpdateReleasePlanMilestoneStrategySchema } from './updateReleasePlanMilestoneStrategySchema'; + +/** + * Schema representing the update of a release plan milestone. + */ +export interface UpdateReleasePlanMilestoneSchema { + /** The name of the milestone. */ + name: string; + /** The ID of the release plan/template that this milestone belongs to. */ + releasePlanDefinitionId: string; + /** The order of the milestone in the release plan. */ + sortOrder: number; + /** A list of strategies that are attached to this milestone. */ + strategies?: UpdateReleasePlanMilestoneStrategySchema[]; +} diff --git a/frontend/src/openapi/models/updateReleasePlanMilestoneStrategySchema.ts b/frontend/src/openapi/models/updateReleasePlanMilestoneStrategySchema.ts new file mode 100644 index 000000000000..2122e1d2d1b6 --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanMilestoneStrategySchema.ts @@ -0,0 +1,37 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ +import type { ConstraintSchema } from './constraintSchema'; +import type { ParametersSchema } from './parametersSchema'; +import type { CreateStrategyVariantSchema } from './createStrategyVariantSchema'; + +/** + * Schema representing the update of a release plan milestone. + */ +export interface UpdateReleasePlanMilestoneStrategySchema { + /** A list of the constraints attached to the strategy. See https://docs.getunleash.io/reference/strategy-constraints */ + constraints?: ConstraintSchema[]; + /** The milestone strategy's ID. Milestone strategy IDs are ulids. */ + id?: string; + /** + * The ID of the milestone that this strategy belongs to. + */ + milestoneId: string; + /** An object containing the parameters for the strategy */ + parameters?: ParametersSchema; + /** Ids of segments to use for this strategy */ + segments?: number[]; + /** The order of the strategy in the list */ + sortOrder: number; + /** The name of the strategy type */ + strategyName: string; + /** + * A descriptive title for the strategy + * @nullable + */ + title?: string | null; + /** Strategy level variants */ + variants?: CreateStrategyVariantSchema[]; +} diff --git a/frontend/src/openapi/models/updateReleasePlanTemplate401.ts b/frontend/src/openapi/models/updateReleasePlanTemplate401.ts new file mode 100644 index 000000000000..a867a4cce7fb --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplate401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateReleasePlanTemplate401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanTemplate403.ts b/frontend/src/openapi/models/updateReleasePlanTemplate403.ts new file mode 100644 index 000000000000..c096eaec71cf --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplate403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateReleasePlanTemplate403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanTemplateMilestone401.ts b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone401.ts new file mode 100644 index 000000000000..f59c9a97d94e --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone401.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateReleasePlanTemplateMilestone401 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanTemplateMilestone403.ts b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone403.ts new file mode 100644 index 000000000000..eaf746feeacd --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone403.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateReleasePlanTemplateMilestone403 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanTemplateMilestone404.ts b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone404.ts new file mode 100644 index 000000000000..173c964b4d43 --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplateMilestone404.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +export type UpdateReleasePlanTemplateMilestone404 = { + /** The ID of the error instance */ + id?: string; + /** A description of what went wrong. */ + message?: string; + /** The name of the error kind */ + name?: string; +}; diff --git a/frontend/src/openapi/models/updateReleasePlanTemplateSchema.ts b/frontend/src/openapi/models/updateReleasePlanTemplateSchema.ts new file mode 100644 index 000000000000..80e3d6f995ad --- /dev/null +++ b/frontend/src/openapi/models/updateReleasePlanTemplateSchema.ts @@ -0,0 +1,28 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ +import type { CreateReleasePlanMilestoneSchema } from './createReleasePlanMilestoneSchema'; + +/** + * Schema representing the update of a release template. + */ +export interface UpdateReleasePlanTemplateSchema { + /** + * A description of the release template. + * @nullable + */ + description?: string | null; + /** + * The release plan/template's ID. Release template IDs are ulids. + */ + id: string; + /** + * A list of the milestones in this release template. + * @nullable + */ + milestones?: CreateReleasePlanMilestoneSchema[] | null; + /** The name of the release template. */ + name: string; +} diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts index 7fff11db4e4d..b25031f7b9dc 100644 --- a/frontend/vite.config.mts +++ b/frontend/vite.config.mts @@ -23,6 +23,13 @@ const vitestConfig = vitestDefineConfig({ environment: 'jsdom', exclude: [...configDefaults.exclude, '**/cypress/**'], }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + }, + }, + }, }); export default mergeConfig( diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts index 5e2951c48dd3..e3c386d3d01c 100644 --- a/src/lib/create-config.ts +++ b/src/lib/create-config.ts @@ -259,7 +259,10 @@ const defaultDbOptions: WithOptional = propagateCreateError: false, }, schema: process.env.DATABASE_SCHEMA || 'public', - disableMigration: false, + disableMigration: parseEnvVarBoolean( + process.env.DATABASE_DISABLE_MIGRATION, + false, + ), applicationName: process.env.DATABASE_APPLICATION_NAME || 'unleash', }; diff --git a/src/lib/db/api-token-store.ts b/src/lib/db/api-token-store.ts index 338e293c2b56..75b5b535429d 100644 --- a/src/lib/db/api-token-store.ts +++ b/src/lib/db/api-token-store.ts @@ -326,4 +326,12 @@ export class ApiTokenStore implements IApiTokenStore { activeLegacyTokens, }; } + + async countProjectTokens(projectId: string): Promise { + const count = await this.db(API_LINK_TABLE) + .where({ project: projectId }) + .count() + .first(); + return Number(count?.count ?? 0); + } } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 4785a3fbbbaa..72ddcd4b497b 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -190,10 +190,7 @@ export const createStores = ( config.flagResolver, ), userUnsubscribeStore: new UserUnsubscribeStore(db), - userSubscriptionsReadModel: new UserSubscriptionsReadModel( - db, - eventBus, - ), + userSubscriptionsReadModel: new UserSubscriptionsReadModel(db), }; }; diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index fb3d749ba5eb..18e5b4309239 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -281,6 +281,19 @@ class UserStore implements IUserStore { .then((res) => Number(res[0].count)); } + async countRecentlyDeleted(): Promise { + return this.db(TABLE) + .whereNotNull('deleted_at') + .andWhere( + 'deleted_at', + '>=', + this.db.raw(`NOW() - INTERVAL '1 month'`), + ) + .andWhere({ is_service: false, is_system: false }) + .count('*') + .then((res) => Number(res[0].count)); + } + destroy(): void {} async exists(id: number): Promise { diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 8b522f30bf8b..5be33de1bda5 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -409,7 +409,7 @@ class EventStore implements IEventStore { })); } - async getProjectEventActivity( + async getProjectRecentEventActivity( project: string, ): Promise { const result = await this.db('events') @@ -418,6 +418,11 @@ class EventStore implements IEventStore { ) .count('* AS count') .where('project', project) + .andWhere( + 'created_at', + '>=', + this.db.raw("NOW() - INTERVAL '1 year'"), + ) .groupBy(this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD')")) .orderBy('date', 'asc'); diff --git a/src/lib/features/project-status/createProjectStatusService.ts b/src/lib/features/project-status/createProjectStatusService.ts index 6db4fa1a45ea..429fd5a147ac 100644 --- a/src/lib/features/project-status/createProjectStatusService.ts +++ b/src/lib/features/project-status/createProjectStatusService.ts @@ -4,6 +4,12 @@ import EventStore from '../events/event-store'; import FakeEventStore from '../../../test/fixtures/fake-event-store'; import ProjectStore from '../project/project-store'; import FakeProjectStore from '../../../test/fixtures/fake-project-store'; +import FakeApiTokenStore from '../../../test/fixtures/fake-api-token-store'; +import { ApiTokenStore } from '../../db/api-token-store'; +import SegmentStore from '../segment/segment-store'; +import FakeSegmentStore from '../../../test/fixtures/fake-segment-store'; +import { PersonalDashboardReadModel } from '../personal-dashboard/personal-dashboard-read-model'; +import { FakePersonalDashboardReadModel } from '../personal-dashboard/fake-personal-dashboard-read-model'; export const createProjectStatusService = ( db: Db, @@ -16,16 +22,44 @@ export const createProjectStatusService = ( config.getLogger, config.flagResolver, ); - return new ProjectStatusService({ eventStore, projectStore }); + const apiTokenStore = new ApiTokenStore( + db, + config.eventBus, + config.getLogger, + config.flagResolver, + ); + const segmentStore = new SegmentStore( + db, + config.eventBus, + config.getLogger, + config.flagResolver, + ); + + return new ProjectStatusService( + { + eventStore, + projectStore, + apiTokenStore, + segmentStore, + }, + new PersonalDashboardReadModel(db), + ); }; export const createFakeProjectStatusService = () => { const eventStore = new FakeEventStore(); const projectStore = new FakeProjectStore(); - const projectStatusService = new ProjectStatusService({ - eventStore, - projectStore, - }); + const apiTokenStore = new FakeApiTokenStore(); + const segmentStore = new FakeSegmentStore(); + const projectStatusService = new ProjectStatusService( + { + eventStore, + projectStore, + apiTokenStore, + segmentStore, + }, + new FakePersonalDashboardReadModel(), + ); return { projectStatusService, diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts new file mode 100644 index 000000000000..62acab91110f --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts @@ -0,0 +1,199 @@ +import { addDays, addMinutes } from 'date-fns'; +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { ProjectLifecycleSummaryReadModel } from './project-lifecycle-summary-read-model'; +import type { StageName } from '../../types'; +import { randomId } from '../../util'; + +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('project_lifecycle_summary_read_model_serial', getLogger); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +afterEach(async () => { + await db.stores.projectStore.deleteAll(); + await db.stores.featureToggleStore.deleteAll(); + await db.stores.featureLifecycleStore.deleteAll(); +}); + +const updateFeatureStageDate = async ( + flagName: string, + stage: string, + newDate: Date, +) => { + await db + .rawDatabase('feature_lifecycles') + .where({ feature: flagName, stage: stage }) + .update({ created_at: newDate }); +}; + +describe('Average time calculation', () => { + test('it calculates the average time for each stage', async () => { + const project = await db.stores.projectStore.create({ + name: 'project', + id: randomId(), + }); + const now = new Date(); + + const flags = [ + { name: randomId(), offsets: [2, 5, 6, 10] }, + { name: randomId(), offsets: [1, null, 4, 7] }, + { name: randomId(), offsets: [12, 25, 0, 9] }, + { name: randomId(), offsets: [1, 2, 3, null] }, + ]; + + for (const { name, offsets } of flags) { + const created = await db.stores.featureToggleStore.create( + project.id, + { + name, + createdByUserId: 1, + }, + ); + await db.stores.featureLifecycleStore.insert([ + { + feature: name, + stage: 'initial', + }, + ]); + + const stages = ['pre-live', 'live', 'completed', 'archived']; + for (const [index, stage] of stages.entries()) { + const offset = offsets[index]; + if (offset === null) { + continue; + } + + const offsetFromInitial = offsets + .slice(0, index + 1) + .reduce((a, b) => (a ?? 0) + (b ?? 0), 0) as number; + + await db.stores.featureLifecycleStore.insert([ + { + feature: created.name, + stage: stage as StageName, + }, + ]); + + await updateFeatureStageDate( + created.name, + stage, + addMinutes( + addDays(now, offsetFromInitial), + 1 * (index + 1), + ), + ); + } + } + + const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); + + const result = await readModel.getAverageTimeInEachStage(project.id); + + expect(result).toMatchObject({ + initial: 4, // (2 + 1 + 12 + 1) / 4 = 4 + 'pre-live': 9, // (5 + 4 + 25 + 2) / 4 = 9 + live: 3, // (6 + 0 + 3) / 3 = 3 + completed: 9, // (10 + 7 + 9) / 3 ~= 8.67 ~= 9 + }); + }); + + test('it returns `null` if it has no data for something', async () => { + const project = await db.stores.projectStore.create({ + name: 'project', + id: randomId(), + }); + const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); + + const result1 = await readModel.getAverageTimeInEachStage(project.id); + + expect(result1).toMatchObject({ + initial: null, + 'pre-live': null, + live: null, + completed: null, + }); + + const flag = await db.stores.featureToggleStore.create(project.id, { + name: randomId(), + createdByUserId: 1, + }); + await db.stores.featureLifecycleStore.insert([ + { + feature: flag.name, + stage: 'initial', + }, + ]); + + await db.stores.featureLifecycleStore.insert([ + { + feature: flag.name, + stage: 'pre-live', + }, + ]); + + await updateFeatureStageDate( + flag.name, + 'pre-live', + addDays(new Date(), 5), + ); + + const result2 = await readModel.getAverageTimeInEachStage(project.id); + + expect(result2).toMatchObject({ + initial: 5, + 'pre-live': null, + live: null, + completed: null, + }); + }); + + test('it ignores flags in other projects', async () => { + const project = await db.stores.projectStore.create({ + name: 'project', + id: randomId(), + }); + const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); + + const flag = await db.stores.featureToggleStore.create(project.id, { + name: randomId(), + createdByUserId: 1, + }); + await db.stores.featureLifecycleStore.insert([ + { + feature: flag.name, + stage: 'initial', + }, + ]); + + await db.stores.featureLifecycleStore.insert([ + { + feature: flag.name, + stage: 'pre-live', + }, + ]); + + await updateFeatureStageDate( + flag.name, + 'pre-live', + addDays(new Date(), 5), + ); + + const result = + await readModel.getAverageTimeInEachStage('some-other-project'); + + expect(result).toMatchObject({ + initial: null, + 'pre-live': null, + live: null, + completed: null, + }); + }); +}); diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts new file mode 100644 index 000000000000..b37f3561ce04 --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts @@ -0,0 +1,136 @@ +import * as permissions from '../../types/permissions'; +import type { Db } from '../../db/db'; + +const { ADMIN } = permissions; + +export type IProjectLifecycleSummaryReadModel = {}; + +type ProjectLifecycleSummary = { + initial: { + averageDays: number; + currentFlags: number; + }; + preLive: { + averageDays: number; + currentFlags: number; + }; + live: { + averageDays: number; + currentFlags: number; + }; + completed: { + averageDays: number; + currentFlags: number; + }; + archived: { + currentFlags: number; + archivedFlagsOverLastMonth: number; + }; +}; + +export class ProjectLifecycleSummaryReadModel + implements IProjectLifecycleSummaryReadModel +{ + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getAverageTimeInEachStage(projectId: string): Promise<{ + initial: number | null; + 'pre-live': number | null; + live: number | null; + completed: number | null; + }> { + const q = this.db + .with( + 'stage_durations', + this.db('feature_lifecycles as fl1') + .select( + 'fl1.feature', + 'fl1.stage', + this.db.raw( + 'EXTRACT(EPOCH FROM (MIN(fl2.created_at) - fl1.created_at)) / 86400 AS days_in_stage', + ), + ) + .join('feature_lifecycles as fl2', function () { + this.on('fl1.feature', '=', 'fl2.feature').andOn( + 'fl2.created_at', + '>', + 'fl1.created_at', + ); + }) + .innerJoin('features as f', 'fl1.feature', 'f.name') + .where('f.project', projectId) + .whereNot('fl1.stage', 'archived') + .groupBy('fl1.feature', 'fl1.stage'), + ) + .select('stage_durations.stage') + .select( + this.db.raw('ROUND(AVG(days_in_stage)) AS avg_days_in_stage'), + ) + .from('stage_durations') + .groupBy('stage_durations.stage'); + + const result = await q; + return result.reduce( + (acc, row) => { + acc[row.stage] = Number(row.avg_days_in_stage); + return acc; + }, + { + initial: null, + 'pre-live': null, + live: null, + completed: null, + }, + ); + } + + async getCurrentFlagsInEachStage(projectId: string) { + return 0; + } + + async getArchivedFlagsOverLastMonth(projectId: string) { + return 0; + } + + async getProjectLifecycleSummary( + projectId: string, + ): Promise { + const [ + averageTimeInEachStage, + currentFlagsInEachStage, + archivedFlagsOverLastMonth, + ] = await Promise.all([ + this.getAverageTimeInEachStage(projectId), + this.getCurrentFlagsInEachStage(projectId), + this.getArchivedFlagsOverLastMonth(projectId), + ]); + + // collate the data + return { + initial: { + averageDays: 0, + currentFlags: 0, + }, + preLive: { + averageDays: 0, + currentFlags: 0, + }, + live: { + averageDays: 0, + currentFlags: 0, + }, + completed: { + averageDays: 0, + currentFlags: 0, + }, + archived: { + currentFlags: 0, + archivedFlagsOverLastMonth: 0, + }, + }; + } +} diff --git a/src/lib/features/project-status/project-status-service.ts b/src/lib/features/project-status/project-status-service.ts index 47352ca3e031..12bc5a80e3d0 100644 --- a/src/lib/features/project-status/project-status-service.ts +++ b/src/lib/features/project-status/project-status-service.ts @@ -1,28 +1,70 @@ import type { ProjectStatusSchema } from '../../openapi'; -import type { IEventStore, IProjectStore, IUnleashStores } from '../../types'; +import type { + IApiTokenStore, + IEventStore, + IProjectStore, + ISegmentStore, + IUnleashStores, +} from '../../types'; +import type { IPersonalDashboardReadModel } from '../personal-dashboard/personal-dashboard-read-model-type'; export class ProjectStatusService { private eventStore: IEventStore; private projectStore: IProjectStore; + private apiTokenStore: IApiTokenStore; + private segmentStore: ISegmentStore; + private personalDashboardReadModel: IPersonalDashboardReadModel; - constructor({ - eventStore, - projectStore, - }: Pick) { + constructor( + { + eventStore, + projectStore, + apiTokenStore, + segmentStore, + }: Pick< + IUnleashStores, + 'eventStore' | 'projectStore' | 'apiTokenStore' | 'segmentStore' + >, + personalDashboardReadModel: IPersonalDashboardReadModel, + ) { this.eventStore = eventStore; this.projectStore = projectStore; + this.apiTokenStore = apiTokenStore; + this.segmentStore = segmentStore; + this.personalDashboardReadModel = personalDashboardReadModel; } async getProjectStatus(projectId: string): Promise { + const [ + connectedEnvironments, + members, + apiTokens, + segments, + activityCountByDate, + healthScores, + ] = await Promise.all([ + this.projectStore.getConnectedEnvironmentCountForProject(projectId), + this.projectStore.getMembersCountByProject(projectId), + this.apiTokenStore.countProjectTokens(projectId), + this.segmentStore.getProjectSegmentCount(projectId), + this.eventStore.getProjectRecentEventActivity(projectId), + this.personalDashboardReadModel.getLatestHealthScores(projectId, 4), + ]); + + const averageHealth = healthScores.length + ? healthScores.reduce((acc, num) => acc + num, 0) / + healthScores.length + : 0; + return { resources: { - connectedEnvironments: - await this.projectStore.getConnectedEnvironmentCountForProject( - projectId, - ), + connectedEnvironments, + members, + apiTokens, + segments, }, - activityCountByDate: - await this.eventStore.getProjectEventActivity(projectId), + activityCountByDate, + averageHealth, }; } } diff --git a/src/lib/features/project-status/projects-status.e2e.test.ts b/src/lib/features/project-status/projects-status.e2e.test.ts index f19171d2f209..79c1d803040a 100644 --- a/src/lib/features/project-status/projects-status.e2e.test.ts +++ b/src/lib/features/project-status/projects-status.e2e.test.ts @@ -4,11 +4,17 @@ import { setupAppWithCustomConfig, } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; -import { FEATURE_CREATED, type IUnleashConfig } from '../../types'; +import { + FEATURE_CREATED, + RoleName, + type IAuditUser, + type IUnleashConfig, +} from '../../types'; import type { EventService } from '../../services'; import { createEventsService } from '../events/createEventsService'; import { createTestConfig } from '../../../test/config/test-config'; import { randomId } from '../../util'; +import { ApiTokenType } from '../../types/models/api-token'; let app: IUnleashTest; let db: ITestDb; @@ -17,6 +23,20 @@ let eventService: EventService; const TEST_USER_ID = -9999; const config: IUnleashConfig = createTestConfig(); +const insertHealthScore = (id: string, health: number) => { + const irrelevantFlagTrendDetails = { + total_flags: 10, + stale_flags: 10, + potentially_stale_flags: 10, + }; + return db.rawDatabase('flag_trends').insert({ + ...irrelevantFlagTrendDetails, + id, + project: 'default', + health, + }); +}; + const getCurrentDateStrings = () => { const today = new Date(); const todayString = today.toISOString().split('T')[0]; @@ -47,6 +67,10 @@ afterAll(async () => { await db.destroy(); }); +beforeEach(async () => { + await db.stores.clientMetricsStoreV2.deleteAll(); +}); + test('project insights should return correct count for each day', async () => { await eventService.storeEvent({ type: FEATURE_CREATED, @@ -145,3 +169,90 @@ test('project status should return environments with connected SDKs', async () = expect(body.resources.connectedEnvironments).toBe(1); }); + +test('project resources should contain the right data', async () => { + const { body: noResourcesBody } = await app.request + .get('/api/admin/projects/default/status') + .expect('Content-Type', /json/) + .expect(200); + + expect(noResourcesBody.resources).toMatchObject({ + members: 0, + apiTokens: 0, + segments: 0, + connectedEnvironments: 0, + }); + + const flagName = randomId(); + await app.createFeature(flagName); + + const environment = 'default'; + await db.stores.clientMetricsStoreV2.batchInsertMetrics([ + { + featureName: flagName, + appName: `web2`, + environment, + timestamp: new Date(), + yes: 5, + no: 2, + }, + ]); + + await app.services.apiTokenService.createApiTokenWithProjects({ + tokenName: 'test-token', + projects: ['default'], + type: ApiTokenType.CLIENT, + environment: 'default', + }); + + await app.services.segmentService.create( + { + name: 'test-segment', + project: 'default', + constraints: [], + }, + {} as IAuditUser, + ); + + const admin = await app.services.userService.createUser({ + username: 'admin', + rootRole: RoleName.ADMIN, + }); + const user = await app.services.userService.createUser({ + username: 'test-user', + rootRole: RoleName.EDITOR, + }); + + await app.services.projectService.addAccess('default', [4], [], [user.id], { + ...admin, + ip: '', + } as IAuditUser); + + const { body } = await app.request + .get('/api/admin/projects/default/status') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.resources).toMatchObject({ + members: 1, + apiTokens: 1, + segments: 1, + connectedEnvironments: 1, + }); +}); + +test('project health should be correct average', async () => { + await insertHealthScore('2024-04', 100); + + await insertHealthScore('2024-05', 0); + await insertHealthScore('2024-06', 0); + await insertHealthScore('2024-07', 90); + await insertHealthScore('2024-08', 70); + + const { body } = await app.request + .get('/api/admin/projects/default/status') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.averageHealth).toBe(40); +}); diff --git a/src/lib/features/project/project-read-model.ts b/src/lib/features/project/project-read-model.ts index 06bb57cb196e..e4c3eaecc739 100644 --- a/src/lib/features/project/project-read-model.ts +++ b/src/lib/features/project/project-read-model.ts @@ -192,7 +192,7 @@ export class ProjectReadModel implements IProjectReadModel { 'projects.id, projects.health, ' + 'count(features.name) FILTER (WHERE features.archived_at is null) AS number_of_features, ' + 'count(features.name) FILTER (WHERE features.archived_at is null and features.stale IS TRUE) AS stale_feature_count, ' + - 'count(features.name) FILTER (WHERE features.archived_at is null and features.potentially_stale IS TRUE) AS potentially_stale_feature_count', + 'count(features.name) FILTER (WHERE features.archived_at is null and features.potentially_stale IS TRUE and features.stale IS FALSE) AS potentially_stale_feature_count', ), 'project_stats.avg_time_to_prod_current_window', 'projects.archived_at', diff --git a/src/lib/features/segment/segment-store-type.ts b/src/lib/features/segment/segment-store-type.ts index eb6f417a595f..b90bfc6e4a2e 100644 --- a/src/lib/features/segment/segment-store-type.ts +++ b/src/lib/features/segment/segment-store-type.ts @@ -25,4 +25,6 @@ export interface ISegmentStore extends Store { existsByName(name: string): Promise; count(): Promise; + + getProjectSegmentCount(projectId: string): Promise; } diff --git a/src/lib/features/segment/segment-store.ts b/src/lib/features/segment/segment-store.ts index 890ce7cc0b28..9f1a751c22a6 100644 --- a/src/lib/features/segment/segment-store.ts +++ b/src/lib/features/segment/segment-store.ts @@ -370,6 +370,15 @@ export default class SegmentStore implements ISegmentStore { return Boolean(rows[0]); } + async getProjectSegmentCount(projectId: string): Promise { + const result = await this.db.raw( + `SELECT COUNT(*) FROM ${T.segments} WHERE segment_project_id = ?`, + [projectId], + ); + + return Number(result.rows[0].count); + } + prefixColumns(): string[] { return COLUMNS.map((c) => `${T.segments}.${c}`); } diff --git a/src/lib/features/user-subscriptions/createUserSubscriptionsService.ts b/src/lib/features/user-subscriptions/createUserSubscriptionsService.ts index e0a9d5b72254..c2fe97fe0b2a 100644 --- a/src/lib/features/user-subscriptions/createUserSubscriptionsService.ts +++ b/src/lib/features/user-subscriptions/createUserSubscriptionsService.ts @@ -6,15 +6,18 @@ import { createFakeEventsService, } from '../events/createEventsService'; import { FakeUserUnsubscribeStore } from './fake-user-unsubscribe-store'; +import { UserSubscriptionsReadModel } from './user-subscriptions-read-model'; +import { FakeUserSubscriptionsReadModel } from './fake-user-subscriptions-read-model'; export const createUserSubscriptionsService = (config: IUnleashConfig) => (db: Db): UserSubscriptionsService => { const userUnsubscribeStore = new UserUnsubscribeStore(db); + const userSubscriptionsReadModel = new UserSubscriptionsReadModel(db); const eventService = createEventsService(db, config); const userSubscriptionsService = new UserSubscriptionsService( - { userUnsubscribeStore }, + { userUnsubscribeStore, userSubscriptionsReadModel }, config, eventService, ); @@ -26,10 +29,11 @@ export const createFakeUserSubscriptionsService = ( config: IUnleashConfig, ): UserSubscriptionsService => { const userUnsubscribeStore = new FakeUserUnsubscribeStore(); + const userSubscriptionsReadModel = new FakeUserSubscriptionsReadModel(); const eventService = createFakeEventsService(config); const userSubscriptionsService = new UserSubscriptionsService( - { userUnsubscribeStore }, + { userUnsubscribeStore, userSubscriptionsReadModel }, config, eventService, ); diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model.test.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model.test.ts new file mode 100644 index 000000000000..30ba7e430226 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model.test.ts @@ -0,0 +1,162 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { UserSubscriptionsReadModel } from './user-subscriptions-read-model'; +import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-model-type'; +import { SUBSCRIPTION_TYPES } from './user-subscriptions-read-model-type'; +import type { IUnleashStores, IUserStore } from '../../types'; +import type { IUserUnsubscribeStore } from './user-unsubscribe-store-type'; + +let db: ITestDb; +let stores: IUnleashStores; +let userStore: IUserStore; +let userUnsubscribeStore: IUserUnsubscribeStore; +let userSubscriptionsReadModel: IUserSubscriptionsReadModel; + +const subscription = + 'productivity-report' satisfies (typeof SUBSCRIPTION_TYPES)[number]; + +beforeAll(async () => { + db = await dbInit('user_subscriptions_read_model_test', getLogger); + stores = db.stores; + userStore = stores.userStore; + userUnsubscribeStore = stores.userUnsubscribeStore; + userSubscriptionsReadModel = new UserSubscriptionsReadModel(db.rawDatabase); +}); + +beforeEach(async () => { + await db.stores.userStore.deleteAll(); +}); + +afterAll(async () => { + await db.destroy(); +}); + +describe('getSubscribedUsers', () => { + test('returns users that did not unsubscribe', async () => { + const user1 = await userStore.insert({ + email: 'user1@example.com', + name: 'User One', + }); + const user2 = await userStore.insert({ + email: 'user2@example.com', + name: 'User Two', + }); + const user3 = await userStore.insert({ + email: 'user3@example.com', + name: 'User Three', + }); + + await userUnsubscribeStore.insert({ + userId: user2.id, + subscription, + }); + + const subscribers = + await userSubscriptionsReadModel.getSubscribedUsers(subscription); + + expect(subscribers).toHaveLength(2); + expect(subscribers).toEqual( + expect.arrayContaining([ + { email: 'user1@example.com', name: 'User One' }, + { email: 'user3@example.com', name: 'User Three' }, + ]), + ); + }); + + test('reflects changes after unsubscribe and resubscribe', async () => { + const user = await userStore.insert({ + email: 'user7@example.com', + name: 'User Seven', + }); + + let subscribers = + await userSubscriptionsReadModel.getSubscribedUsers(subscription); + expect(subscribers).toEqual( + expect.arrayContaining([ + { email: 'user7@example.com', name: 'User Seven' }, + ]), + ); + + await userUnsubscribeStore.insert({ + userId: user.id, + subscription, + }); + subscribers = + await userSubscriptionsReadModel.getSubscribedUsers(subscription); + expect(subscribers).not.toEqual( + expect.arrayContaining([ + { email: 'user7@example.com', name: 'User Seven' }, + ]), + ); + + await userUnsubscribeStore.delete({ + userId: user.id, + subscription, + }); + + subscribers = + await userSubscriptionsReadModel.getSubscribedUsers(subscription); + expect(subscribers).toEqual( + expect.arrayContaining([ + { email: 'user7@example.com', name: 'User Seven' }, + ]), + ); + }); + + test('should not include deleted users', async () => { + const user = await userStore.insert({ + email: 'todelete@getunleash.io', + name: 'To Delete', + }); + + await userStore.delete(user.id); + + const subscribers = + await userSubscriptionsReadModel.getSubscribedUsers(subscription); + + expect(subscribers).toHaveLength(0); + }); +}); + +describe('getUserSubscriptions', () => { + test('returns all subscriptions if user has not unsubscribed', async () => { + const user = await userStore.insert({ + email: 'user4@example.com', + name: 'User Four', + }); + + const userSubscriptions = + await userSubscriptionsReadModel.getUserSubscriptions(user.id); + + expect(userSubscriptions).toEqual(SUBSCRIPTION_TYPES); + }); + + test('returns correct subscriptions if user unsubscribed and resubscribed', async () => { + const user = await userStore.insert({ + email: 'user5@example.com', + name: 'User Five', + }); + const subscription = + 'productivity-report' satisfies (typeof SUBSCRIPTION_TYPES)[number]; + + await userUnsubscribeStore.insert({ + userId: user.id, + subscription, + }); + + const userSubscriptions = + await userSubscriptionsReadModel.getUserSubscriptions(user.id); + + expect(userSubscriptions).not.toContain(subscription); + + await userUnsubscribeStore.delete({ + userId: user.id, + subscription, + }); + + const userSubscriptionsAfterResubscribe = + await userSubscriptionsReadModel.getUserSubscriptions(user.id); + + expect(userSubscriptionsAfterResubscribe).toContain(subscription); + }); +}); diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts index a6f80363a02d..c8176391ab5e 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts @@ -1,5 +1,4 @@ import type { Db } from '../../db/db'; -import type EventEmitter from 'events'; import { SUBSCRIPTION_TYPES, type IUserSubscriptionsReadModel, @@ -26,7 +25,7 @@ const mapRowToSubscriber = (row) => export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { private db: Db; - constructor(db: Db, eventBus: EventEmitter) { + constructor(db: Db) { this.db = db; } @@ -39,6 +38,7 @@ export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { .select(USER_COLUMNS) .whereNotIn('id', unsubscribedUserIdsQuery) .andWhere('is_service', false) + .andWhere('deleted_at', null) .andWhereNot('email', null); return users.map(mapRowToSubscriber); diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts index 39e5744a2ccd..76db78344447 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts @@ -1,7 +1,8 @@ import { + type IEventStore, type IUnleashConfig, type IUnleashStores, - type IUser, + type IUserStore, TEST_AUDIT_USER, } from '../../types'; import type { UserSubscriptionsService } from './user-subscriptions-service'; @@ -13,25 +14,27 @@ import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-mode let stores: IUnleashStores; let db: ITestDb; +let userStore: IUserStore; let userSubscriptionService: UserSubscriptionsService; let userSubscriptionsReadModel: IUserSubscriptionsReadModel; +let eventsStore: IEventStore; let config: IUnleashConfig; -let user: IUser; beforeAll(async () => { db = await dbInit('user_subscriptions', getLogger); stores = db.stores; config = createTestConfig({}); + userStore = stores.userStore; userSubscriptionService = createUserSubscriptionsService(config)( db.rawDatabase, ); userSubscriptionsReadModel = db.stores.userSubscriptionsReadModel; + eventsStore = db.stores.eventStore; +}); - user = await stores.userStore.insert({ - email: 'test@getunleash.io', - name: 'Sample Name', - }); +beforeEach(async () => { + await userStore.deleteAll(); }); afterAll(async () => { @@ -39,6 +42,11 @@ afterAll(async () => { }); test('Subscribe and unsubscribe', async () => { + const user = await userStore.insert({ + email: 'test@getunleash.io', + name: 'Sample Name', + }); + const subscribers = await userSubscriptionsReadModel.getSubscribedUsers( 'productivity-report', ); @@ -47,7 +55,7 @@ test('Subscribe and unsubscribe', async () => { ]); const userSubscriptions = - await userSubscriptionsReadModel.getUserSubscriptions(user.id); + await userSubscriptionService.getUserSubscriptions(user.id); expect(userSubscriptions).toMatchObject(['productivity-report']); await userSubscriptionService.unsubscribe( @@ -62,6 +70,49 @@ test('Subscribe and unsubscribe', async () => { expect(noSubscribers).toMatchObject([]); const noUserSubscriptions = - await userSubscriptionsReadModel.getUserSubscriptions(user.id); + await userSubscriptionService.getUserSubscriptions(user.id); expect(noUserSubscriptions).toMatchObject([]); }); + +test('Event log for subscription actions', async () => { + const user = await userStore.insert({ + email: 'test@getunleash.io', + name: 'Sample Name', + }); + + await userSubscriptionService.unsubscribe( + user.id, + 'productivity-report', + TEST_AUDIT_USER, + ); + + const unsubscribeEvent = (await eventsStore.getAll())[0]; + + expect(unsubscribeEvent).toEqual( + expect.objectContaining({ + type: 'user-preference-updated', + data: { + subscription: 'productivity-report', + action: 'unsubscribed', + }, + }), + ); + + await userSubscriptionService.subscribe( + user.id, + 'productivity-report', + TEST_AUDIT_USER, + ); + + const subscribeEvent = (await eventsStore.getAll())[0]; + + expect(subscribeEvent).toEqual( + expect.objectContaining({ + type: 'user-preference-updated', + data: { + subscription: 'productivity-report', + action: 'subscribed', + }, + }), + ); +}); diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.ts index cebd781a930b..4ba7eb760cb4 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-service.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.ts @@ -1,4 +1,8 @@ -import type { IUnleashConfig, IUnleashStores } from '../../types'; +import { + UserPreferenceUpdatedEvent, + type IUnleashConfig, + type IUnleashStores, +} from '../../types'; import type { Logger } from '../../logger'; import type { IAuditUser } from '../../types/user'; import type { @@ -6,24 +10,38 @@ import type { UnsubscribeEntry, } from './user-unsubscribe-store-type'; import type EventService from '../events/event-service'; +import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-model-type'; export class UserSubscriptionsService { private userUnsubscribeStore: IUserUnsubscribeStore; + private userSubscriptionsReadModel: IUserSubscriptionsReadModel; + private eventService: EventService; private logger: Logger; constructor( - { userUnsubscribeStore }: Pick, + { + userUnsubscribeStore, + userSubscriptionsReadModel, + }: Pick< + IUnleashStores, + 'userUnsubscribeStore' | 'userSubscriptionsReadModel' + >, { getLogger }: Pick, eventService: EventService, ) { this.userUnsubscribeStore = userUnsubscribeStore; + this.userSubscriptionsReadModel = userSubscriptionsReadModel; this.eventService = eventService; this.logger = getLogger('services/user-subscription-service.ts'); } + async getUserSubscriptions(userId: number) { + return this.userSubscriptionsReadModel.getUserSubscriptions(userId); + } + async subscribe( userId: number, subscription: string, @@ -35,13 +53,13 @@ export class UserSubscriptionsService { }; await this.userUnsubscribeStore.delete(entry); - // TODO: log an event - // await this.eventService.storeEvent( - // new UserSubscriptionEvent({ - // data: { ...entry, action: 'subscribed' }, - // auditUser, - // }), - // ); + await this.eventService.storeEvent( + new UserPreferenceUpdatedEvent({ + userId, + data: { subscription, action: 'subscribed' }, + auditUser, + }), + ); } async unsubscribe( @@ -55,12 +73,12 @@ export class UserSubscriptionsService { }; await this.userUnsubscribeStore.insert(entry); - // TODO: log an event - // await this.eventService.storeEvent( - // new UserSubscriptionEvent({ - // data: { ...entry, action: 'unsubscribed' }, - // auditUser, - // }), - // ); + await this.eventService.storeEvent( + new UserPreferenceUpdatedEvent({ + userId, + data: { subscription, action: 'unsubscribed' }, + auditUser, + }), + ); } } diff --git a/src/lib/openapi/spec/profile-schema.test.ts b/src/lib/openapi/spec/profile-schema.test.ts index de7d17bcc871..e168e8eb91d0 100644 --- a/src/lib/openapi/spec/profile-schema.test.ts +++ b/src/lib/openapi/spec/profile-schema.test.ts @@ -9,6 +9,7 @@ test('profileSchema', () => { name: 'Admin', }, projects: ['default', 'secretproject'], + subscriptions: ['productivity-report'], features: [ { name: 'firstFeature', project: 'default' }, { name: 'secondFeature', project: 'secretproject' }, diff --git a/src/lib/openapi/spec/profile-schema.ts b/src/lib/openapi/spec/profile-schema.ts index 08a5a3e41a1b..92ebba40b6d2 100644 --- a/src/lib/openapi/spec/profile-schema.ts +++ b/src/lib/openapi/spec/profile-schema.ts @@ -7,7 +7,7 @@ export const profileSchema = { type: 'object', additionalProperties: false, description: 'User profile overview', - required: ['rootRole', 'projects', 'features'], + required: ['rootRole', 'projects', 'features', 'subscriptions'], properties: { rootRole: { $ref: '#/components/schemas/roleSchema', @@ -20,6 +20,14 @@ export const profileSchema = { }, example: ['my-projectA', 'my-projectB'], }, + subscriptions: { + description: 'Which email subscriptions this user is subscribed to', + type: 'array', + items: { + type: 'string', + }, + example: ['productivity-report'], + }, features: { description: 'Deprecated, always returns empty array', type: 'array', diff --git a/src/lib/openapi/spec/project-status-schema.test.ts b/src/lib/openapi/spec/project-status-schema.test.ts index 23a3ad34ed02..f00b6cea5287 100644 --- a/src/lib/openapi/spec/project-status-schema.test.ts +++ b/src/lib/openapi/spec/project-status-schema.test.ts @@ -3,11 +3,17 @@ import type { ProjectStatusSchema } from './project-status-schema'; test('projectStatusSchema', () => { const data: ProjectStatusSchema = { + averageHealth: 50, activityCountByDate: [ { date: '2022-12-14', count: 2 }, { date: '2022-12-15', count: 5 }, ], - resources: { connectedEnvironments: 2 }, + resources: { + connectedEnvironments: 2, + apiTokens: 2, + members: 1, + segments: 0, + }, }; expect( diff --git a/src/lib/openapi/spec/project-status-schema.ts b/src/lib/openapi/spec/project-status-schema.ts index 8683b6ae5e65..1efdd3625984 100644 --- a/src/lib/openapi/spec/project-status-schema.ts +++ b/src/lib/openapi/spec/project-status-schema.ts @@ -5,7 +5,7 @@ export const projectStatusSchema = { $id: '#/components/schemas/projectStatusSchema', type: 'object', additionalProperties: false, - required: ['activityCountByDate', 'resources'], + required: ['activityCountByDate', 'resources', 'averageHealth'], description: 'Schema representing the overall status of a project, including an array of activity records. Each record in the activity array contains a date and a count, providing a snapshot of the project’s activity level over time.', properties: { @@ -14,17 +14,47 @@ export const projectStatusSchema = { description: 'Array of activity records with date and count, representing the project’s daily activity statistics.', }, + averageHealth: { + type: 'integer', + minimum: 0, + description: + 'The average health score over the last 4 weeks, indicating whether features are stale or active.', + }, resources: { type: 'object', additionalProperties: false, - required: ['connectedEnvironments'], + required: [ + 'connectedEnvironments', + 'apiTokens', + 'members', + 'segments', + ], description: 'Key resources within the project', properties: { connectedEnvironments: { - type: 'number', + type: 'integer', + minimum: 0, description: 'The number of environments that have received SDK traffic in this project.', }, + apiTokens: { + type: 'integer', + minimum: 0, + description: + 'The number of API tokens created specifically for this project.', + }, + members: { + type: 'integer', + minimum: 0, + description: + 'The number of users who have been granted roles in this project. Does not include users who have access via groups.', + }, + segments: { + type: 'integer', + minimum: 0, + description: + 'The number of segments that are scoped to this project.', + }, }, }, }, diff --git a/src/lib/routes/admin-api/user/user.test.ts b/src/lib/routes/admin-api/user/user.test.ts index d92fac9113fe..5e759e8e0f8c 100644 --- a/src/lib/routes/admin-api/user/user.test.ts +++ b/src/lib/routes/admin-api/user/user.test.ts @@ -53,6 +53,24 @@ test('should return current user', async () => { }); const owaspPassword = 't7GTx&$Y9pcsnxRv6'; +test('should return current profile', async () => { + expect.assertions(1); + const { request, base } = await getSetup(); + + return request + .get(`${base}/api/admin/user/profile`) + .expect(200) + .expect('Content-Type', /json/) + .expect((res) => { + expect(res.body).toMatchObject({ + projects: [], + rootRole: { id: -1, name: 'Viewer', type: 'root' }, + subscriptions: ['productivity-report'], + features: [], + }); + }); +}); + test('should allow user to change password', async () => { const { request, base, userStore } = await getSetup(); await request diff --git a/src/lib/routes/admin-api/user/user.ts b/src/lib/routes/admin-api/user/user.ts index f6c2d1b6d8f9..af68fe2133f0 100644 --- a/src/lib/routes/admin-api/user/user.ts +++ b/src/lib/routes/admin-api/user/user.ts @@ -32,6 +32,7 @@ import { type RolesSchema, } from '../../../openapi/spec/roles-schema'; import type { IFlagResolver } from '../../../types'; +import type { UserSubscriptionsService } from '../../../features/user-subscriptions/user-subscriptions-service'; class UserController extends Controller { private accessService: AccessService; @@ -48,6 +49,8 @@ class UserController extends Controller { private flagResolver: IFlagResolver; + private userSubscriptionsService: UserSubscriptionsService; + constructor( config: IUnleashConfig, { @@ -57,6 +60,7 @@ class UserController extends Controller { userSplashService, openApiService, projectService, + transactionalUserSubscriptionsService, }: Pick< IUnleashServices, | 'accessService' @@ -65,6 +69,7 @@ class UserController extends Controller { | 'userSplashService' | 'openApiService' | 'projectService' + | 'transactionalUserSubscriptionsService' >, ) { super(config); @@ -74,6 +79,7 @@ class UserController extends Controller { this.userSplashService = userSplashService; this.openApiService = openApiService; this.projectService = projectService; + this.userSubscriptionsService = transactionalUserSubscriptionsService; this.flagResolver = config.flagResolver; this.route({ @@ -237,12 +243,16 @@ class UserController extends Controller { ): Promise { const { user } = req; - const projects = await this.projectService.getProjectsByUser(user.id); + const [projects, rootRole, subscriptions] = await Promise.all([ + this.projectService.getProjectsByUser(user.id), + this.accessService.getRootRoleForUser(user.id), + this.userSubscriptionsService.getUserSubscriptions(user.id), + ]); - const rootRole = await this.accessService.getRootRoleForUser(user.id); const responseData: ProfileSchema = { projects, rootRole, + subscriptions, features: [], }; diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index d71c28df5f91..3ddee00068c2 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -204,6 +204,14 @@ export const ACTIONS_CREATED = 'actions-created' as const; export const ACTIONS_UPDATED = 'actions-updated' as const; export const ACTIONS_DELETED = 'actions-deleted' as const; +export const RELEASE_PLAN_TEMPLATE_CREATED = + 'release-plan-template-created' as const; +export const RELEASE_PLAN_TEMPLATE_UPDATED = + 'release-plan-template-updated' as const; +export const RELEASE_PLAN_TEMPLATE_DELETED = + 'release-plan-template-deleted' as const; +export const USER_PREFERENCE_UPDATED = 'user-preference-updated' as const; + export const IEventTypes = [ APPLICATION_CREATED, FEATURE_CREATED, @@ -351,6 +359,10 @@ export const IEventTypes = [ ACTIONS_CREATED, ACTIONS_UPDATED, ACTIONS_DELETED, + RELEASE_PLAN_TEMPLATE_CREATED, + RELEASE_PLAN_TEMPLATE_UPDATED, + RELEASE_PLAN_TEMPLATE_DELETED, + USER_PREFERENCE_UPDATED, ] as const; export type IEventType = (typeof IEventTypes)[number]; @@ -2009,6 +2021,42 @@ export class GroupDeletedEvent extends BaseEvent { } } +export class ReleasePlanTemplateCreatedEvent extends BaseEvent { + readonly data: any; + constructor(eventData: { + data: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_CREATED, eventData.auditUser); + this.data = eventData.data; + } +} + +export class ReleasePlanTemplateUpdatedEvent extends BaseEvent { + readonly preData: any; + readonly data: any; + constructor(eventData: { + data: any; + preData: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_UPDATED, eventData.auditUser); + this.data = eventData.data; + this.preData = eventData.preData; + } +} + +export class ReleasePlanTemplateDeletedEvent extends BaseEvent { + readonly preData: any; + constructor(eventData: { + preData: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_DELETED, eventData.auditUser); + this.preData = eventData.preData; + } +} + interface IUserEventData extends Pick< IUserWithRootRole, @@ -2024,3 +2072,18 @@ function mapUserToData(user: IUserEventData): any { rootRole: user.rootRole, }; } + +export class UserPreferenceUpdatedEvent extends BaseEvent { + readonly userId; + readonly data: any; + + constructor(eventData: { + userId: number; + data: any; + auditUser: IAuditUser; + }) { + super(USER_PREFERENCE_UPDATED, eventData.auditUser); + this.userId = eventData.userId; + this.data = eventData.data; + } +} diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 222897aef9b0..08715b299386 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -28,8 +28,6 @@ export type IFlagKey = | 'signals' | 'automatedActions' | 'celebrateUnleash' - | 'featureSearchFeedback' - | 'featureSearchFeedbackPosting' | 'extendedUsageMetrics' | 'adminTokenKillSwitch' | 'feedbackComments' @@ -44,7 +42,6 @@ export type IFlagKey = | 'outdatedSdksBanner' | 'responseTimeMetricsFix' | 'disableShowContextFieldSelectionValues' - | 'projectOverviewRefactorFeedback' | 'manyStrategiesPagination' | 'enableLegacyVariants' | 'extendedMetrics' @@ -60,7 +57,8 @@ export type IFlagKey = | 'releasePlans' | 'productivityReportEmail' | 'enterprise-payg' - | 'simplifyProjectOverview'; + | 'simplifyProjectOverview' + | 'flagOverviewRedesign'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -146,23 +144,6 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_CELEBRATE_UNLEASH, false, ), - featureSearchFeedback: { - name: 'withText', - enabled: parseEnvVarBoolean( - process.env.UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_FEEDBACK, - false, - ), - payload: { - type: PayloadType.JSON, - value: - process.env - .UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_FEEDBACK_PAYLOAD ?? '', - }, - }, - featureSearchFeedbackPosting: parseEnvVarBoolean( - process.env.UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_FEEDBACK_POSTING, - false, - ), encryptEmails: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_ENCRYPT_EMAILS, false, @@ -233,10 +214,6 @@ const flags: IFlags = { .UNLEASH_EXPERIMENTAL_DISABLE_SHOW_CONTEXT_FIELD_SELECTION_VALUES, false, ), - projectOverviewRefactorFeedback: parseEnvVarBoolean( - process.env.UNLEASH_EXPERIMENTAL_PROJECT_OVERVIEW_REFACTOR_FEEDBACK, - false, - ), manyStrategiesPagination: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_MANY_STRATEGIES_PAGINATION, false, @@ -301,6 +278,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW, false, ), + flagOverviewRedesign: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 0379a0f78017..b4ab43c17eab 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -164,4 +164,5 @@ export { type IntegrationEventsStore, type IProjectReadModel, IOnboardingStore, + type IUserSubscriptionsReadModel, }; diff --git a/src/lib/types/stores/api-token-store.ts b/src/lib/types/stores/api-token-store.ts index 08fd584e2d0f..9da92337b6d4 100644 --- a/src/lib/types/stores/api-token-store.ts +++ b/src/lib/types/stores/api-token-store.ts @@ -14,4 +14,5 @@ export interface IApiTokenStore extends Store { legacyTokens: number; activeLegacyTokens: number; }>; + countProjectTokens(projectId: string): Promise; } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index aada71d1dea3..3cd13e255bfe 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -47,5 +47,7 @@ export interface IEventStore queryCount(operations: IQueryOperations[]): Promise; setCreatedByUserId(batchSize: number): Promise; getEventCreators(): Promise>; - getProjectEventActivity(project: string): Promise; + getProjectRecentEventActivity( + project: string, + ): Promise; } diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 2ca1f39a1e32..e3fe1aea44a2 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -38,5 +38,6 @@ export interface IUserStore extends Store { incLoginAttempts(user: IUser): Promise; successfullyLogin(user: IUser): Promise; count(): Promise; + countRecentlyDeleted(): Promise; countServiceAccounts(): Promise; } diff --git a/src/mailtemplates/productivity-report/productivity-report.html.mustache b/src/mailtemplates/productivity-report/productivity-report.html.mustache index e3ce0a50f086..b7d2229265f0 100644 --- a/src/mailtemplates/productivity-report/productivity-report.html.mustache +++ b/src/mailtemplates/productivity-report/productivity-report.html.mustache @@ -4,6 +4,8 @@ Your Unleash Productivity Report + + @@ -11,7 +13,7 @@ style="max-width: 600px;margin: 24px auto;background-color: #ffffff;border-radius: 8px;border: 1px solid #f0f0f5;overflow: hidden;">
Unleash -
Your Monthly Productivity Report
+
Your Monthly Productivity Report
@@ -55,7 +57,7 @@ diff --git a/src/mailtemplates/productivity-report/productivity-report.plain.mustache b/src/mailtemplates/productivity-report/productivity-report.plain.mustache index 4c23236ce4b3..00c584100ee4 100644 --- a/src/mailtemplates/productivity-report/productivity-report.plain.mustache +++ b/src/mailtemplates/productivity-report/productivity-report.plain.mustache @@ -14,4 +14,4 @@ Production updates last month: {{productionUpdates}} Go to your Insights to learn more: {{unleashUrl}}/insights This email was sent to {{userEmail}}. You’ve received this as you are a user of Unleash. -If you wish to unsubscribe, go to you profile settings here. +If you wish to unsubscribe, go to you profile settings on {{unleashUrl}}/profile diff --git a/src/migrations/20241105123918-add-cascade-for-user-unsubscription.js b/src/migrations/20241105123918-add-cascade-for-user-unsubscription.js new file mode 100644 index 000000000000..7d8b18e9aa9c --- /dev/null +++ b/src/migrations/20241105123918-add-cascade-for-user-unsubscription.js @@ -0,0 +1,16 @@ +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE user_unsubscription DROP CONSTRAINT user_unsubscription_user_id_fkey; + ALTER TABLE user_unsubscription + ADD CONSTRAINT user_unsubscription_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE; +`, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql('', cb); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 81a95d9f16c9..b3b21d479943 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -41,11 +41,9 @@ process.nextTick(async () => { anonymiseEventLog: false, responseTimeWithAppNameKillSwitch: false, celebrateUnleash: true, - featureSearchFeedbackPosting: true, userAccessUIEnabled: true, outdatedSdksBanner: true, disableShowContextFieldSelectionValues: false, - projectOverviewRefactorFeedback: true, manyStrategiesPagination: true, enableLegacyVariants: false, extendedMetrics: true, @@ -57,6 +55,7 @@ process.nextTick(async () => { webhookDomainLogging: true, releasePlans: false, simplifyProjectOverview: true, + flagOverviewRedesign: true, }, }, authentication: { diff --git a/src/test/e2e/stores/api-token-store.e2e.test.ts b/src/test/e2e/stores/api-token-store.e2e.test.ts index c7edd1ef7524..dbda4bb080d8 100644 --- a/src/test/e2e/stores/api-token-store.e2e.test.ts +++ b/src/test/e2e/stores/api-token-store.e2e.test.ts @@ -2,6 +2,7 @@ import dbInit, { type ITestDb } from '../helpers/database-init'; import getLogger from '../../fixtures/no-logger'; import type { IUnleashStores } from '../../../lib/types'; import { ApiTokenType } from '../../../lib/types/models/api-token'; +import { randomId } from '../../../lib/util'; let stores: IUnleashStores; let db: ITestDb; @@ -182,3 +183,46 @@ describe('count deprecated tokens', () => { }); }); }); + +describe('count project tokens', () => { + test('counts only tokens belonging to the specified project', async () => { + const project = await stores.projectStore.create({ + id: randomId(), + name: 'project A', + }); + + const store = stores.apiTokenStore; + await store.insert({ + secret: `default:default.${randomId()}`, + environment: 'default', + type: ApiTokenType.CLIENT, + projects: ['default'], + tokenName: 'token1', + }); + await store.insert({ + secret: `*:*.${randomId()}`, + environment: 'default', + type: ApiTokenType.CLIENT, + projects: ['*'], + tokenName: 'token2', + }); + + await store.insert({ + secret: `${project.id}:default.${randomId()}`, + environment: 'default', + type: ApiTokenType.CLIENT, + projects: [project.id], + tokenName: 'token3', + }); + + await store.insert({ + secret: `[]:default.${randomId()}`, + environment: 'default', + type: ApiTokenType.CLIENT, + projects: [project.id, 'default'], + tokenName: 'token4', + }); + + expect(await store.countProjectTokens(project.id)).toBe(2); + }); +}); diff --git a/src/test/e2e/stores/user-store.e2e.test.ts b/src/test/e2e/stores/user-store.e2e.test.ts index 93f587fa9713..230699e41fa9 100644 --- a/src/test/e2e/stores/user-store.e2e.test.ts +++ b/src/test/e2e/stores/user-store.e2e.test.ts @@ -192,4 +192,7 @@ test('should delete user', async () => { await expect(() => stores.userStore.get(user.id)).rejects.toThrow( new NotFoundError('No user found'), ); + + const deletedCount = await stores.userStore.countRecentlyDeleted(); + expect(deletedCount).toBe(1); }); diff --git a/src/test/fixtures/fake-api-token-store.ts b/src/test/fixtures/fake-api-token-store.ts index 6b90064fceb6..d24f920e83e8 100644 --- a/src/test/fixtures/fake-api-token-store.ts +++ b/src/test/fixtures/fake-api-token-store.ts @@ -92,4 +92,8 @@ export default class FakeApiTokenStore activeLegacyTokens: 0, }; } + + async countProjectTokens(): Promise { + return 0; + } } diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index ea4b63438bf4..db17fa148666 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -18,7 +18,9 @@ class FakeEventStore implements IEventStore { this.events = []; } - getProjectEventActivity(project: string): Promise { + getProjectRecentEventActivity( + project: string, + ): Promise { throw new Error('Method not implemented.'); } diff --git a/src/test/fixtures/fake-segment-store.ts b/src/test/fixtures/fake-segment-store.ts index 96241181a31c..53f8a3937930 100644 --- a/src/test/fixtures/fake-segment-store.ts +++ b/src/test/fixtures/fake-segment-store.ts @@ -62,4 +62,8 @@ export default class FakeSegmentStore implements ISegmentStore { } destroy(): void {} + + async getProjectSegmentCount(): Promise { + return 0; + } } diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index a201d70c4029..f37b6c67d363 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -67,6 +67,10 @@ class UserStoreMock implements IUserStore { return this.data.length; } + async countRecentlyDeleted(): Promise { + return Promise.resolve(0); + } + async get(key: number): Promise { return this.data.find((u) => u.id === key)!; } diff --git a/website/docs/availability.md b/website/docs/availability.md new file mode 100644 index 000000000000..a394e86acaab --- /dev/null +++ b/website/docs/availability.md @@ -0,0 +1,25 @@ +--- +title: Unleash Availability +--- + +Your Unleash [plan](#plans) and [version](#versioning) determine what features you have access to. Our documentation displays the availability for each feature using the following annotation: + +:::note Availability + +**Plan**: [Enterprise](https://www.getunleash.io/pricing) | **Version**: `5.7+` + +::: + +This is an example of a feature that is only available to Enterprise customers who are on version `5.7` or later. + +## Plans + +- [Open Source](https://www.getunleash.io/pricing) - Available on [GitHub](https://github.com/Unleash/unleash) under an Apache 2.0 license. +- Pro - Currently not offered. +- [Enterprise](https://www.getunleash.io/pricing) - Available as Pay-as-you-go or as an annual contract. + +## Versioning + +Unleash uses [semantic versioning](https://semver.org/) with release notes available on [GitHub](https://github.com/Unleash/unleash/releases). For detailed instructions on upgrading your version, see [Upgrading Unleash](../using-unleash/deploy/upgrading-unleash). + +[Unleash Edge](https://github.com/Unleash/unleash-edge) and our [SDKs](/reference/sdks) are versioned and released independently of Unleash. We recommend upgrading your SDKs and Unleash Edge to the latest versions to ensure compatibility, optimal performance, and access to the latest features and security updates. diff --git a/website/docs/feature-flag-tutorials/flutter/a-b-testing.md b/website/docs/feature-flag-tutorials/flutter/a-b-testing.md index 67fb051ee695..be633549b884 100644 --- a/website/docs/feature-flag-tutorials/flutter/a-b-testing.md +++ b/website/docs/feature-flag-tutorials/flutter/a-b-testing.md @@ -11,7 +11,7 @@ This article is a contribution by **[Ayush Bherwani](https://www.linkedin.com/in After successfully integrating the first feature flag in the Unsplash sample app, let’s talk about how you can use Unleash to perform experimentation, also known as A/B testing, in Flutter to ship features more confidently. -For this article, we’ll integrate feature flags for A/B testing to experiment with “like image” feature user experience. As an overview, the app is quite simple, with two screens displaying images and image details respectively. The behavior of the “image details” feature is controlled through an Unleash instance. You can check out the previous article, “[How to set up feature flags in Flutter](https://www.getunleash.io/blog/from-the-community-how-to-set-up-feature-flags-in-flutter)” for an overview of the code structure and implementation. For those who want to skip straight to the code, you can find it on [GitHub](https://github.com/AyushBherwani1998/unsplash_sample/). +For this article, we’ll integrate feature flags for A/B testing to experiment with “like image” feature user experience. As an overview, the app is quite simple, with two screens displaying images and image details respectively. The behavior of the “image details” feature is controlled through an Unleash instance. For those who want to skip straight to the code, you can find it on [GitHub](https://github.com/AyushBherwani1998/unsplash_sample/). Here’s a screenshot of the application: ![Unsplash App built on Flutter](/img/unsplash-demo-flutter.png) @@ -22,7 +22,7 @@ In your Unleash instance, create a new feature flag called `likeOptionExperiment ![Set Up Variant in Unleash](/img/variant-setup-1.png) -Now that you have created your feature flag, let’s create two new [variants](https://docs.getunleash.io/reference/feature-toggle-variants) “gridTile'' and “imageDetails” respectively. These variants will help you position your “like image” button. +Now that you have created your feature flag, let’s create two new [variants](https://docs.getunleash.io/reference/feature-toggle-variants) `gridTile` and `imageDetails` respectively. These variants will help you position your **like image** button. ![Succesfully setting up variant in Unleash](/img/setup-variant-2.png) @@ -34,7 +34,7 @@ Below is a screenshot of experimentation in action based on the `likeOptionExper For analytics and metrics, we’ll use [Mixpanel](https://mixpanel.com/) to track user behavior and usage patterns. We have chosen Mixpanel because it offers a user-friendly setup and in-depth user analytics and segmentation. Given that the project follows clean architecture and Test-Driven Development (TDD) principles, you’ll want to create an abstract layer to interact with the Mixpanel. -Whenever a user opens the app, we track `like-variant` if `likeOptionExperiment` is enabled to tag them with their assigned variant (gridTile or imageDetails). The stored variant in Mixpanel can be used later to analyze how each variant impacts user behavior to like an image. +Whenever a user opens the app, we track `like-variant` if `likeOptionExperiment` is enabled to tag them with their assigned variant (`gridTile` or `imageDetails`). The stored variant in Mixpanel can be used later to analyze how each variant impacts user behavior to like an image. Whenever a user interacts with the `LikeButton`, we track `trackLikeEventForExperimentation`, along with their assigned variants. By correlating the `trackLikeEventForExperimentation` with the `like-variant`, you can effectively measure the impact of a variant on user behavior and make data-driven decisions. To learn how to correlate and generate reports, see the [Mixpanel docs](https://docs.mixpanel.com/docs/analysis/reports). diff --git a/website/docs/feature-flag-tutorials/use-cases/a-b-testing.md b/website/docs/feature-flag-tutorials/use-cases/a-b-testing.md index 791a2cc9aaf6..082fb956d023 100644 --- a/website/docs/feature-flag-tutorials/use-cases/a-b-testing.md +++ b/website/docs/feature-flag-tutorials/use-cases/a-b-testing.md @@ -1,112 +1,235 @@ --- -title: How to do A/B Testing +title: How to do A/B Testing using Feature Flags slug: /feature-flag-tutorials/use-cases/a-b-testing --- -## What is A/B Testing? +Feature flags are a great way to run A/B or multivariate tests with minimal code modifications, and Unleash offers built-in features that make it easy to get started. In this tutorial, we will walk through how to do an A/B test using Unleash with your application. -**A/B testing** is a randomized controlled experiment where you test two or more versions of a feature to see which version performs better. If you have more than two versions, it's known as multivariate testing. Coupled with analytics, A/B and multivariate testing enable you to better understand your users and how to serve them better. +## How to Perform A/B Testing with Feature Flags -Feature flags are a great way to run A/B tests to decouple them from your code, and Unleash ships with features to make it easy to get started with. In this tutorial, we will walk through how to do an A/B test using Unleash with your application. +To follow along with this tutorial, you need access to an Unleash instance to create and manage feature flags. Head over to our [Quick Start documentation](/quickstart) for options, including running locally or using an [Unleash SaaS instance](https://www.getunleash.io/pricing?). -## How to Perform A/B Testing with Unleash +With Unleash set up, you can use your application to talk to Unleash through one of our [SDKs](/reference/sdks). -To follow along with this tutorial, you will need an Unleash instance. If you’d prefer to self-host Unleash, read our [Quickstart guide](/quickstart). Alternatively, if you’d like your project to be hosted by Unleash, go to [www.getunleash.io](https://www.getunleash.io/pricing?_gl=1*1ytmg93*_gcl_au*MTY3MTQxNjM4OS4xNzIxOTEwNTY5*_ga*OTkzMjI0MDMwLjE3MDYxNDc3ODM.*_ga_492KEZQRT8*MTcyNzQzNTQwOS4yMzcuMS4xNzI3NDM1NDExLjU4LjAuMA). +In this tutorial, you will learn how to set up and run an A/B test using feature flags. You will learn: -With Unleash set up, you can use your application to talk to Unleash through one of our SDKs. +1. [How to use feature flags to define variants of your application for testing](#create-a-feature-flag) +2. [Target specific users for each test variant](#target-users-for-ab-testing) +3. [Manage cross-session visibility of test variants](#manage-user-session-behavior) +4. [Connect feature flag impression data to conversion outcomes](#track-ab-testing-for-your-key-performance-metrics) +5. [Roll out the winning variant to all users](#rollout-the-winning-variant-to-all-users) -To conduct an A/B test, we will need to create the feature flag that will implement an activation strategy. In the next section, we will explore what strategies are and how they are configured in Unleash. +You will also learn about how to [automate advanced A/B testing strategies](#multi-arm-bandit-tests-to-find-the-winning-variant) such as multi-arm bandit testing using feature flags. -In the projects view, the Unleash platform shows a list of feature flags that you’ve generated. Click on the ‘New Feature Flag' button to create a new feature flag. +### Create a Feature Flag -![Create a new feature flag in Unleash.](/img/react-tutorial-create-new-flag.png) +To do A/B testing, we'll create a feature flag to implement the rollout strategy. After that, we'll explore what strategies are and how they are configured in Unleash. -Next, you will create a feature flag on the platform and turn it on for your app. +In the Unleash Admin UI, open a project and click **New feature flag**. -Flags can be used with different purposes and we consider experimentation important enough to have its own flag type. Experimentation flags have a lifetime expectancy suited to let you run an experiment and gather enough data to know whether it was a success or not. Learn more about [feature flag types](/reference/feature-toggles#feature-flag-types) in our documentation. +![Create a new feature flag in the Unleash Admin UI.](/img/use-case-new-flag.png) -The feature flag we are creating is considered an ‘Experimentation’ flag type. The project will be ‘Default’ or the named project in which you are working in for the purpose of this tutorial. As the number of feature flags grows, you can organize them in your projects. +Next, you will create a feature flag and turn it on. -Read our docs on [Projects](/reference/projects) to learn more about how to configure and manage them for your team/organization. A description of the flag can help properly identify its specific purposes. However, this field is optional. +Feature flags can be used for different purposes and we consider experimentation important enough to have its own flag type. Experimentation flags have a lifetime expectancy suited for running an experiment and gathering enough data to know whether the experiment was a success or not. The feature flag we are creating is considered an Experiment flag type. -![Create a feature flag by filling out the form fields.](/img/react-tutorial-create-flag-form.png) +![Create a feature flag by filling out the form fields.](/img/use-case-create-experiment-flag.png) -Once you have completed the form, you can click ‘Create feature flag’. +Once you have completed the form, click **Create feature flag**. -Your new feature flag has been created and is ready to be used. Upon returning to your projects view, enable the flag for your development environment, which makes it accessible to use in your app. +Your new feature flag is now ready to be used. Next, we will configure the A/B testing strategy for your flag. -![Enable the development environment for your feature flag for use in your application.](/img/tutorial-enable-dev-env.png) +### Target Users for A/B Testing -Next, we will configure the A/B testing strategy for your new flag. +With an A/B testing strategy, you’ll be able to: -### Implementing a Default Activation Strategy for A/B Testing +- Determine the percentage of users exposed to the new feature +- Determine the percentage of users that get exposed to each version of the feature -An important Unleash concept that enables developers to perform an A/B test is an [activation strategy](/reference/activation-strategies). An activation strategy defines who will be exposed to a particular flag or flags. Unleash comes pre-configured with multiple activation strategies that let you enable a feature only for a specified audience, depending on the parameters under which you would like to release a feature. +To target users accordingly, let's create an [activation strategy](/reference/activation-strategies). This Unleash concept defines who will be exposed to a particular flag. Unleash comes pre-configured with multiple activation strategies that let you enable a feature only for a specified audience, depending on the parameters under which you would like to release a feature. ![Anatomy of an activation strategy](/img/anatomy-of-unleash-strategy.png) -Different strategies use different parameters. Predefined strategies are bundled with Unleash. The default strategy is the gradual rollout strategy with 100% rollout, which basically means that the feature is enabled for all users. In this case, we have only enabled the flag in the development environment for all users in the previous section. +Different strategies use different parameters. Predefined strategies are bundled with Unleash. The default strategy is a gradual rollout to 100%, which means that the feature is enabled for all users. In this tutorial, we'll adjust the percentage of users who have access to the feature. +:::note Activation strategies are defined on the server. For server-side SDKs, activation strategy implementation is done on the client side. For frontend SDKs, the feature is calculated on the server side. +::: -There are two more advanced extensions of a default strategy that you will see available to customize in the form: +Open your feature flag and click **Add strategy**. + +![Add your first strategy from the flag view in Unleash.](/img/use-case-experiment-add-strategy.png) + +The gradual rollout strategy form has multiple fields that control the rollout of your feature. You can name the strategy something relevant to the A/B test you’re creating, but this is an optional field. -- [Strategy Variants](/reference/strategy-variants) -- [Strategy Constraints](/reference/strategy-constraints) +![In the gradual rollout form, you can configure the parameters of your A/B tests and releases.](/img/use-case-experiment-gradual-rollout.png) -Variants and constraints are not required for A/B testing. These additional customizations can be built on top of the overall strategy should you need more granular conditions for your feature beyond the rollout percentage. +Next, configure the rollout percentage so only a certain portion of your users are targeted. For example, you can adjust the dial so that 35% of all users are targeted. The remaining percentage of users will not experience any variation of the new feature. Adjust the rollout dial to set the percentage of users the feature targets, or keep it at 100% to target all users. + +There are two more advanced extensions of a default strategy that you will see available to customize in the form: -[Strategy variants](/reference/strategy-variants) can expose a particular version of a feature to select user bases when a flag is enabled. From there, a way to use the variants is to view the performance metrics and see which is more efficient. We can create several variations of this feature to release to users and gather performance metrics to determine which one yields better results. +- [Strategy variants](/reference/strategy-variants) +- [Strategy constraints](/reference/strategy-constraints) -For A/B testing, _strategy variants_ are most applicable for more granular conditions of a feature release. In the next section, we’ll explore how to apply a strategy variant on top of an A/B test for more advanced use cases. +With strategy variants and constraints, you can extend your overall strategy. They help you define more granular conditions for your feature beyond the rollout percentage. We recommend using strategy variants to configure an A/B test. -### Applying Strategy Variants +[Strategy variants](/reference/strategy-variants) let you expose a particular version of a feature to select user bases when a flag is enabled. You can then collect data to determine which variant performs better, which we'll cover later in this tutorial. -Using strategy variants in your activation strategy is the canonical way to run A/B tests with Unleash and your application. You can expose a particular version of the feature to select user bases when a flag is enabled. From there, a way to use the variants is to view the performance metrics and see which is more efficient. +Using strategy variants in your activation strategy is the canonical way to run A/B tests with Unleash and your application. + +![This diagram breaks down how strategy variants sit on top activation strategies for flags in Unleash.](/img/tutorial-building-blocks-strategy-variants.png) A variant has four components that define it: -- **name**: This must be unique among the strategy's variants. When working with a feature with variants in a client, you will typically use the variant's name to find out which variant it is. -- **weight**: The weight is the likelihood of any one user getting this specific variant. See the weights section for more info. -- **value** -- **(optional) payload**: A variant can also have an associated payload. Use this to deliver more data or context. See the payload section for more details. +- a name: This must be unique among the strategy's variants. You typically use the name to identify the variant in your client. +- a weight: The [variant weight](/reference/strategy-variants#variant-weight) is the likelihood of any one user getting this specific variant. +- an optional payload: A variant can also have an associated [payload](/reference/strategy-variants#variant-payload) to deliver more data or context. The type defines the data format of the payload and can be one of the following options: `string`, `json`, `csv`, or `number`. +- a value: specifies the payload data associated with the variant. Define this if you want to return a value other than `enabled`/`disabled`. It must correspond with the payload type. -While teams may have different goals for measuring performance, Unleash enables you to configure a strategy for the feature variants within your application/service and the platform. +Open the gradual rollout strategy, select the **Variants** tab, and click **Add variant**. Enter a unique name for the variant. For the purpose of this tutorial, we’ve created 2 variants: `variantA` and `variantB`. In a real-world use case, we recommend more specific names to be comprehensible and relevant to the versions of the feature you’re referencing. Create additional variants if you need to test more versions. -## A/B Testing with Enterprise Security Automation +Next, decide the percentage of users to target for each variant, known as the variant weight. By default, 50% of users will be targeted between 2 variants. For example, 50% of users within the 35% of users targeted from the rollout percentage you defined earlier would experience `variantA`. Toggle **Custom percentage** to change the default variant weights. -For large-scale organizations, managing feature flags across many teams can be complex and challenging. Unleash was architected for your feature flag management to be scalable and traceable for enterprises, which boosts overall internal security posture while delivering software efficiently. +![You can configure multiple strategy variants for A/B testing within the gradual rollout form.](/img/use-case-experiment-variants.png) -After you have implemented an A/B test, we recommend managing it by: +### Manage User Session Behavior -- Tracking performance of feature releases within your application -- Reviewing audit logs of each change to your flag configurations over time by project collaborators within your organization, which is exportable for reporting -- Reviewing and approving change requests to your flags and strategy configurations +Unleash is built to give developers confidence in their ability to run A/B tests effectively. One critical component of implementing A/B testing strategies is maintaining a consistent experience for each user across multiple user sessions. -Read our documentation on how to effectively manage [feature flags at scale](/topics/feature-flags/best-practices-using-feature-flags-at-scale) while reducing security risks. Let’s walk through these recommended Unleash features in the subsequent sections. +For example, user `uuid1234` should be the target of `variantA` regardless of their session. The original subset of users that get `variantA` will continue to experience that variation of the feature over time. At Unleash, we call this [stickiness](/reference/stickiness). You can define the parameter of stickiness in the gradual rollout form. By default, stickiness is calculated by `sessionId` and `groupId`. -### Enabling Impression Data +### Track A/B Testing for your Key Performance Metrics -Once you have created a feature flag and configured your A/B test, you can use Unleash to collect insights about the ongoing results of the test. One way to collect this data is through enabling [impression data](/reference/impression-data#impression-event-data) per feature flag. Impression data contains information about a specific feature flag activation check. It’s important to review data from an A/B test, as this could inform you on how (and if) users interact with the feature you have released. +An A/B testing strategy is most useful when you can track the results of a feature rollout to users. When your team has clearly defined the goals for your A/B tests, you can use Unleash to analyze how results tie back to key metrics, like conversion rates or time spent on a page. One way to collect this data is by enabling [impression data](/reference/impression-data) per feature flag. Impression data contains information about a specific feature flag activation check. -Strategy variants are meant to work with impression data. You get the name of the variant to your analytics which allows you a better understanding of what happened, rather than seeing a simple true/false from your logs. +To enable impression data for your rollout, navigate to your feature flag form and turn the toggle on. -To enable impression data for your flag, navigate to your feature flag form and turn the toggle on. +![Enable impression data in the strategy rollout form for your flag.](/img/use-case-experiment-enable-impression-data.png) -Next, in your application code, use the SDK to capture the impression events as they are being emitted in real time. Follow [language and framework-specific tutorials](/languages-and-frameworks) to learn how to capture the events and send them to data analytics and warehouse platforms of your choice. +Next, in your application code, use the SDK to capture the impression events as they are being emitted in real time. +Your client SDK will emit an impression event when it calls `isEnabled` or `getVariant`. Some front-end SDKs emit impression events only when a flag is enabled. You can define custom event types to track specific user actions. If you want to confirm that users from your A/B test have the new feature, Unleash will receive the `isEnabled` event. If you have created variants, the `getVariant` event type will be sent to Unleash. -Now that the application is capturing impression events, you can configure the correct data fields and formatting to send to any analytics tool or data warehouse you use. +Strategy variants are meant to work with impression data. You get the name of the variant sent to your analytics tool, which allows you a better understanding of what happened, rather than seeing a simple true/false from your logs. -#### Collect Event Type Data +The output from the impression data in your app may look like this code snippet: -Your client SDK will emit an impression event when it calls `isEnabled` or `getVariant`. Some front-end SDKs emit impression events only when a flag is enabled. +```js +{ + "eventType": "getVariant", + "eventId": "c41aa58b-d2c7-45cf-b668-7267f465e01a", + "context": { + "sessionId": 386689528, + "appName": "my-example-app", + "environment": "default" + }, + "enabled": true, + "featureName": "ab-testing-example", + "impressionData": true, + "variant": "variantA" +} +``` -You can define custom event types to track specific user actions. If you want to confirm that users from your A/B test have the new feature, Unleash will receive the `isEnabled` event. If you have created one or more variations of the same feature, known as strategy variants, the `getVariant` event type will be sent to Unleash. +In order to capture impression events in your app, follow our [language and framework-specific tutorials](/languages-and-frameworks). -### Automating A/B Tests with Actions & Signals +Now that your application is capturing impression events, you can configure the correct data fields and formatting to send to any analytics tool or data warehouse you use. -Unleash provides the ability to automate your feature flags using [actions](/reference/actions) and [signals](/reference/signals). When running A/B tests, you can configure your projects to execute tasks in response to application metrics and thresholds you define. If an experimentation feature that targets a part of your user base logs errors, your actions can automatically disable the feature so your team is given the time to triage while still providing a seamless, alternative experience to users. In another case, you can use actions to modify the percentage of users targeted for variations of a feature based off users engaging with one variation more than the other. +Here are two code examples of collecting impression data in an application to send to Google Analytics: -A/B tests are performed safely and strategically with extra safeguards when you automate your flags based on user activity and other metrics of your choice. +Example 1 + +```js +unleash.on(UnleashEvents.Impression, (e: ImpressionEvent) => { + // send to google analytics, something like + gtag("event", "screen_view", { + app_name: e.context.appName, + feature: e.featureName, + treatment: e.enabled ? e.variant : "Control", // in case we use feature disabled for control + }); +}); +``` + +Example 2 + +```js +unleash.on(UnleashEvents.Impression, (e: ImpressionEvent) => { + if (e.enabled) { + // send to google analytics, something like + gtag("event", "screen_view", { + app_name: e.context.appName, + feature: e.featureName, + treatment: e.variant, // in case we use a variant for the control treatment + }); + } +}); +``` + +In these example code snippets, `e` references the event object from the impression data output. Map these values to plug into the appropriate functions that make calls to your analytics tools and data warehouses. + +In some cases like in Example 1, you may want to use the "disabled feature" state as the "Control group". + +Alternatively, in Example 2, you can expose the feature to 100% of users and use two variants: "Control" and "Test". In either case, the variants are always used for the "Test" group. The difference is determined by how you use the "Control" group. + +An advantage of having your feature disabled for the Control group is that you can use metrics to see how many of the users are exposed to experiment(s) in comparison to the ones that are not. If you use only variants (for both the test and control group), you may see the feature metric as 100% exposed and would have to look deeper into the variant to know how many were exposed. + +Here is an example of a payload that is returned from Google Analytics that includes impression event data: + +```js +{ + "client_id": "unleash_client" + "user_id": "uuid1234" + "timestamp_micros": "1730407349525000" + "non_personalized_ads": true + "events": [ + { + "name":"select_item" + "params": { + "items":[] + "event":"screen_view" + "app_name":"myAppName" + "feature":"myFeatureName" + "treatment":"variantValue" + } + } + ] +} +``` + +By enabling impression data for your feature flag and listening to events within your application code, you can leverage this data flowing to your integrated analytics tools to make informed decisions faster and adjust your strategies based on real user behavior. + +### Rollout the Winning Variant to All Users + +After you have implemented your A/B test and measured the performance of a feature to a subset of users, you can decide which variant is the most optimal experience to roll out to all users in production. + +Unleash gives you control over which environments you release your feature to, when you release the feature, and to whom. Every team's release strategy may vary, but the overarching goal of A/B testing is to select the most effective experience for users, whether it be a change in your app's UI, a web performance improvement, or backend optimizations. + +When rolling out the winning variant, your flag may already be on in your production environment. Adjust the rollout strategy configurations to release to 100% of your user base in the Unleash Admin. + +After the flag has been available to 100% of users over time, archive the flag and clean up your codebase. + +## A/B Testing with Enterprise Automation + +With Unleash, you can automate your feature flags using [actions](/reference/actions) and [signals](/reference/signals). When running A/B tests, configure your projects to execute tasks in response to application metrics and thresholds you define. If an experimentation feature that targets a part of your user base logs errors, your actions can automatically disable the feature so your team is given the time to triage while still providing a seamless, alternative experience to users. In another case, you can use actions to modify the percentage of users targeted for variations of a feature based off users engaging with one variation more than the other. + +### Multi-arm Bandit Tests to Find the Winning Variant + +When running complex multivariate tests with numerous combinations, automating the process of finding the best variation of a feature is the most optimal, cost-effective approach for organizations with a large user base. [Multi-arm bandit tests](https://en.wikipedia.org/wiki/Multi-armed_bandit) are a powerful technique used in A/B testing to allocate traffic to different versions of a feature or application in a way that maximizes the desired outcome, such as conversion rate or click-through rate. This approach offers several advantages over traditional A/B testing and is a viable solution for large enterprise teams. + +The variants you created with Unleash would be the "arms" in the multi-bandit context. You can use a multi-arm bandit algorithm, such as [epsilon-greedy](https://www.geeksforgeeks.org/epsilon-greedy-algorithm-in-reinforcement-learning/) or [Thompson sampling](https://en.wikipedia.org/wiki/Thompson_sampling), to dynamically allocate traffic based on the performance of each variant. Experiment with different variants to gather more information. Allocate more traffic to the variants that are performing better. As the test progresses, the algorithm will adjust the traffic allocation to favor the variants that are showing promising results. After completing the test, you can analyze the data to determine the winning variant. By dynamically allocating traffic based on performance, multi-arm bandit tests can identify the winning variant more quickly than traditional A/B testing. + +![This is a graph comparing traditional A/B testing and multi-arm bandit selection.](/img/use-case-ab-testing-vs-bandit.png) + +> [Image Source: Matt Gershoff](https://blog.conductrics.com/balancing-earning-with-learning-bandits-and-adaptive-optimization/) + +To use Unleash to conduct a multi-arm bandit test, follow these steps: + +1. Collect the necessary data from each variant’s performance by enabling impression data for your feature flag +2. Capture impression events in your application code +3. Funnel the impression events captured from your application code to an external analytics tool +4. Create [signal endpoints](/reference/signals) in Unleash and point them to your external analytics tools +5. Create [actions](/reference/actions) in Unleash that can react to your signals Learn how to configure [actions](/reference/actions) and [signals](/reference/signals) from our documentation to get started. + +This approach minimizes the "regret" associated with allocating traffic to lower-performing variants. Multi-arm bandit tests using Unleash can adapt to changing conditions, such as seasonal fluctuations or user behavior changes. In some cases, they can be used to ensure that users are not exposed to suboptimal experiences for extended periods. + +A/B tests are performed safely and strategically with extra safeguards when you automate your flags based on user activity and other metrics of your choice. diff --git a/website/docs/how-to/how-to-add-sso-google.md b/website/docs/how-to/how-to-add-sso-google.md index 9693c2c8719e..7a495a920db4 100644 --- a/website/docs/how-to/how-to-add-sso-google.md +++ b/website/docs/how-to/how-to-add-sso-google.md @@ -1,5 +1,5 @@ --- -title: '[Deprecated] How to add SSO with Google' +title: 'How to add SSO with Google' --- :::caution Deprecation notice diff --git a/website/docs/how-to/how-to-import-export.mdx b/website/docs/how-to/how-to-import-export.mdx index 2ed1ffa0327a..24c7e90e7f41 100644 --- a/website/docs/how-to/how-to-import-export.mdx +++ b/website/docs/how-to/how-to-import-export.mdx @@ -1,5 +1,5 @@ --- -title: '[Deprecated] Import & Export' +title: 'Import & Export' --- import ApiRequest from '@site/src/components/ApiRequest' diff --git a/website/docs/how-to/how-to-schedule-feature-releases.mdx b/website/docs/how-to/how-to-schedule-feature-releases.mdx index 6ee2a9486bf7..be41fead3312 100644 --- a/website/docs/how-to/how-to-schedule-feature-releases.mdx +++ b/website/docs/how-to/how-to-schedule-feature-releases.mdx @@ -12,9 +12,8 @@ There's a whole host of reasons why you may want to schedule the release of a fe - **to make a feature available only up until a specific moment** (for a contest cutoff, for instance) - **to make a feature available during a limited period** (for a 24 hour flash sale, for instance) -There are two distinct ways to do this, depending on which version of Unleash you are running: -- If you're using version 4.9 or later of Unleash Pro or Enterprise, you can (and should) [use strategy constraints](#strategy-constraints) -- Otherwise, [use custom activation strategies](#custom-activation-strategies) +Depending on which version of Unleash you are using, there are two ways to schedule a feature release. If you are using [Pro](/availability#plans) or [Enterprise](https://www.getunleash.io/pricing) version 4.9 or later, you can use [strategy constraints](#schedule-feature-releases-with-strategy-constraints). +Otherwise, you can use [custom activation strategies](#schedule-feature-releases-with-custom-activation-strategies). In this guide we'll schedule a feature for release at some point in time. The exact same logic applies if you want to make a feature available until some point in the future. Finally, if you want to only make a feature available during a limited time period, you can easily combine the two options. @@ -81,4 +80,4 @@ To schedule feature releases without using strategy constraints, you can use cus ### Step 2: Implement the custom activation strategy in your clients -In each of the client SDKs that will interact with your feature, implement the strategy ([the implementation how-to guide](../how-to/how-to-use-custom-strategies#step-3) has steps for all SDK types). +In each of the client SDKs that will interact with your feature, implement the strategy ([the implementation how-to guide](../how-to/how-to-use-custom-strategies#step-3) has steps for all SDK types). \ No newline at end of file diff --git a/website/docs/quickstart.mdx b/website/docs/quickstart.mdx index 85842d714514..2ab0bbab0c08 100644 --- a/website/docs/quickstart.mdx +++ b/website/docs/quickstart.mdx @@ -132,11 +132,11 @@ For other ways to get started locally, see the steps for [starting an Unleash se #### Hosted by Unleash -With our [Pro and Enterprise plans](https://www.getunleash.io/pricing), you can run Unleash in the cloud by using our hosted offerings. +With our [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) plans, you can run Unleash in the cloud by using our hosted offerings. #### Self-hosted -Self-hosting Unleash is available for [Open-Source](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) customers. Visit [Self-hosting Unleash](/using-unleash/deploy) to learn more. +Self-hosting Unleash is available for [Open Source](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) customers. Visit [Self-hosting Unleash](/using-unleash/deploy) to learn more. ## Next steps diff --git a/website/docs/reference/api-tokens-and-client-keys.mdx b/website/docs/reference/api-tokens-and-client-keys.mdx index 14464e2dbe52..1e59a54e6386 100644 --- a/website/docs/reference/api-tokens-and-client-keys.mdx +++ b/website/docs/reference/api-tokens-and-client-keys.mdx @@ -55,27 +55,18 @@ By default, only admin users can create API tokens, and only admins can see thei However, any [client](#client-tokens client tokens) and [front-end tokens](#front-end-tokens) that are applicable to a project, will also be visible to any members of that project that have the `READ_PROJECT_API_TOKEN` permission (all project members by default). Similarly, any project members with the `CREATE_PROJECT_API_TOKEN` permission can also create client and front-end tokens for that specific project ([how to create project API tokens](../how-to/how-to-create-project-api-tokens)). -### Admin tokens - -**Admin tokens** grant _full read and write access_ to all resources in the Unleash server API. Admin tokens have access to all projects, all environments, and all root resources (find out more about [resources in the RBAC document](../reference/rbac#core-principles)). - -Use admin tokens to: - -- Automate Unleash behavior such as creating feature flags, projects, etc. -- Write custom Unleash UIs to replace the default Unleash admin UI. -Do **not** use admin tokens for: -- [Client SDKs](../reference/sdks): You will _not_ be able to read flag data from multiple environments. Use [client tokens](#client-tokens) instead. - -Support for scoped admin tokens with more fine-grained permissions is currently in the planning stage. +### Admin tokens -**Deprecation Notice** -We do not recommend using admin tokens anymore, they are not connected to any user, and as such is a lot harder to track. -* For OSS and Pro users, we recommend using [Personal Access Tokens](#personal-access-tokens) instead. -* Enterprise users have the option to use [Service accounts](./service-accounts). +:::warning +Admin tokens are deprecated. Use other tokens types: +- With [Open Source](https://www.getunleash.io/pricing) and [Pro](../availability#plans), use [personal access tokens](#personal-access-tokens). +- With [Enterprise](https://www.getunleash.io/pricing), use [service accounts](./service-accounts). +::: +**Admin tokens** grant _full read and write access_ to all resources in the Unleash server API. Admin tokens have access to all projects, all environments, and all root resources (find out more about [resources in the RBAC document](../reference/rbac#core-principles)). ### Personal access tokens diff --git a/website/docs/reference/api/legacy/unleash/admin/segments.mdx b/website/docs/reference/api/legacy/unleash/admin/segments.mdx index ab99ce927d78..81d0503a8f93 100644 --- a/website/docs/reference/api/legacy/unleash/admin/segments.mdx +++ b/website/docs/reference/api/legacy/unleash/admin/segments.mdx @@ -6,7 +6,7 @@ import ApiRequest from '@site/src/components/ApiRequest'; export const basePath :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.13+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.13+` ::: diff --git a/website/docs/reference/feature-toggles.mdx b/website/docs/reference/feature-toggles.mdx index fdd0e5f9dea2..519ee79c5c32 100644 --- a/website/docs/reference/feature-toggles.mdx +++ b/website/docs/reference/feature-toggles.mdx @@ -125,7 +125,7 @@ If an archived feature is revived, it starts a new lifecycle with a new [initial :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) **Unleash version**: `5.7+` | **Unleash Edge version**: `13.1+` | **Unleash Proxy version**: `0.18+`. Requires [SDK compatibility](../reference/sdks#server-side-sdk-compatibility-table) for variants. ::: @@ -151,7 +151,7 @@ Note that metrics are affected only by child feature flag evaluations. :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `5.12+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `5.12+` ::: diff --git a/website/docs/reference/insights.mdx b/website/docs/reference/insights.mdx index 51ba7db67918..ad5d989512d0 100644 --- a/website/docs/reference/insights.mdx +++ b/website/docs/reference/insights.mdx @@ -6,7 +6,7 @@ import Figure from '@site/src/components/Figure/Figure.tsx' :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `6.0+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `6.0+` ::: diff --git a/website/docs/reference/integrations/jira-cloud-plugin-installation.mdx b/website/docs/reference/integrations/jira-cloud-plugin-installation.mdx index 072e25c18317..14b8b8d70e94 100644 --- a/website/docs/reference/integrations/jira-cloud-plugin-installation.mdx +++ b/website/docs/reference/integrations/jira-cloud-plugin-installation.mdx @@ -5,7 +5,7 @@ import Figure from '@site/src/components/Figure/Figure.tsx' :::note Availability -**Version**: `4.0+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.0+` ::: @@ -24,8 +24,6 @@ For Jira Data Center, check out the [Jira Server plugin](jira-server-plugin-inst You will need an Unleash admin user to configure the access tokens needed to connect the plugin to Unleash. -This plugin requires an Unleash Pro or an Unleash Enterprise instance. - We recommend using a [service account](../service-accounts.md) token for communicating with Unleash. Service accounts are also required to integrate with [change requests](../change-requests) ### Jira diff --git a/website/docs/reference/network-view.mdx b/website/docs/reference/network-view.mdx index bac9610240d5..13f34553cbfa 100644 --- a/website/docs/reference/network-view.mdx +++ b/website/docs/reference/network-view.mdx @@ -6,7 +6,7 @@ import Figure from '@site/src/components/Figure/Figure.tsx' :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.21+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.21+` ::: diff --git a/website/docs/reference/notifications.md b/website/docs/reference/notifications.md index b4ea9787395e..e0a1153a6a82 100644 --- a/website/docs/reference/notifications.md +++ b/website/docs/reference/notifications.md @@ -4,7 +4,7 @@ title: Notifications :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.22+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | **Version**: `4.22+` ::: diff --git a/website/docs/reference/projects.mdx b/website/docs/reference/projects.mdx index 22ee9c95d190..10fe92474fbb 100644 --- a/website/docs/reference/projects.mdx +++ b/website/docs/reference/projects.mdx @@ -17,7 +17,7 @@ By default, projects have an open [collaboration mode](./project-collaboration-m :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: @@ -31,7 +31,7 @@ To create a new project: :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: @@ -45,7 +45,7 @@ The available project settings depend on a user's [root and project roles](./rba :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). | **Version**: `6.3+` +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). | **Version**: `6.3+` ::: @@ -67,7 +67,7 @@ To revive an archived project, go to **Projects > Archived projects** and click :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: diff --git a/website/docs/reference/rbac.md b/website/docs/reference/rbac.md index fb1dd2c0072a..3e27001683d6 100644 --- a/website/docs/reference/rbac.md +++ b/website/docs/reference/rbac.md @@ -29,8 +29,8 @@ Unleash has two levels in its hierarchy of resources: ## Predefined roles Unleash comes with a set of built-in predefined roles that you can use. The _root roles_ are available to all Unleash -users, while the _project-based roles_ are only available to Pro and Enterprise users. The below table lists the roles, -what they do, and what plans they are available in. Additionally, Enterprise users can create their +users, while the _project-based roles_ are only available to [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) users. The below table lists the roles, +what they do, and what plans they are available in. Additionally, [Enterprise](https://www.getunleash.io/pricing) users can create their own [custom root roles](#custom-root-roles) and [custom project roles](#custom-project-roles). When you add a new user, you can assign them one of the root roles listed below. @@ -40,8 +40,8 @@ When you add a new user, you can assign them one of the root roles listed below. | **Admin** | Root | Users with the root admin role have superuser access to Unleash and can perform any operation within the Unleash platform. | All versions | | **Editor** | Root | Users with the root editor role have access to most features in Unleash, but can not manage users and roles in the root scope. Editors will be added as project owners when creating projects and get superuser rights within the context of these projects. Users with the editor role will also get access to most permissions on the default project by default. | All versions | | **Viewer** | Root | Users with the root viewer role can only read root resources in Unleash. Viewers can be added to specific projects as project members. Users with the viewer role may not view API tokens. | All versions | -| **Owner** | Project | Users with the project owner role have full control over the project, and can add and manage other users within the project context, manage feature flags within the project, and control advanced project features like archiving and deleting the project. | Pro and Enterprise | -| **Member** | Project | Users with the project member role are allowed to view, create, and update feature flags within a project, but have limited permissions in regards to managing the project's user access and can not archive or delete the project. | Pro and Enterprise | +| **Owner** | Project | Users with the project owner role have full control over the project, and can add and manage other users within the project context, manage feature flags within the project, and control advanced project features like archiving and deleting the project. | [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | +| **Member** | Project | Users with the project member role are allowed to view, create, and update feature flags within a project, but have limited permissions in regards to managing the project's user access and can not archive or delete the project. | [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) | ## Custom Root Roles diff --git a/website/docs/reference/resource-limits.mdx b/website/docs/reference/resource-limits.mdx index f62700e9ab73..0d5338935c64 100644 --- a/website/docs/reference/resource-limits.mdx +++ b/website/docs/reference/resource-limits.mdx @@ -15,7 +15,7 @@ To ensure that Unleash operates smoothly, it includes resource limits for some o The resources and their respective limits and environment variables are: -| Resource | OSS limit | Pro limit | Enterprise limit | Environment variable | +| Resource | [Open Source](https://www.getunleash.io/pricing) limit | [Pro](/availability#plans) limit | [Enterprise](https://www.getunleash.io/pricing) limit | Environment variable | |----------------------------------------------------------------|----------:|----------:|-----------------:|------------------------------------------------| | [Feature flags](./feature-toggles)[^1] | 5,000 | 5,000 | 50,000 | `UNLEASH_FEATURE_FLAGS_LIMIT` | | [Strategies](./activation-strategies) per flag per environment | 30 | 30 | 30 | `UNLEASH_FEATURE_ENVIRONMENT_STRATEGIES_LIMIT` | @@ -51,8 +51,6 @@ If you try to set their limits lower than that, Unleash will automatically adjus If you operate a self-hosted Unleash instance, you can adjust the limit yourself. For hosted users of Unleash, you'll need to reach out and talk to your Unleash contact. -The only limits that can't be changed, are -- the limits for projects and environments for OSS instances -- the limits for projects and environments for Pro instances +The only limits that can't be changed, are projects and environments for [Open Source](https://www.getunleash.io/pricing) and [Pro](/availability#plans) instances. [^1]: Archived feature flags do not count towards your feature flag total. The limit only applies to active (i.e. not archived) feature flags. diff --git a/website/docs/reference/segments.mdx b/website/docs/reference/segments.mdx index c69c1ce6c45b..f1a456ef1e97 100644 --- a/website/docs/reference/segments.mdx +++ b/website/docs/reference/segments.mdx @@ -7,7 +7,7 @@ import VideoContent from '@site/src/components/VideoContent.jsx' :::note Availability -**Version**: `4.13+` for [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Version**: `4.13+` for [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). **Version**: `5.5+` for [Open Source](https://www.getunleash.io/pricing). ::: @@ -42,11 +42,11 @@ If an activation strategy has a segment _and_ additional constraints applied, th In theory, you could create segments with a thousand constraints, each with a million values. But this wouldn't scale well, so there are limitations in place to stop you from doing this. Unleash enforces the following limits on use of segments: -- If you're on a Pro plan +- If you're on a [Pro](/availability#plans) plan: A segment can have **at most 250 values** specified across all of its constraints. That means that if you add a constraint that uses 10 values, you will have 240 more values to use for any other constraints you add to the same segment. -- If you're on an Enterprise plan +- If you're on an [Enterprise](https://www.getunleash.io/pricing) plan: A segment can have **at most 1000 values** specified across all of its constraints. That means that if you add a constraint that uses 70 values, you will have 930 more values to use for any other constraints you add to the same segment. diff --git a/website/docs/reference/terraform.mdx b/website/docs/reference/terraform.mdx new file mode 100644 index 000000000000..8f2411565898 --- /dev/null +++ b/website/docs/reference/terraform.mdx @@ -0,0 +1,76 @@ +--- +title: Using Unleash through Terraform +description: "Set up and configure your Unleash instance using infastructure as code." +--- + +:::note Availability + +**Version**: `5.6+` + +::: + + +## Overview + +The [Unleash Terraform provider](https://github.com/Unleash/terraform-provider-unleash) enables you to manage and configure Unleash programmatically, leveraging infrastructure as code (IaC) for automated and scalable configuration. + +This provider is designed to help you with the **initial setup and configuration** of an instance. The provider does not support managing feature flags through Terraform. Since most [feature flags are short-lived](/topics/feature-flags/feature-flag-best-practices#7-make-flags-short-lived), we recommend managing them through the Unleash Admin UI. + +For a detailed video tutorial, check out [Managing Unleash through Terraform](https://www.youtube.com/watch?v=B4OIBC1u1ns). +For more examples of specific resources and data sources, visit the [Terraform registry](https://registry.terraform.io/providers/Unleash/unleash/latest/docs/data-sources/permission). + +## Manage Terraform access + +The permissions of the API token you use with Terraform and your Unleash [plan](https://www.getunleash.io/pricing) determine which objects Terraform can manage: +- For [Open Source](https://www.getunleash.io/pricing) and [Pro](../availability#plans), use [personal access tokens](/reference/api-tokens-and-client-keys#personal-access-tokens). +- For [Enterprise](https://www.getunleash.io/pricing), use [service accounts](/reference/service-accounts). For larger teams, we recommend multiple service accounts with different permissions and separate Terraform repositories for each team under their respective projects. + +## Resources + +### API tokens + +- `unleash_api_token`: Manage access and maintain secure communication with verified integrations. + +Example usage: +```hcl +resource "unleash_api_token" "client_token" { + token_name = "client_token" + type = "client" + expires_at = "2024-12-31T23:59:59Z" + project = "default" + environment = "development" +} +``` + +### Projects + +- `unleash_project`: Create and manage projects. +- `unleash_project_access`: Assign access roles and users to specific project resources. + +### Users and roles + +- `unleash_role`: Define permissions systematically. +- `unleash_user`: Automate user management. Use `send_email = true` to generate an invitation link. + +### Service accounts + +- `unleash_service_account`: Define and manage service accounts for secure automated access. +- `unleash_service_account_token`: Generate tokens associated with service accounts. + +### Single sign-on protocols + +- `unleash_oidc`: Manage your [OpenID Connect configuration](../how-to/how-to-add-sso-open-id-connect). +- `unleash_saml`: Manage your [SAML configuration](../how-to/sso). + +For example usage and schemas, visit the resources documentation of the [Unleash Terraform provider](https://github.com/Unleash/terraform-provider-unleash/tree/main/docs/resources). + +## Data sources + +You can use the following data sources to fetch resources from Unleash: + +- `unleash_project` +- `unleash_user` +- `unleash_role` +- `unleash_permission` + +For example usage and schemas, visit the data sources documentation of the [Unleash Terraform provider](https://github.com/Unleash/terraform-provider-unleash/tree/main/docs/data-sources). \ No newline at end of file diff --git a/website/docs/understanding-unleash/proxy-hosting.mdx b/website/docs/understanding-unleash/proxy-hosting.mdx index 20b6f499230b..b64f1d46e37e 100644 --- a/website/docs/understanding-unleash/proxy-hosting.mdx +++ b/website/docs/understanding-unleash/proxy-hosting.mdx @@ -23,7 +23,7 @@ In general, we recommend you use Edge over the [Frontend API](https://docs.getun If you want Unleash to host the Frontend API for you, you should be aware of the following limitations: -- This is only available to Pro and Enterprise customers who have signed up for a managed Unleash instance. +- This is only available to [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) customers who have signed up for a managed Unleash instance. - We allow short spikes in traffic and our adaptive infrastructure will automatically scale to your needs. - Please check the [Fair Use Policy](https://www.getunleash.io/fair-use-policy) to see the limits of the Unleash-hosted Frontend API. - There's no guarantee that it'll be geographically close to your end users, this means your end users might be getting slower response times on feature flag evaluations. @@ -45,12 +45,12 @@ Hosting Edge requires a little more setup than the Unleash-hosted Frontend API d :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: -Unleash no longer hosts instances of the proxy, but makes the [Frontend API](../reference/front-end-api) available to all Pro and Enterprise customers. The API is backed by an Amazon RDS database. Your applications can connect to the frontend API from your own cloud or from other hosting solutions. +Unleash no longer hosts instances of the proxy, but makes the [Frontend API](../reference/front-end-api) available to all [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) customers. The API is backed by an Amazon RDS database. Your applications can connect to the frontend API from your own cloud or from other hosting solutions. In order to access the frontend API you'll need: - A [Frontend API key](../reference/api-tokens-and-client-keys#front-end-tokens) for the environment you'd like to use. @@ -66,7 +66,7 @@ While this is easy to get started with, it comes with the limitations described :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: @@ -155,7 +155,7 @@ Please take note of the section covering features Edge does not currently suppor :::note Availability -**Plan**: [Pro](https://www.getunleash.io/pricing) and [Enterprise](https://www.getunleash.io/pricing). +**Plan**: [Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing). ::: diff --git a/website/docs/understanding-unleash/the-anatomy-of-unleash.mdx b/website/docs/understanding-unleash/the-anatomy-of-unleash.mdx index 066ee050f173..472b01646875 100644 --- a/website/docs/understanding-unleash/the-anatomy-of-unleash.mdx +++ b/website/docs/understanding-unleash/the-anatomy-of-unleash.mdx @@ -26,7 +26,7 @@ Some things in Unleash are configured and defined on the root level. These optio All Unleash instances must have at least one project at any given time. New instances get a project called “Default”. -Pro and Enterprise customers can create, rename, and delete projects as they wish (as long as there is always **at least one project**). Open-source users, on the other hand, only get access to the Default project. +[Pro](/availability#plans) and [Enterprise](https://www.getunleash.io/pricing) customers can create, rename, and delete projects. [Open Source](https://www.getunleash.io/pricing) users have a single project called 'Default'.
{ + const { defaultCreateSitemapItems, ...rest } = params; + const items = await defaultCreateSitemapItems(rest); + return items.filter( + (item) => !item.url.includes('/page/'), + ); + }, + }, }, ], ], diff --git a/website/sidebars.ts b/website/sidebars.ts index 36891126a7a0..00d463103887 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -445,7 +445,7 @@ const sidebars: SidebarsConfig = { ], }, { - label: 'Integrations', + label: 'Integrations and automation', type: 'category', link: { type: 'doc', id: 'reference/integrations/index' }, items: [ @@ -461,9 +461,100 @@ const sidebars: SidebarsConfig = { ], }, 'reference/integrations/slack-app', - 'reference/integrations/slack', 'reference/integrations/teams', 'reference/integrations/webhook', + { + type: 'doc', + label: 'Terraform', + id: 'reference/terraform', + }, + ], + }, + { + type: 'category', + link: { + type: 'generated-index', + title: 'Self-Hosting Unleash', + description: + 'All you need to learn how to deploy and manage your own Unleash instance.', + slug: '/using-unleash/deploy', + }, + label: 'Self-hosting', + items: [ + 'using-unleash/deploy/getting-started', + 'using-unleash/deploy/configuring-unleash', + 'using-unleash/deploy/database-setup', + 'using-unleash/deploy/database-backup', + 'using-unleash/deploy/email-service', + 'using-unleash/deploy/google-auth-hook', + 'using-unleash/deploy/upgrading-unleash', + 'using-unleash/deploy/securing-unleash', + 'using-unleash/deploy/license-keys', + ], + }, + { + label: 'Single sign-on', + items: [ + 'how-to/how-to-add-sso-open-id-connect', + 'how-to/how-to-add-sso-saml', + 'how-to/how-to-add-sso-saml-keycloak', + 'how-to/how-to-add-sso-azure-saml', + 'how-to/how-to-setup-sso-keycloak-group-sync', + ], + type: 'category', + link: { + type: 'generated-index', + title: 'How-to: Single sign-on', + description: 'Single sign-on guides.', + slug: '/how-to/sso', + }, + }, + { + label: 'Automatic provisioning', + items: [ + 'how-to/how-to-setup-provisioning-with-okta', + 'how-to/how-to-setup-provisioning-with-entra', + ], + type: 'category', + link: { + type: 'generated-index', + title: 'How to: Provisioning', + description: 'Provisioning how-to guides.', + slug: '/how-to/provisioning', + }, + }, + { + type: 'category', + label: 'Unleash Edge', + collapsed: true, + link: { + type: 'doc', + id: 'generated/unleash-edge', + }, + items: [ + 'generated/unleash-edge/concepts', + 'generated/unleash-edge/deploying', + ], + }, + 'generated/unleash-proxy', + { + label: 'Troubleshooting', + type: 'category', + link: { + type: 'generated-index', + title: 'How-to: troubleshooting', + description: + 'Troubleshooting common problems. If you want to suggest new items, please phrase the title as a concrete problem', + slug: '/using-unleash/troubleshooting', + }, + items: [ + 'using-unleash/troubleshooting/cors', + 'using-unleash/troubleshooting/https', + 'using-unleash/troubleshooting/email-service', + 'using-unleash/troubleshooting/feature-not-available', + 'using-unleash/troubleshooting/flag-exposure', + 'using-unleash/troubleshooting/flag-not-returned', + 'using-unleash/troubleshooting/flag-abn-test-unexpected-result', ], }, { @@ -538,10 +629,7 @@ const sidebars: SidebarsConfig = { description: 'Environments how-to guides.', slug: '/how-to/env', }, - items: [ - 'how-to/how-to-import-export', - 'how-to/how-to-environment-import-export', - ], + items: ['how-to/how-to-environment-import-export'], }, { label: 'Users and permissions', @@ -561,96 +649,8 @@ const sidebars: SidebarsConfig = { slug: '/how-to/users-and-permissions', }, }, - { - label: 'Single sign-on SSO', - items: [ - 'how-to/how-to-add-sso-open-id-connect', - 'how-to/how-to-add-sso-saml', - 'how-to/how-to-add-sso-saml-keycloak', - 'how-to/how-to-add-sso-azure-saml', - 'how-to/how-to-setup-sso-keycloak-group-sync', - 'how-to/how-to-add-sso-google', - ], - type: 'category', - link: { - type: 'generated-index', - title: 'How-to: Single sign-on', - description: 'Single sign-on guides.', - slug: '/how-to/sso', - }, - }, - { - label: 'Automatic provisioning', - items: [ - 'how-to/how-to-setup-provisioning-with-okta', - 'how-to/how-to-setup-provisioning-with-entra', - ], - type: 'category', - link: { - type: 'generated-index', - title: 'How to: Provisioning', - description: 'Provisioning how-to guides.', - slug: '/how-to/provisioning', - }, - }, ], }, - { - type: 'category', - link: { - type: 'generated-index', - title: 'Self-Hosting Unleash', - description: - 'All you need to learn how to deploy and manage your own Unleash instance.', - slug: '/using-unleash/deploy', - }, - label: 'Self-Hosting Unleash', - items: [ - 'using-unleash/deploy/getting-started', - 'using-unleash/deploy/configuring-unleash', - 'using-unleash/deploy/database-setup', - 'using-unleash/deploy/database-backup', - 'using-unleash/deploy/email-service', - 'using-unleash/deploy/google-auth-hook', - 'using-unleash/deploy/upgrading-unleash', - 'using-unleash/deploy/securing-unleash', - 'using-unleash/deploy/license-keys', - ], - }, - { - label: 'Troubleshooting', - type: 'category', - link: { - type: 'generated-index', - title: 'How-to: troubleshooting', - description: - 'Troubleshooting common problems. If you want to suggest new items, please phrase the title as a concrete problem', - slug: '/using-unleash/troubleshooting', - }, - items: [ - 'using-unleash/troubleshooting/cors', - 'using-unleash/troubleshooting/https', - 'using-unleash/troubleshooting/email-service', - 'using-unleash/troubleshooting/feature-not-available', - 'using-unleash/troubleshooting/flag-exposure', - 'using-unleash/troubleshooting/flag-not-returned', - 'using-unleash/troubleshooting/flag-abn-test-unexpected-result', - ], - }, - { - type: 'category', - label: 'Unleash Edge', - collapsed: true, - link: { - type: 'doc', - id: 'generated/unleash-edge', - }, - items: [ - 'generated/unleash-edge/concepts', - 'generated/unleash-edge/deploying', - ], - }, - 'generated/unleash-proxy', ], }, { diff --git a/website/static/img/tutorial-building-blocks-strategy-variants.png b/website/static/img/tutorial-building-blocks-strategy-variants.png new file mode 100644 index 000000000000..e1cfd4ecaa29 Binary files /dev/null and b/website/static/img/tutorial-building-blocks-strategy-variants.png differ diff --git a/website/static/img/use-case-ab-testing-vs-bandit.png b/website/static/img/use-case-ab-testing-vs-bandit.png new file mode 100644 index 000000000000..58283439e6d8 Binary files /dev/null and b/website/static/img/use-case-ab-testing-vs-bandit.png differ diff --git a/website/static/img/use-case-create-experiment-flag.png b/website/static/img/use-case-create-experiment-flag.png new file mode 100644 index 000000000000..623f218daed5 Binary files /dev/null and b/website/static/img/use-case-create-experiment-flag.png differ diff --git a/website/static/img/use-case-experiment-add-strategy.png b/website/static/img/use-case-experiment-add-strategy.png new file mode 100644 index 000000000000..bc498afd5207 Binary files /dev/null and b/website/static/img/use-case-experiment-add-strategy.png differ diff --git a/website/static/img/use-case-experiment-enable-impression-data.png b/website/static/img/use-case-experiment-enable-impression-data.png new file mode 100644 index 000000000000..d4f1e2357430 Binary files /dev/null and b/website/static/img/use-case-experiment-enable-impression-data.png differ diff --git a/website/static/img/use-case-experiment-gradual-rollout.png b/website/static/img/use-case-experiment-gradual-rollout.png new file mode 100644 index 000000000000..d87b950b5ea2 Binary files /dev/null and b/website/static/img/use-case-experiment-gradual-rollout.png differ diff --git a/website/static/img/use-case-experiment-variants.png b/website/static/img/use-case-experiment-variants.png new file mode 100644 index 000000000000..6ed1a41e5135 Binary files /dev/null and b/website/static/img/use-case-experiment-variants.png differ diff --git a/website/static/img/use-case-new-flag.png b/website/static/img/use-case-new-flag.png new file mode 100644 index 000000000000..6904b7aa7501 Binary files /dev/null and b/website/static/img/use-case-new-flag.png differ