1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

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.
This commit is contained in:
Nuno Góis 2023-03-17 08:23:27 +00:00 committed by GitHub
parent f685c1059f
commit 292d6a7f60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 140 additions and 17 deletions

View File

@ -34,6 +34,7 @@ const StyledContainer = styled('section', {
minHeight: modal ? '100vh' : '80vh', minHeight: modal ? '100vh' : '80vh',
borderRadius: modal ? 0 : theme.spacing(2), borderRadius: modal ? 0 : theme.spacing(2),
width: '100%', width: '100%',
height: '100%',
display: 'flex', display: 'flex',
margin: '0 auto', margin: '0 auto',
[theme.breakpoints.down(1100)]: { [theme.breakpoints.down(1100)]: {

View File

@ -46,6 +46,7 @@ const StyledHeader = styled('div')(({ theme }) => ({
const StyledHeaderTitle = styled(Typography)(({ theme }) => ({ const StyledHeaderTitle = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader, fontSize: theme.fontSizes.mainHeader,
fontWeight: 'normal', fontWeight: 'normal',
lineHeight: theme.spacing(5),
})); }));
const StyledHeaderActions = styled('div')(({ theme }) => ({ const StyledHeaderActions = styled('div')(({ theme }) => ({

View File

@ -63,6 +63,11 @@ const PremiumFeatures = {
url: 'https://docs.getunleash.io/reference/change-requests', url: 'https://docs.getunleash.io/reference/change-requests',
label: 'Change Requests', label: 'Change Requests',
}, },
segments: {
plan: FeaturePlan.PRO,
url: 'https://docs.getunleash.io/reference/segments',
label: 'Segments',
},
}; };
type PremiumFeatureType = keyof typeof PremiumFeatures; type PremiumFeatureType = keyof typeof PremiumFeatures;

View File

@ -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 (
<PageContent
header={<PageHeader titleElement="Segments" />}
sx={{ justifyContent: 'center' }}
>
<PremiumFeature feature="segments" />
</PageContent>
);
}
return (
<Routes>
<Route
path="create"
element={
<SidebarModal
open
onClose={() => navigate(GO_BACK)}
label="Create segment"
>
<CreateSegment />
</SidebarModal>
}
/>
<Route
path="edit/:segmentId"
element={
<SidebarModal
open
onClose={() => navigate(GO_BACK)}
label="Edit segment"
>
<EditSegment />
</SidebarModal>
}
/>
<Route path="*" element={<SegmentTable />} />
</Routes>
);
};

View File

@ -11,12 +11,13 @@ import ProjectEnvironmentList from 'component/project/ProjectEnvironment/Project
import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration'; import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration';
import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess'; import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import { ProjectSegments } from './ProjectSegments/ProjectSegments';
export const ProjectSettings = () => { export const ProjectSettings = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { showProjectApiAccess } = uiConfig.flags; const { showProjectApiAccess, projectScopedSegments } = uiConfig.flags;
const tabs: ITab[] = [ const tabs: ITab[] = [
{ {
@ -27,6 +28,11 @@ export const ProjectSettings = () => {
id: 'access', id: 'access',
label: 'Access', label: 'Access',
}, },
{
id: 'segments',
label: 'Segments',
hidden: !Boolean(projectScopedSegments),
},
{ {
id: 'change-requests', id: 'change-requests',
label: 'Change request configuration', label: 'Change request configuration',
@ -60,6 +66,7 @@ export const ProjectSettings = () => {
element={<ProjectEnvironmentList />} element={<ProjectEnvironmentList />}
/> />
<Route path="access/*" element={<ProjectAccess />} /> <Route path="access/*" element={<ProjectAccess />} />
<Route path="segments/*" element={<ProjectSegments />} />
<Route <Route
path="change-requests/*" path="change-requests/*"
element={<ChangeRequestConfiguration />} element={<ChangeRequestConfiguration />}

View File

@ -16,8 +16,10 @@ import { segmentsDocsLink } from 'component/segments/SegmentDocs';
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds'; import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
export const CreateSegment = () => { export const CreateSegment = () => {
const projectId = useOptionalPathParam('projectId');
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { showFeedbackCES } = useContext(feedbackCESContext); const { showFeedbackCES } = useContext(feedbackCESContext);
@ -37,7 +39,7 @@ export const CreateSegment = () => {
getSegmentPayload, getSegmentPayload,
errors, errors,
clearErrors, clearErrors,
} = useSegmentForm(); } = useSegmentForm('', '', projectId);
const hasValidConstraints = useConstraintsValidation(constraints); const hasValidConstraints = useConstraintsValidation(constraints);
const { segmentValuesLimit } = useSegmentLimits(); const { segmentValuesLimit } = useSegmentLimits();
@ -62,7 +64,11 @@ export const CreateSegment = () => {
try { try {
await createSegment(getSegmentPayload()); await createSegment(getSegmentPayload());
await refetchSegments(); await refetchSegments();
navigate('/segments/'); if (projectId) {
navigate(`/projects/${projectId}/settings/segments/`);
} else {
navigate('/segments/');
}
setToastData({ setToastData({
title: 'Segment created', title: 'Segment created',
confetti: true, confetti: true,

View File

@ -2,13 +2,21 @@ import { CREATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds'; import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
export const CreateSegmentButton = () => { export const CreateSegmentButton = () => {
const projectId = useOptionalPathParam('projectId');
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<PermissionButton <PermissionButton
onClick={() => navigate('/segments/create')} onClick={() => {
if (projectId) {
navigate(`/projects/${projectId}/settings/segments/create`);
} else {
navigate('/segments/create');
}
}}
permission={CREATE_SEGMENT} permission={CREATE_SEGMENT}
data-testid={NAVIGATE_TO_CREATE_SEGMENT} data-testid={NAVIGATE_TO_CREATE_SEGMENT}
> >

View File

@ -18,8 +18,10 @@ import { segmentsDocsLink } from 'component/segments/SegmentDocs';
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds'; import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
export const EditSegment = () => { export const EditSegment = () => {
const projectId = useOptionalPathParam('projectId');
const segmentId = useRequiredPathParam('segmentId'); const segmentId = useRequiredPathParam('segmentId');
const { segment } = useSegment(Number(segmentId)); const { segment } = useSegment(Number(segmentId));
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -71,7 +73,11 @@ export const EditSegment = () => {
try { try {
await updateSegment(segment.id, getSegmentPayload()); await updateSegment(segment.id, getSegmentPayload());
await refetchSegments(); await refetchSegments();
navigate('/segments/'); if (projectId) {
navigate(`/projects/${projectId}/settings/segments/`);
} else {
navigate('/segments/');
}
setToastData({ setToastData({
title: 'Segment updated', title: 'Segment updated',
type: 'success', type: 'success',

View File

@ -3,17 +3,27 @@ import { Edit } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_SEGMENT } from 'component/providers/AccessProvider/permissions'; import { UPDATE_SEGMENT } from 'component/providers/AccessProvider/permissions';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
interface IEditSegmentButtonProps { interface IEditSegmentButtonProps {
segment: ISegment; segment: ISegment;
} }
export const EditSegmentButton = ({ segment }: IEditSegmentButtonProps) => { export const EditSegmentButton = ({ segment }: IEditSegmentButtonProps) => {
const projectId = useOptionalPathParam('projectId');
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<PermissionIconButton <PermissionIconButton
onClick={() => 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} permission={UPDATE_SEGMENT}
tooltipProps={{ title: 'Edit segment' }} tooltipProps={{ title: 'Edit segment' }}
> >

View File

@ -11,6 +11,8 @@ import {
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useProjects from 'hooks/api/getters/useProjects/useProjects'; import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import { GO_BACK } from 'constants/navigate';
interface ISegmentFormPartOneProps { interface ISegmentFormPartOneProps {
name: string; name: string;
@ -65,6 +67,7 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
clearErrors, clearErrors,
setCurrentStep, setCurrentStep,
}) => { }) => {
const projectId = useOptionalPathParam('projectId');
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const { projects } = useProjects(); const { projects } = useProjects();
@ -105,7 +108,10 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
data-testid={SEGMENT_DESC_ID} data-testid={SEGMENT_DESC_ID}
/> />
<ConditionallyRender <ConditionallyRender
condition={Boolean(uiConfig.flags.projectScopedSegments)} condition={
Boolean(uiConfig.flags.projectScopedSegments) &&
!projectId
}
show={ show={
<> <>
<StyledInputDescription> <StyledInputDescription>
@ -141,7 +147,7 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
<StyledCancelButton <StyledCancelButton
type="button" type="button"
onClick={() => { onClick={() => {
navigate('/segments'); navigate(GO_BACK);
}} }}
> >
Cancel Cancel

View File

@ -29,6 +29,7 @@ import {
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount'; import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
import { GO_BACK } from 'constants/navigate';
interface ISegmentFormPartTwoProps { interface ISegmentFormPartTwoProps {
constraints: IConstraint[]; constraints: IConstraint[];
@ -214,7 +215,7 @@ export const SegmentFormStepTwo: React.FC<ISegmentFormPartTwoProps> = ({
<StyledCancelButton <StyledCancelButton
type="button" type="button"
onClick={() => { onClick={() => {
navigate('/segments'); navigate(GO_BACK);
}} }}
> >
Cancel Cancel

View File

@ -28,8 +28,10 @@ import { Search } from 'component/common/Search/Search';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
export const SegmentTable = () => { export const SegmentTable = () => {
const projectId = useOptionalPathParam('projectId');
const { segments, loading } = useSegments(); const { segments, loading } = useSegments();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
@ -39,17 +41,22 @@ export const SegmentTable = () => {
}); });
const data = useMemo(() => { const data = useMemo(() => {
return ( if (!segments) {
segments ?? return Array(5).fill({
Array(5).fill({
name: 'Segment name', name: 'Segment name',
description: 'Segment descripton', description: 'Segment descripton',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
createdBy: 'user', createdBy: 'user',
projectId: 'Project', projectId: 'Project',
}) });
); }
}, [segments]);
if (projectId) {
return segments.filter(({ project }) => project === projectId);
}
return segments;
}, [segments, projectId]);
const { const {
getTableProps, getTableProps,
@ -85,7 +92,9 @@ export const SegmentTable = () => {
columns: ['createdAt', 'createdBy'], columns: ['createdAt', 'createdBy'],
}, },
{ {
condition: !Boolean(uiConfig.flags.projectScopedSegments), condition:
Boolean(projectId) ||
!Boolean(uiConfig.flags.projectScopedSegments),
columns: ['project'], columns: ['project'],
}, },
], ],

View File

@ -23,7 +23,7 @@ exports[`renders an empty list correctly 1`] = `
data-loading={true} data-loading={true}
> >
<h1 <h1
className="MuiTypography-root MuiTypography-h1 css-1pr8obe-MuiTypography-root" className="MuiTypography-root MuiTypography-h1 css-1jqnoga-MuiTypography-root"
> >
Tag types (5) Tag types (5)
</h1> </h1>