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  Adds the "segments" option to project settings, providing the usual CRUD operations but scoped to the specific project.
This commit is contained in:
parent
f685c1059f
commit
292d6a7f60
@ -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)]: {
|
||||||
|
@ -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 }) => ({
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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 />}
|
||||||
|
@ -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,
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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',
|
||||||
|
@ -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' }}
|
||||||
>
|
>
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user