-
+ {FeatureOverviewSidePanel}
-
+
+ }
+ elseShow={}
+ />
({
- 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}
+
+ Environments ({feature.environments.length})
+
+ setEnvironmentId(id)}
/>
);
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/project/Project/ProjectSettings/ProjectSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx
index a5ec9eb7c445..ee5b15a92760 100644
--- a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx
+++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx
@@ -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() ? (