From 292d6a7f6032d51812d8963770c4f729310cc084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 17 Mar 2023 08:23:27 +0000 Subject: [PATCH] feat: implement project-scoped segments in project settings (#3335) https://linear.app/unleash/issue/2-743/have-a-project-specific-configuration-section ![image](https://user-images.githubusercontent.com/14320932/225657038-1a385e6e-deb3-4229-a30d-e7ca28ef2b3c.png) Adds the "segments" option to project settings, providing the usual CRUD operations but scoped to the specific project. --- .../common/FormTemplate/FormTemplate.tsx | 1 + .../common/PageHeader/PageHeader.tsx | 1 + .../common/PremiumFeature/PremiumFeature.tsx | 5 ++ .../ProjectSegments/ProjectSegments.tsx | 63 +++++++++++++++++++ .../ProjectSettings/ProjectSettings.tsx | 9 ++- .../segments/CreateSegment/CreateSegment.tsx | 10 ++- .../CreateSegmentButton.tsx | 10 ++- .../segments/EditSegment/EditSegment.tsx | 8 ++- .../EditSegmentButton/EditSegmentButton.tsx | 12 +++- .../component/segments/SegmentFormStepOne.tsx | 10 ++- .../component/segments/SegmentFormStepTwo.tsx | 3 +- .../src/component/segments/SegmentTable.tsx | 23 ++++--- .../__snapshots__/TagTypeList.test.tsx.snap | 2 +- 13 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index 4cf226892e..ec397b671a 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -34,6 +34,7 @@ const StyledContainer = styled('section', { minHeight: modal ? '100vh' : '80vh', borderRadius: modal ? 0 : theme.spacing(2), width: '100%', + height: '100%', display: 'flex', margin: '0 auto', [theme.breakpoints.down(1100)]: { diff --git a/frontend/src/component/common/PageHeader/PageHeader.tsx b/frontend/src/component/common/PageHeader/PageHeader.tsx index 17555e5272..1afa647e20 100644 --- a/frontend/src/component/common/PageHeader/PageHeader.tsx +++ b/frontend/src/component/common/PageHeader/PageHeader.tsx @@ -46,6 +46,7 @@ const StyledHeader = styled('div')(({ theme }) => ({ const StyledHeaderTitle = styled(Typography)(({ theme }) => ({ fontSize: theme.fontSizes.mainHeader, fontWeight: 'normal', + lineHeight: theme.spacing(5), })); const StyledHeaderActions = styled('div')(({ theme }) => ({ diff --git a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx index f356b47f0d..f8291ce08b 100644 --- a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx +++ b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx @@ -63,6 +63,11 @@ const PremiumFeatures = { url: 'https://docs.getunleash.io/reference/change-requests', label: 'Change Requests', }, + segments: { + plan: FeaturePlan.PRO, + url: 'https://docs.getunleash.io/reference/segments', + label: 'Segments', + }, }; type PremiumFeatureType = keyof typeof PremiumFeatures; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx new file mode 100644 index 0000000000..30cc0c570b --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSegments/ProjectSegments.tsx @@ -0,0 +1,63 @@ +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 { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject'; +import { SegmentTable } from 'component/segments/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'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { GO_BACK } from 'constants/navigate'; + +export const ProjectSegments = () => { + const projectId = useRequiredPathParam('projectId'); + const projectName = useProjectNameOrId(projectId); + const { isOss } = useUiConfig(); + const navigate = useNavigate(); + + usePageTitle(`Project segments – ${projectName}`); + + if (isOss()) { + return ( + } + sx={{ justifyContent: 'center' }} + > + + + ); + } + + return ( + + navigate(GO_BACK)} + label="Create segment" + > + + + } + /> + navigate(GO_BACK)} + label="Edit segment" + > + + + } + /> + } /> + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx index 0bd2d1347a..9ef51c0523 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx @@ -11,12 +11,13 @@ import ProjectEnvironmentList from 'component/project/ProjectEnvironment/Project import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration'; import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess'; import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; +import { ProjectSegments } from './ProjectSegments/ProjectSegments'; export const ProjectSettings = () => { const location = useLocation(); const navigate = useNavigate(); const { uiConfig } = useUiConfig(); - const { showProjectApiAccess } = uiConfig.flags; + const { showProjectApiAccess, projectScopedSegments } = uiConfig.flags; const tabs: ITab[] = [ { @@ -27,6 +28,11 @@ export const ProjectSettings = () => { id: 'access', label: 'Access', }, + { + id: 'segments', + label: 'Segments', + hidden: !Boolean(projectScopedSegments), + }, { id: 'change-requests', label: 'Change request configuration', @@ -60,6 +66,7 @@ export const ProjectSettings = () => { element={} /> } /> + } /> } diff --git a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx index a65241b6d2..388ce66523 100644 --- a/frontend/src/component/segments/CreateSegment/CreateSegment.tsx +++ b/frontend/src/component/segments/CreateSegment/CreateSegment.tsx @@ -16,8 +16,10 @@ import { segmentsDocsLink } from 'component/segments/SegmentDocs'; import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; export const CreateSegment = () => { + const projectId = useOptionalPathParam('projectId'); const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); const { showFeedbackCES } = useContext(feedbackCESContext); @@ -37,7 +39,7 @@ export const CreateSegment = () => { getSegmentPayload, errors, clearErrors, - } = useSegmentForm(); + } = useSegmentForm('', '', projectId); const hasValidConstraints = useConstraintsValidation(constraints); const { segmentValuesLimit } = useSegmentLimits(); @@ -62,7 +64,11 @@ export const CreateSegment = () => { try { await createSegment(getSegmentPayload()); await refetchSegments(); - navigate('/segments/'); + if (projectId) { + navigate(`/projects/${projectId}/settings/segments/`); + } else { + navigate('/segments/'); + } setToastData({ title: 'Segment created', confetti: true, diff --git a/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx b/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx index 192a44fda8..57c885a3cd 100644 --- a/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx +++ b/frontend/src/component/segments/CreateSegmentButton/CreateSegmentButton.tsx @@ -2,13 +2,21 @@ import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions'; import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds'; import { useNavigate } from 'react-router-dom'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; export const CreateSegmentButton = () => { + const projectId = useOptionalPathParam('projectId'); const navigate = useNavigate(); return ( navigate('/segments/create')} + onClick={() => { + if (projectId) { + navigate(`/projects/${projectId}/settings/segments/create`); + } else { + navigate('/segments/create'); + } + }} permission={CREATE_SEGMENT} data-testid={NAVIGATE_TO_CREATE_SEGMENT} > diff --git a/frontend/src/component/segments/EditSegment/EditSegment.tsx b/frontend/src/component/segments/EditSegment/EditSegment.tsx index dbf440470b..68bcdaea1f 100644 --- a/frontend/src/component/segments/EditSegment/EditSegment.tsx +++ b/frontend/src/component/segments/EditSegment/EditSegment.tsx @@ -18,8 +18,10 @@ import { segmentsDocsLink } from 'component/segments/SegmentDocs'; import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; export const EditSegment = () => { + const projectId = useOptionalPathParam('projectId'); const segmentId = useRequiredPathParam('segmentId'); const { segment } = useSegment(Number(segmentId)); const { uiConfig } = useUiConfig(); @@ -71,7 +73,11 @@ export const EditSegment = () => { try { await updateSegment(segment.id, getSegmentPayload()); await refetchSegments(); - navigate('/segments/'); + if (projectId) { + navigate(`/projects/${projectId}/settings/segments/`); + } else { + navigate('/segments/'); + } setToastData({ title: 'Segment updated', type: 'success', diff --git a/frontend/src/component/segments/EditSegmentButton/EditSegmentButton.tsx b/frontend/src/component/segments/EditSegmentButton/EditSegmentButton.tsx index 98a2cb822e..f1eb10a55a 100644 --- a/frontend/src/component/segments/EditSegmentButton/EditSegmentButton.tsx +++ b/frontend/src/component/segments/EditSegmentButton/EditSegmentButton.tsx @@ -3,17 +3,27 @@ import { Edit } from '@mui/icons-material'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import { UPDATE_SEGMENT } from 'component/providers/AccessProvider/permissions'; import { useNavigate } from 'react-router-dom'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; interface IEditSegmentButtonProps { segment: ISegment; } export const EditSegmentButton = ({ segment }: IEditSegmentButtonProps) => { + const projectId = useOptionalPathParam('projectId'); const navigate = useNavigate(); return ( navigate(`/segments/edit/${segment.id}`)} + onClick={() => { + if (projectId) { + navigate( + `/projects/${projectId}/settings/segments/edit/${segment.id}` + ); + } else { + navigate(`/segments/edit/${segment.id}`); + } + }} permission={UPDATE_SEGMENT} tooltipProps={{ title: 'Edit segment' }} > diff --git a/frontend/src/component/segments/SegmentFormStepOne.tsx b/frontend/src/component/segments/SegmentFormStepOne.tsx index a5fd0507e3..4c41bdc170 100644 --- a/frontend/src/component/segments/SegmentFormStepOne.tsx +++ b/frontend/src/component/segments/SegmentFormStepOne.tsx @@ -11,6 +11,8 @@ import { import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; +import { GO_BACK } from 'constants/navigate'; interface ISegmentFormPartOneProps { name: string; @@ -65,6 +67,7 @@ export const SegmentFormStepOne: React.FC = ({ clearErrors, setCurrentStep, }) => { + const projectId = useOptionalPathParam('projectId'); const { uiConfig } = useUiConfig(); const navigate = useNavigate(); const { projects } = useProjects(); @@ -105,7 +108,10 @@ export const SegmentFormStepOne: React.FC = ({ data-testid={SEGMENT_DESC_ID} /> @@ -141,7 +147,7 @@ export const SegmentFormStepOne: React.FC = ({ { - navigate('/segments'); + navigate(GO_BACK); }} > Cancel diff --git a/frontend/src/component/segments/SegmentFormStepTwo.tsx b/frontend/src/component/segments/SegmentFormStepTwo.tsx index c52cc32fea..9e35a8e4c9 100644 --- a/frontend/src/component/segments/SegmentFormStepTwo.tsx +++ b/frontend/src/component/segments/SegmentFormStepTwo.tsx @@ -29,6 +29,7 @@ import { import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import AccessContext from 'contexts/AccessContext'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; +import { GO_BACK } from 'constants/navigate'; interface ISegmentFormPartTwoProps { constraints: IConstraint[]; @@ -214,7 +215,7 @@ export const SegmentFormStepTwo: React.FC = ({ { - navigate('/segments'); + navigate(GO_BACK); }} > Cancel diff --git a/frontend/src/component/segments/SegmentTable.tsx b/frontend/src/component/segments/SegmentTable.tsx index db1404b334..c312002376 100644 --- a/frontend/src/component/segments/SegmentTable.tsx +++ b/frontend/src/component/segments/SegmentTable.tsx @@ -28,8 +28,10 @@ import { Search } from 'component/common/Search/Search'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; export const SegmentTable = () => { + const projectId = useOptionalPathParam('projectId'); const { segments, loading } = useSegments(); const { uiConfig } = useUiConfig(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -39,17 +41,22 @@ export const SegmentTable = () => { }); const data = useMemo(() => { - return ( - segments ?? - Array(5).fill({ + if (!segments) { + return Array(5).fill({ name: 'Segment name', description: 'Segment descripton', createdAt: new Date().toISOString(), createdBy: 'user', projectId: 'Project', - }) - ); - }, [segments]); + }); + } + + if (projectId) { + return segments.filter(({ project }) => project === projectId); + } + + return segments; + }, [segments, projectId]); const { getTableProps, @@ -85,7 +92,9 @@ export const SegmentTable = () => { columns: ['createdAt', 'createdBy'], }, { - condition: !Boolean(uiConfig.flags.projectScopedSegments), + condition: + Boolean(projectId) || + !Boolean(uiConfig.flags.projectScopedSegments), columns: ['project'], }, ], diff --git a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap index 540bd86bec..7c7a02e385 100644 --- a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap +++ b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap @@ -23,7 +23,7 @@ exports[`renders an empty list correctly 1`] = ` data-loading={true} >

Tag types (5)