1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: ui for external link templates (#9945)

Support for project link templates to the frontend UI
This commit is contained in:
Tymoteusz Czech 2025-05-12 11:05:04 +02:00 committed by GitHub
parent ea26e008d0
commit 5614cb56d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 415 additions and 71 deletions

View File

@ -15,11 +15,8 @@ import {
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { CreateButton } from 'component/common/CreateButton/CreateButton'; import { CreateButton } from 'component/common/CreateButton/CreateButton';
import type { IPermissionButtonProps } from 'component/common/PermissionButton/PermissionButton'; import type { IPermissionButtonProps } from 'component/common/PermissionButton/PermissionButton';
import type { FeatureNamingType } from 'interfaces/project';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
import { NamingPatternInfo } from './NamingPatternInfo'; import { NamingPatternInfo } from './NamingPatternInfo';
import type { CreateFeatureNamingPatternSchema } from 'openapi';
type NamingPattern = FeatureNamingType;
type FormProps = { type FormProps = {
createButtonProps: IPermissionButtonProps; createButtonProps: IPermissionButtonProps;
@ -35,7 +32,7 @@ type FormProps = {
setDescription: (newDescription: string) => void; setDescription: (newDescription: string) => void;
setName: (newName: string) => void; setName: (newName: string) => void;
validateName?: () => void; validateName?: () => void;
namingPattern?: NamingPattern; namingPattern?: CreateFeatureNamingPatternSchema;
}; };
export const DialogFormTemplate: React.FC<FormProps> = ({ export const DialogFormTemplate: React.FC<FormProps> = ({
@ -54,8 +51,6 @@ export const DialogFormTemplate: React.FC<FormProps> = ({
createButtonProps, createButtonProps,
validateName = () => {}, validateName = () => {},
}) => { }) => {
const displayNamingPattern = Boolean(namingPattern?.pattern);
return ( return (
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
<TopGrid> <TopGrid>
@ -66,7 +61,7 @@ export const DialogFormTemplate: React.FC<FormProps> = ({
label={`${resource} name`} label={`${resource} name`}
aria-required aria-required
aria-details={ aria-details={
displayNamingPattern namingPattern?.pattern
? 'naming-pattern-info' ? 'naming-pattern-info'
: undefined : undefined
} }
@ -89,10 +84,9 @@ export const DialogFormTemplate: React.FC<FormProps> = ({
size='medium' size='medium'
/> />
<ConditionallyRender {namingPattern?.pattern ? (
condition={displayNamingPattern} <NamingPatternInfo naming={namingPattern!} />
show={<NamingPatternInfo naming={namingPattern!} />} ) : null}
/>
</NameContainer> </NameContainer>
<DescriptionContainer> <DescriptionContainer>
<StyledInput <StyledInput

View File

@ -5,8 +5,8 @@ import {
styled, styled,
} from '@mui/material'; } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { FeatureNamingType } from 'interfaces/project';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import type { CreateFeatureNamingPatternSchema } from 'openapi';
const StyledFlagNamingInfo = styled('article')(({ theme }) => ({ const StyledFlagNamingInfo = styled('article')(({ theme }) => ({
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
@ -35,7 +35,7 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({
})); }));
type Props = { type Props = {
naming: FeatureNamingType; naming: CreateFeatureNamingPatternSchema;
}; };
export const NamingPatternInfo: React.FC<Props> = ({ naming }) => { export const NamingPatternInfo: React.FC<Props> = ({ naming }) => {

View File

@ -1,6 +1,6 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { FeatureNamingType } from 'interfaces/project'; import type { CreateFeatureNamingPatternSchema } from 'openapi';
const StyledFlagNamingInfo = styled('article')(({ theme }) => ({ const StyledFlagNamingInfo = styled('article')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
@ -25,7 +25,7 @@ const StyledFlagNamingInfo = styled('article')(({ theme }) => ({
})); }));
type Props = { type Props = {
featureNaming: FeatureNamingType; featureNaming: CreateFeatureNamingPatternSchema;
}; };
export const FeatureNamingPatternInfo: React.FC<Props> = ({ export const FeatureNamingPatternInfo: React.FC<Props> = ({

View File

@ -50,7 +50,8 @@ const FeatureSettingsProjectConfirm = ({
const hasSameEnvironments: boolean = useMemo(() => { const hasSameEnvironments: boolean = useMemo(() => {
return arraysHaveSameItems( return arraysHaveSameItems(
feature.environments.map((env) => env.name), feature.environments.map((env) => env.name),
project.environments.map((projectEnv) => projectEnv.environment), project.environments?.map((projectEnv) => projectEnv.environment) ||
[],
); );
}, [feature, project]); }, [feature, project]);

View File

@ -32,7 +32,7 @@ export const ImportOptions: FC<IImportOptionsProps> = ({
onChange, onChange,
}) => { }) => {
const { project: projectInfo } = useProjectOverview(project); const { project: projectInfo } = useProjectOverview(project);
const environmentOptions = projectInfo.environments.map( const environmentOptions = projectInfo.environments?.map(
({ environment }) => ({ ({ environment }) => ({
key: environment, key: environment,
label: environment, label: environment,
@ -41,7 +41,7 @@ export const ImportOptions: FC<IImportOptionsProps> = ({
); );
useEffect(() => { useEffect(() => {
if (environment === '' && environmentOptions[0]) { if (environment === '' && environmentOptions?.[0]) {
onChange(environmentOptions[0].key); onChange(environmentOptions[0].key);
} }
}, [JSON.stringify(environmentOptions)]); }, [JSON.stringify(environmentOptions)]);
@ -54,7 +54,7 @@ export const ImportOptions: FC<IImportOptionsProps> = ({
</ImportOptionsDescription> </ImportOptionsDescription>
<GeneralSelect <GeneralSelect
sx={{ width: '180px' }} sx={{ width: '180px' }}
options={environmentOptions} options={environmentOptions || []}
onChange={onChange} onChange={onChange}
label={'Environment'} label={'Environment'}
value={environment} value={environment}

View File

@ -190,8 +190,8 @@ const CreateFeatureDialogContent = ({
count: totalFlags ?? 0, count: totalFlags ?? 0,
}, },
project: { project: {
limit: projectInfo.featureLimit, limit: projectInfo.featureLimit || undefined,
count: featuresCount(projectInfo), count: featuresCount(projectInfo) ?? 0,
}, },
}); });

View File

@ -282,7 +282,7 @@ export const Project = () => {
<StyledDiv> <StyledDiv>
<StyledFavoriteIconButton <StyledFavoriteIconButton
onClick={onFavorite} onClick={onFavorite}
isFavorite={project?.favorite} isFavorite={project?.favorite || false}
/> />
<StyledProjectTitle> <StyledProjectTitle>
<ConditionallyRender <ConditionallyRender

View File

@ -1,4 +1,9 @@
import React, { useEffect } from 'react'; import React, {
type Dispatch,
type ReactNode,
type SetStateAction,
useEffect,
} from 'react';
import Select from 'component/common/select'; import Select from 'component/common/select';
import type { ProjectMode } from '../hooks/useProjectEnterpriseSettingsForm'; import type { ProjectMode } from '../hooks/useProjectEnterpriseSettingsForm';
import { Box, InputAdornment, styled, TextField } from '@mui/material'; import { Box, InputAdornment, styled, TextField } from '@mui/material';
@ -6,6 +11,9 @@ import { CollaborationModeTooltip } from './CollaborationModeTooltip';
import Input from 'component/common/Input/Input'; import Input from 'component/common/Input/Input';
import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip'; import { FeatureFlagNamingTooltip } from './FeatureFlagNamingTooltip';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import type { ProjectLinkTemplateSchema } from 'openapi';
import { useUiFlag } from 'hooks/useUiFlag';
import ProjectLinkTemplates from './ProjectLinkTemplates/ProjectLinkTemplates';
interface IProjectEnterpriseSettingsForm { interface IProjectEnterpriseSettingsForm {
projectId: string; projectId: string;
@ -13,14 +21,16 @@ interface IProjectEnterpriseSettingsForm {
featureNamingPattern?: string; featureNamingPattern?: string;
featureNamingExample?: string; featureNamingExample?: string;
featureNamingDescription?: string; featureNamingDescription?: string;
setFeatureNamingPattern?: React.Dispatch<React.SetStateAction<string>>; linkTemplates?: ProjectLinkTemplateSchema[];
setFeatureNamingExample?: React.Dispatch<React.SetStateAction<string>>; setFeatureNamingPattern?: Dispatch<SetStateAction<string>>;
setFeatureNamingDescription?: React.Dispatch<React.SetStateAction<string>>; setFeatureNamingExample?: Dispatch<SetStateAction<string>>;
setProjectMode?: React.Dispatch<React.SetStateAction<ProjectMode>>; setFeatureNamingDescription?: Dispatch<SetStateAction<string>>;
setProjectMode?: Dispatch<SetStateAction<ProjectMode>>;
setLinkTemplates?: Dispatch<SetStateAction<ProjectLinkTemplateSchema[]>>;
handleSubmit: (e: any) => void; handleSubmit: (e: any) => void;
errors: { [key: string]: string }; errors: { [key: string]: string };
clearErrors: () => void; clearErrors: () => void;
children?: React.ReactNode; children?: ReactNode;
} }
const StyledForm = styled('form')(({ theme }) => ({ const StyledForm = styled('form')(({ theme }) => ({
@ -128,10 +138,12 @@ const ProjectEnterpriseSettingsForm: React.FC<
featureNamingExample, featureNamingExample,
featureNamingPattern, featureNamingPattern,
featureNamingDescription, featureNamingDescription,
linkTemplates = [],
setFeatureNamingExample, setFeatureNamingExample,
setFeatureNamingPattern, setFeatureNamingPattern,
setFeatureNamingDescription, setFeatureNamingDescription,
setProjectMode, setProjectMode,
setLinkTemplates,
errors, errors,
}) => { }) => {
const { setPreviousPattern, trackPattern } = const { setPreviousPattern, trackPattern } =
@ -143,6 +155,8 @@ const ProjectEnterpriseSettingsForm: React.FC<
{ key: 'private', label: 'private' }, { key: 'private', label: 'private' },
]; ];
const projectLinkTemplatesEnabled = useUiFlag('projectLinkTemplates');
useEffect(() => { useEffect(() => {
setPreviousPattern(featureNamingPattern || ''); setPreviousPattern(featureNamingPattern || '');
}, [projectId]); }, [projectId]);
@ -253,7 +267,7 @@ const ProjectEnterpriseSettingsForm: React.FC<
gap: 1, gap: 1,
}} }}
> >
<legend>Feature flag naming pattern?</legend> <legend>Feature flag naming pattern</legend>
<FeatureFlagNamingTooltip /> <FeatureFlagNamingTooltip />
</Box> </Box>
<StyledSubtitle> <StyledSubtitle>
@ -339,6 +353,13 @@ The flag name should contain the project name, the feature name, and the ticket
} }
/> />
</StyledFlagNamingContainer> </StyledFlagNamingContainer>
{projectLinkTemplatesEnabled && (
<ProjectLinkTemplates
linkTemplates={linkTemplates || []}
setLinkTemplates={setLinkTemplates}
/>
)}
</StyledFieldset> </StyledFieldset>
<StyledButtonContainer>{children}</StyledButtonContainer> <StyledButtonContainer>{children}</StyledButtonContainer>
</StyledForm> </StyledForm>

View File

@ -0,0 +1,105 @@
import { useState } from 'react';
import { Button, styled, TextField, Typography } from '@mui/material';
import type { ProjectLinkTemplateSchema } from 'openapi';
interface IProjectLinkTemplateEditorProps {
template?: ProjectLinkTemplateSchema;
onSave: (template: ProjectLinkTemplateSchema) => void;
onCancel: () => void;
isAdding: boolean;
}
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(3),
}));
const StyledDialogActions = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1.5),
}));
const ProjectLinkTemplateEditor = ({
template,
onSave,
onCancel,
isAdding,
}: IProjectLinkTemplateEditorProps) => {
const [templateTitle, setTemplateTitle] = useState(template?.title || '');
const [templateUrl, setTemplateUrl] = useState(template?.urlTemplate || '');
const [templateErrors, setTemplateErrors] = useState<{
title?: string;
url?: string;
}>({});
const validateTemplateForm = () => {
const errors: { title?: string; url?: string } = {};
if (!templateUrl) {
errors.url = 'URL template is required';
}
setTemplateErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (validateTemplateForm()) {
onSave({
title: templateTitle || null,
urlTemplate: templateUrl,
});
}
};
return (
<StyledContainer>
<Typography
variant='h5'
sx={(theme) => ({ fontSize: theme.typography.body1.fontSize })}
>
{isAdding ? 'Add new link template' : 'Edit link template'}
</Typography>
<TextField
label='Title (optional)'
fullWidth
value={templateTitle}
onChange={(e) => setTemplateTitle(e.target.value)}
placeholder='e.g., GitHub Issue, Ticket number'
helperText='A descriptive name for the link.'
size='small'
/>
<TextField
label='URL Template'
fullWidth
required
value={templateUrl}
onChange={(e) => setTemplateUrl(e.target.value)}
placeholder='https://github.com/{{project}}/{{feature}}'
helperText={
templateErrors.url ||
'You can optionally use placeholders {{project}} and {{feature}} that will be replaced with actual values.'
}
size='small'
error={Boolean(templateErrors.url)}
/>
<StyledDialogActions>
<Button variant='outlined' onClick={onCancel}>
Cancel
</Button>
<Button
variant='contained'
color='primary'
onClick={handleSave}
>
{isAdding ? 'Add' : 'Update'}
</Button>
</StyledDialogActions>
</StyledContainer>
);
};
export default ProjectLinkTemplateEditor;

View File

@ -0,0 +1,218 @@
import { useState, type Dispatch, type SetStateAction } from 'react';
import {
Box,
Button,
IconButton,
List,
ListItem,
ListItemText,
styled,
Tooltip,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import type { ProjectLinkTemplateSchema } from 'openapi';
import ProjectLinkTemplateEditor from './ProjectLinkTemplateEditor';
import { Truncator } from 'component/common/Truncator/Truncator';
interface IProjectLinkTemplatesProps {
linkTemplates: ProjectLinkTemplateSchema[];
setLinkTemplates?: Dispatch<SetStateAction<ProjectLinkTemplateSchema[]>>;
}
const StyledSubtitle = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
lineHeight: 1.25,
paddingBottom: theme.spacing(1),
}));
const StyledLinkTemplatesContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(4),
marginBottom: theme.spacing(2),
gap: theme.spacing(2),
}));
const StyledLinkTemplatesList = styled(List)(({ theme }) => ({
width: '100%',
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
margin: 0,
padding: 0,
}));
const StyledLinkTemplateItem = styled(ListItem)(({ theme }) => ({
borderBottom: `1px solid ${theme.palette.divider}`,
'&:last-child': {
borderBottom: 'none',
},
borderRight: 0,
}));
const ProjectLinkTemplates = ({
linkTemplates = [],
setLinkTemplates,
}: IProjectLinkTemplatesProps) => {
const [isAddingTemplate, setIsAddingTemplate] = useState(false);
const [editingTemplateIndex, setEditingTemplateIndex] = useState<
number | null
>(null);
const handleEditTemplate = (index: number) => {
setEditingTemplateIndex(index);
};
const handleSaveTemplate = (template: ProjectLinkTemplateSchema) => {
if (editingTemplateIndex !== null) {
const updatedTemplates = [...linkTemplates];
updatedTemplates[editingTemplateIndex] = template;
setLinkTemplates?.(updatedTemplates);
setEditingTemplateIndex(null);
} else {
setLinkTemplates?.([...linkTemplates, template]);
setIsAddingTemplate(false);
}
};
const handleCancelEdit = () => {
setEditingTemplateIndex(null);
setIsAddingTemplate(false);
};
const handleDeleteTemplate = (index: number) => {
const updatedTemplates = [...linkTemplates];
updatedTemplates.splice(index, 1);
setLinkTemplates?.(updatedTemplates);
};
return (
<StyledLinkTemplatesContainer>
<Box display='flex' alignItems='center' gap={1}>
<Typography variant='h4'>Project Link Templates</Typography>
<Tooltip
title={
<Box
sx={(theme) => ({
fontWeight: theme.typography.body1.fontWeight,
})}
>
<p>
Link templates can be automatically added to new
feature flags. They can include placeholders
like <code>{`{{project}}`}</code> and
<code>{`{{feature}}`}</code> that will be
replaced with actual values.
</p>
</Box>
}
>
<IconButton size='small' sx={{ ml: 1 }}>
<HelpOutlineIcon fontSize='small' />
</IconButton>
</Tooltip>
</Box>
<StyledSubtitle>
<p>
Define link templates that can be automatically added to new
feature flags in this project.
</p>
</StyledSubtitle>
{linkTemplates.length > 0 ? (
<StyledLinkTemplatesList>
{linkTemplates.map((template, index) => {
if (editingTemplateIndex === index) {
return (
<StyledLinkTemplateItem
key={index}
style={{ listStyleType: 'none' }}
>
<ProjectLinkTemplateEditor
template={template}
onSave={handleSaveTemplate}
onCancel={handleCancelEdit}
isAdding={false}
/>
</StyledLinkTemplateItem>
);
}
return (
<StyledLinkTemplateItem key={index}>
<ListItemText
primary={
template.title ? (
<Truncator>
{template.title}
</Truncator>
) : null
}
secondary={
<Truncator>
{template.urlTemplate}
</Truncator>
}
/>
<Box
sx={(theme) => ({
display: 'flex',
marginRight: theme.spacing(-1),
})}
>
<IconButton
edge='end'
aria-label='edit'
onClick={() =>
handleEditTemplate(index)
}
sx={{ margin: 0 }}
>
<EditIcon />
</IconButton>
<IconButton
edge='end'
aria-label='delete'
onClick={() =>
handleDeleteTemplate(index)
}
sx={{ margin: 0 }}
>
<DeleteIcon />
</IconButton>
</Box>
</StyledLinkTemplateItem>
);
})}
</StyledLinkTemplatesList>
) : null}
{isAddingTemplate && (
<ProjectLinkTemplateEditor
onSave={handleSaveTemplate}
onCancel={handleCancelEdit}
isAdding={true}
/>
)}
{!isAddingTemplate && editingTemplateIndex === null && (
<Box display='flex' justifyContent='flex-start'>
<Button
startIcon={<AddIcon />}
variant='outlined'
onClick={() => setIsAddingTemplate(true)}
>
Add link template
</Button>
</Box>
)}
</StyledLinkTemplatesContainer>
);
};
export default ProjectLinkTemplates;

View File

@ -59,9 +59,11 @@ const ProjectOverview: FC = () => {
/> />
<StyledProjectToggles> <StyledProjectToggles>
<ProjectFeatureToggles <ProjectFeatureToggles
environments={project.environments.map( environments={
(environment) => environment.environment, project.environments?.map(
)} (environment) => environment.environment,
) || []
}
/> />
</StyledProjectToggles> </StyledProjectToggles>
</StyledContentContainer> </StyledContentContainer>

View File

@ -61,7 +61,7 @@ export const ProjectDefaultStrategySettings = () => {
specific environment. These will be used when you enable a specific environment. These will be used when you enable a
toggle environment that has no strategies defined toggle environment that has no strategies defined
</StyledAlert> </StyledAlert>
{project?.environments.map((environment) => ( {project?.environments?.map((environment) => (
<ProjectEnvironment <ProjectEnvironment
environment={environment} environment={environment}
key={environment.environment} key={environment.environment}

View File

@ -37,7 +37,7 @@ export const useDefaultStrategy = (
}, },
}; };
const strategy = project.environments.find( const strategy = project.environments?.find(
(env) => env.environment === environmentId, (env) => env.environment === environmentId,
)?.defaultStrategy; )?.defaultStrategy;

View File

@ -49,7 +49,9 @@ const EditProject = () => {
condition={isEnterprise()} condition={isEnterprise()}
show={<UpdateEnterpriseSettings project={project} />} show={<UpdateEnterpriseSettings project={project} />}
/> />
<ArchiveProjectForm featureCount={featuresCount(project)} /> <ArchiveProjectForm
featureCount={featuresCount(project) ?? 0}
/>
</StyledFormContainer> </StyledFormContainer>
</> </>
); );

View File

@ -9,10 +9,10 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import ProjectEnterpriseSettingsForm from 'component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm'; import ProjectEnterpriseSettingsForm from 'component/project/Project/ProjectEnterpriseSettingsForm/ProjectEnterpriseSettingsForm';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import type { IProjectOverview } from 'component/../interfaces/project';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import type { ProjectOverviewSchema } from 'openapi';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
minHeight: 0, minHeight: 0,
@ -33,7 +33,7 @@ const StyledFormContainer = styled('div')(({ theme }) => ({
})); }));
interface IUpdateEnterpriseSettings { interface IUpdateEnterpriseSettings {
project: IProjectOverview; project: ProjectOverviewSchema;
} }
const EDIT_PROJECT_SETTINGS_BTN = 'EDIT_PROJECT_SETTINGS_BTN'; const EDIT_PROJECT_SETTINGS_BTN = 'EDIT_PROJECT_SETTINGS_BTN';
@ -65,18 +65,21 @@ export const UpdateEnterpriseSettings = ({
featureNamingExample, featureNamingExample,
featureNamingDescription, featureNamingDescription,
featureNamingPattern, featureNamingPattern,
linkTemplates,
setFeatureNamingPattern, setFeatureNamingPattern,
setFeatureNamingExample, setFeatureNamingExample,
setFeatureNamingDescription, setFeatureNamingDescription,
setProjectMode, setProjectMode,
setLinkTemplates,
getEnterpriseSettingsPayload, getEnterpriseSettingsPayload,
errors: settingsErrors = {}, errors: settingsErrors = {},
clearErrors: clearSettingsErrors, clearErrors: clearSettingsErrors,
} = useProjectEnterpriseSettingsForm( } = useProjectEnterpriseSettingsForm(
project.mode, project.mode,
project?.featureNaming?.pattern, project?.featureNaming?.pattern || undefined,
project?.featureNaming?.example, project?.featureNaming?.example || undefined,
project?.featureNaming?.description, project?.featureNaming?.description || undefined,
project?.linkTemplates || [],
); );
const formatProjectSettingsApiCode = () => { const formatProjectSettingsApiCode = () => {
@ -161,12 +164,14 @@ export const UpdateEnterpriseSettings = ({
featureNamingPattern={featureNamingPattern} featureNamingPattern={featureNamingPattern}
featureNamingExample={featureNamingExample} featureNamingExample={featureNamingExample}
featureNamingDescription={featureNamingDescription} featureNamingDescription={featureNamingDescription}
linkTemplates={linkTemplates}
setFeatureNamingPattern={setFeatureNamingPattern} setFeatureNamingPattern={setFeatureNamingPattern}
setFeatureNamingExample={setFeatureNamingExample} setFeatureNamingExample={setFeatureNamingExample}
setFeatureNamingDescription={ setFeatureNamingDescription={
setFeatureNamingDescription setFeatureNamingDescription
} }
setProjectMode={setProjectMode} setProjectMode={setProjectMode}
setLinkTemplates={setLinkTemplates}
handleSubmit={handleEditProjectSettings} handleSubmit={handleEditProjectSettings}
errors={settingsErrors} errors={settingsErrors}
clearErrors={clearSettingsErrors} clearErrors={clearSettingsErrors}

View File

@ -14,10 +14,10 @@ import useToast from 'hooks/useToast';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import type { IProjectOverview } from 'interfaces/project';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import type { ProjectOverviewSchema } from 'openapi';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
minHeight: 0, minHeight: 0,
@ -38,7 +38,7 @@ const StyledFormContainer = styled('div')(({ theme }) => ({
})); }));
interface IUpdateProject { interface IUpdateProject {
project: IProjectOverview; project: ProjectOverviewSchema;
} }
const EDIT_PROJECT_BTN = 'EDIT_PROJECT_BTN'; const EDIT_PROJECT_BTN = 'EDIT_PROJECT_BTN';
export const UpdateProject = ({ project }: IUpdateProject) => { export const UpdateProject = ({ project }: IUpdateProject) => {
@ -66,7 +66,7 @@ export const UpdateProject = ({ project }: IUpdateProject) => {
} = useProjectForm( } = useProjectForm(
id, id,
project.name, project.name,
project.description, project.description || undefined,
defaultStickiness, defaultStickiness,
String(project.featureLimit), String(project.featureLimit),
); );

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ProjectLinkTemplateSchema } from 'openapi';
export type ProjectMode = 'open' | 'protected' | 'private'; export type ProjectMode = 'open' | 'protected' | 'private';
const useProjectEnterpriseSettingsForm = ( const useProjectEnterpriseSettingsForm = (
@ -6,6 +7,7 @@ const useProjectEnterpriseSettingsForm = (
initialFeatureNamingPattern = '', initialFeatureNamingPattern = '',
initialFeatureNamingExample = '', initialFeatureNamingExample = '',
initialFeatureNamingDescription = '', initialFeatureNamingDescription = '',
initialLinkTemplates: ProjectLinkTemplateSchema[] = [],
) => { ) => {
const [projectMode, setProjectMode] = const [projectMode, setProjectMode] =
useState<ProjectMode>(initialProjectMode); useState<ProjectMode>(initialProjectMode);
@ -20,6 +22,9 @@ const useProjectEnterpriseSettingsForm = (
initialFeatureNamingDescription, initialFeatureNamingDescription,
); );
const [linkTemplates, setLinkTemplates] =
useState<ProjectLinkTemplateSchema[]>(initialLinkTemplates);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
useEffect(() => { useEffect(() => {
@ -38,6 +43,10 @@ const useProjectEnterpriseSettingsForm = (
setFeatureNamingDescription(initialFeatureNamingDescription); setFeatureNamingDescription(initialFeatureNamingDescription);
}, [initialFeatureNamingDescription]); }, [initialFeatureNamingDescription]);
useEffect(() => {
setLinkTemplates(initialLinkTemplates);
}, [initialLinkTemplates]);
const getEnterpriseSettingsPayload = () => { const getEnterpriseSettingsPayload = () => {
return { return {
mode: projectMode, mode: projectMode,
@ -46,6 +55,7 @@ const useProjectEnterpriseSettingsForm = (
example: featureNamingExample, example: featureNamingExample,
description: featureNamingDescription, description: featureNamingDescription,
}, },
linkTemplates,
}; };
}; };
@ -58,10 +68,12 @@ const useProjectEnterpriseSettingsForm = (
featureNamingPattern, featureNamingPattern,
featureNamingExample, featureNamingExample,
featureNamingDescription, featureNamingDescription,
linkTemplates,
setFeatureNamingPattern, setFeatureNamingPattern,
setFeatureNamingExample, setFeatureNamingExample,
setFeatureNamingDescription, setFeatureNamingDescription,
setProjectMode, setProjectMode,
setLinkTemplates,
getEnterpriseSettingsPayload, getEnterpriseSettingsPayload,
clearErrors, clearErrors,
errors, errors,

View File

@ -75,7 +75,9 @@ const ProjectEnvironmentList = () => {
environments.map((environment) => ({ environments.map((environment) => ({
...environment, ...environment,
projectVisible: project?.environments projectVisible: project?.environments
.map((projectEnvironment) => projectEnvironment.environment) ?.map(
(projectEnvironment) => projectEnvironment.environment,
)
.includes(environment.name), .includes(environment.name),
})), })),
[environments, project?.environments], [environments, project?.environments],

View File

@ -1,15 +1,15 @@
import useSWR, { type SWRConfiguration } from 'swr'; import useSWR, { type SWRConfiguration } from 'swr';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { getProjectOverviewFetcher } from './getProjectOverviewFetcher'; import { getProjectOverviewFetcher } from './getProjectOverviewFetcher';
import type { IProjectOverview } from 'interfaces/project'; import type { ProjectOverviewSchema } from 'openapi';
const fallbackProject: IProjectOverview = { const fallbackProject: ProjectOverviewSchema = {
featureTypeCounts: [], featureTypeCounts: [],
environments: [], environments: [],
name: '', name: '',
health: 0, health: 0,
members: 0, members: 0,
version: '1', version: 1,
description: 'Default', description: 'Default',
favorite: false, favorite: false,
mode: 'open', mode: 'open',
@ -31,7 +31,7 @@ const fallbackProject: IProjectOverview = {
const useProjectOverview = (id: string, options: SWRConfiguration = {}) => { const useProjectOverview = (id: string, options: SWRConfiguration = {}) => {
const { KEY, fetcher } = getProjectOverviewFetcher(id); const { KEY, fetcher } = getProjectOverviewFetcher(id);
const { data, error, mutate } = useSWR<IProjectOverview>( const { data, error, mutate } = useSWR<ProjectOverviewSchema>(
KEY, KEY,
fetcher, fetcher,
options, options,
@ -54,10 +54,10 @@ export const useProjectOverviewNameOrId = (id: string): string => {
}; };
export const featuresCount = ( export const featuresCount = (
project: Pick<IProjectOverview, 'featureTypeCounts'>, project: Pick<ProjectOverviewSchema, 'featureTypeCounts'>,
) => { ) => {
return project.featureTypeCounts return project.featureTypeCounts
.map((count) => count.count) ?.map((count) => count.count)
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
}; };

View File

@ -1,4 +1,4 @@
import type { ProjectOverviewSchema, ProjectStatsSchema } from 'openapi'; import type { ProjectStatsSchema } from 'openapi';
import type { IFeatureFlagListItem } from './featureToggle'; import type { IFeatureFlagListItem } from './featureToggle';
import type { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef'; import type { ProjectEnvironmentType } from 'component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef';
import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnterpriseSettingsForm'; import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnterpriseSettingsForm';
@ -31,25 +31,6 @@ export interface IProject {
featureNaming?: FeatureNamingType; featureNaming?: FeatureNamingType;
} }
export interface IProjectOverview {
id?: string;
members: number;
version: string;
name: string;
description?: string;
environments: Array<ProjectEnvironmentType>;
health: number;
stats: ProjectStatsSchema;
featureTypeCounts: FeatureTypeCount[];
favorite: boolean;
mode: ProjectMode;
defaultStickiness: string;
featureLimit?: number;
featureNaming?: FeatureNamingType;
archivedAt?: Date;
onboardingStatus: ProjectOverviewSchema['onboardingStatus'];
}
export interface IProjectHealthReport extends IProject { export interface IProjectHealthReport extends IProject {
staleCount: number; staleCount: number;
potentiallyStaleCount: number; potentiallyStaleCount: number;

View File

@ -94,6 +94,7 @@ export type UiFlags = {
cleanupReminder?: boolean; cleanupReminder?: boolean;
registerFrontendClient?: boolean; registerFrontendClient?: boolean;
featureLinks?: boolean; featureLinks?: boolean;
projectLinkTemplates?: boolean;
}; };
export interface IVersionInfo { export interface IVersionInfo {