1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-12 13:48:35 +02:00

feat: welcome to project onboarding status rendering (#8076)

![image](https://github.com/user-attachments/assets/8a828f95-10bd-4294-b2f4-1d7f4e7f1a3d)
This commit is contained in:
Jaanus Sellin 2024-09-04 12:17:33 +03:00 committed by GitHub
parent 896707f7d5
commit f41a688edb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 280 additions and 17 deletions

View File

@ -28,6 +28,7 @@ const setupApi = () => {
testServerRoute(server, '/api/admin/ui-config', { testServerRoute(server, '/api/admin/ui-config', {
flags: { flags: {
flagCreator: true, flagCreator: true,
onboardingUI: true,
}, },
}); });
testServerRoute(server, '/api/admin/tags', { testServerRoute(server, '/api/admin/tags', {
@ -162,3 +163,49 @@ test.skip('filters by flag author', async () => {
expect(window.location.href).toContain('createdBy=IS%3A1'); expect(window.location.href).toContain('createdBy=IS%3A1');
}); });
test('Project is onboarded', async () => {
const projectId = 'default';
setupApi();
testServerRoute(server, '/api/admin/projects/default/overview', {
onboardingStatus: {
status: 'onboarded',
},
});
render(
<Routes>
<Route
path={'/projects/:projectId'}
element={<ProjectFeatureToggles environments={[]} />}
/>
</Routes>,
{
route: `/projects/${projectId}`,
},
);
expect(
screen.queryByText('Welcome to your project'),
).not.toBeInTheDocument();
});
test('Project is not onboarded', async () => {
const projectId = 'default';
setupApi();
testServerRoute(server, '/api/admin/projects/default/overview', {
onboardingStatus: {
status: 'onboarding-started',
},
});
render(
<Routes>
<Route
path={'/projects/:projectId'}
element={<ProjectFeatureToggles environments={[]} />}
/>
</Routes>,
{
route: `/projects/${projectId}`,
},
);
await screen.findByText('Welcome to your project');
});

View File

@ -42,6 +42,7 @@ import { AvatarCell } from './AvatarCell';
import { ProjectOnboarding } from './ProjectOnboarding/ProjectOnboarding'; import { ProjectOnboarding } from './ProjectOnboarding/ProjectOnboarding';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ConnectSdkDialog } from '../../../onboarding/ConnectSdkDialog'; import { ConnectSdkDialog } from '../../../onboarding/ConnectSdkDialog';
interface IPaginatedProjectFeatureTogglesProps { interface IPaginatedProjectFeatureTogglesProps {
@ -65,6 +66,7 @@ export const ProjectFeatureToggles = ({
}: IPaginatedProjectFeatureTogglesProps) => { }: IPaginatedProjectFeatureTogglesProps) => {
const onboardingUIEnabled = useUiFlag('onboardingUI'); const onboardingUIEnabled = useUiFlag('onboardingUI');
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId);
const { const {
features, features,
@ -396,7 +398,10 @@ export const ProjectFeatureToggles = ({
return ( return (
<Container> <Container>
<ConditionallyRender <ConditionallyRender
condition={onboardingUIEnabled} condition={
onboardingUIEnabled &&
project.onboardingStatus.status !== 'onboarded'
}
show={<ProjectOnboarding projectId={projectId} />} show={<ProjectOnboarding projectId={projectId} />}
/> />
<PageContent <PageContent

View File

@ -0,0 +1,52 @@
import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { WelcomeToProject } from './WelcomeToProject';
import { screen } from '@testing-library/react';
const server = testServerSetup();
test('Project can start onboarding', async () => {
const projectId = 'default';
testServerRoute(server, '/api/admin/projects/default/overview', {
onboarding: {
onboardingStatus: 'onboarding-started',
},
});
render(
<Routes>
<Route
path={'/projects/:projectId'}
element={<WelcomeToProject projectId={projectId} />}
/>
</Routes>,
{
route: `/projects/${projectId}`,
},
);
await screen.findByText('The project currently holds no feature toggles.');
});
test('Project can connect SDK', async () => {
const projectId = 'default';
testServerRoute(server, '/api/admin/projects/default/overview', {
onboardingStatus: {
status: 'first-flag-created',
feature: 'default-feature',
},
});
render(
<Routes>
<Route
path={'/projects/:projectId'}
element={<WelcomeToProject projectId={projectId} />}
/>
</Routes>,
{
route: `/projects/${projectId}`,
},
);
await screen.findByText(
'Your project is not yet connected to any SDK. In order to start using your feature flag connect an SDK to the project.',
);
});

View File

@ -1,13 +1,24 @@
import { styled, Typography } from '@mui/material'; import { styled, Typography, useTheme } from '@mui/material';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { FlagCreationButton } from '../ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader'; import { FlagCreationButton } from '../ProjectFeatureTogglesHeader/ProjectFeatureTogglesHeader';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
import { Link } from 'react-router-dom';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
interface IWelcomeToProjectProps { interface IWelcomeToProjectProps {
projectId: string; projectId: string;
} }
interface IExistingFlagsProps {
featureId: string;
projectId: string;
}
const Container = styled('div')(({ theme }) => ({ const Container = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -44,7 +55,7 @@ const TitleContainer = styled('div')(({ theme }) => ({
fontWeight: 'bold', fontWeight: 'bold',
})); }));
const CircleContainer = styled('span')(({ theme }) => ({ const NeutralCircleContainer = styled('span')(({ theme }) => ({
width: '28px', width: '28px',
height: '28px', height: '28px',
display: 'flex', display: 'flex',
@ -54,7 +65,27 @@ const CircleContainer = styled('span')(({ theme }) => ({
borderRadius: '50%', borderRadius: '50%',
})); }));
const MainCircleContainer = styled(NeutralCircleContainer)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
color: theme.palette.background.paper,
}));
const TypeCircleContainer = styled(MainCircleContainer)(({ theme }) => ({
borderRadius: '20%',
}));
const StyledLink = styled(Link)({
fontWeight: 'bold',
textDecoration: 'none',
display: 'flex',
justifyContent: 'center',
});
export const WelcomeToProject = ({ projectId }: IWelcomeToProjectProps) => { export const WelcomeToProject = ({ projectId }: IWelcomeToProjectProps) => {
const { project } = useProjectOverview(projectId);
const isFirstFlagCreated =
project.onboardingStatus.status === 'first-flag-created';
return ( return (
<Container> <Container>
<TitleBox> <TitleBox>
@ -67,21 +98,19 @@ export const WelcomeToProject = ({ projectId }: IWelcomeToProjectProps) => {
</TitleBox> </TitleBox>
<Actions> <Actions>
<ActionBox> <ActionBox>
<TitleContainer> {project.onboardingStatus.status ===
<CircleContainer>1</CircleContainer> 'first-flag-created' ? (
Create a feature flag <ExistingFlag
</TitleContainer> projectId={projectId}
<Typography> featureId={project.onboardingStatus.feature}
<div> />
The project currently holds no feature toggles. ) : (
</div> <CreateFlag />
<div>Create a feature flag to get started.</div> )}
</Typography>
<FlagCreationButton />
</ActionBox> </ActionBox>
<ActionBox> <ActionBox>
<TitleContainer> <TitleContainer>
<CircleContainer>2</CircleContainer> <NeutralCircleContainer>2</NeutralCircleContainer>
Connect an SDK Connect an SDK
</TitleContainer> </TitleContainer>
<Typography> <Typography>
@ -92,7 +121,7 @@ export const WelcomeToProject = ({ projectId }: IWelcomeToProjectProps) => {
maxWidth='200px' maxWidth='200px'
projectId={projectId} projectId={projectId}
Icon={Add} Icon={Add}
disabled={true} disabled={!isFirstFlagCreated}
permission={CREATE_FEATURE} permission={CREATE_FEATURE}
> >
Connect SDK Connect SDK
@ -102,3 +131,55 @@ export const WelcomeToProject = ({ projectId }: IWelcomeToProjectProps) => {
</Container> </Container>
); );
}; };
const CreateFlag = () => {
return (
<>
<TitleContainer>
<NeutralCircleContainer>1</NeutralCircleContainer>
Create a feature flag
</TitleContainer>
<Typography>
<div>The project currently holds no feature toggles.</div>
<div>Create a feature flag to get started.</div>
</Typography>
<FlagCreationButton />
</>
);
};
const ExistingFlag = ({ featureId, projectId }: IExistingFlagsProps) => {
const theme = useTheme();
const { feature } = useFeature(projectId, featureId);
const { featureTypes } = useFeatureTypes();
const IconComponent = getFeatureTypeIcons(feature.type);
const typeName = featureTypes.find(
(featureType) => featureType.id === feature.type,
)?.name;
const typeTitle = `${typeName || feature.type} flag`;
return (
<>
<TitleContainer>
<MainCircleContainer></MainCircleContainer>
Create a feature flag
</TitleContainer>
<TitleContainer>
<HtmlTooltip arrow title={typeTitle} describeChild>
<TypeCircleContainer>
<IconComponent />
</TypeCircleContainer>
</HtmlTooltip>
<StyledLink
to={`/projects/${projectId}/features/${feature.name}`}
>
{feature.name}
</StyledLink>
</TitleContainer>
<Typography>
Your project is not yet connected to any SDK. In order to start
using your feature flag connect an SDK to the project.
</Typography>
</>
);
};

View File

@ -21,6 +21,9 @@ test('Show outdated SDKs and apps using them', async () => {
count: 57, count: 57,
}, },
], ],
onboardingStatus: {
status: 'onboarded',
},
}); });
render( render(
<Routes> <Routes>

View File

@ -24,6 +24,9 @@ const fallbackProject: IProjectOverview = {
projectActivityPastWindow: 0, projectActivityPastWindow: 0,
projectMembersAddedCurrentWindow: 0, projectMembersAddedCurrentWindow: 0,
}, },
onboardingStatus: {
status: 'onboarded',
},
}; };
const useProjectOverview = (id: string, options: SWRConfiguration = {}) => { const useProjectOverview = (id: string, options: SWRConfiguration = {}) => {

View File

@ -1,4 +1,4 @@
import type { ProjectStatsSchema } from 'openapi'; import type { ProjectOverviewSchema, 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';
@ -47,6 +47,7 @@ export interface IProjectOverview {
featureLimit?: number; featureLimit?: number;
featureNaming?: FeatureNamingType; featureNaming?: FeatureNamingType;
archivedAt?: Date; archivedAt?: Date;
onboardingStatus: ProjectOverviewSchema['onboardingStatus'];
} }
export interface IProjectHealthReport extends IProject { export interface IProjectHealthReport extends IProject {

View File

@ -942,6 +942,11 @@ export * from './projectInsightsSchemaHealth';
export * from './projectInsightsSchemaMembers'; export * from './projectInsightsSchemaMembers';
export * from './projectOverviewSchema'; export * from './projectOverviewSchema';
export * from './projectOverviewSchemaMode'; export * from './projectOverviewSchemaMode';
export * from './projectOverviewSchemaOnboardingStatus';
export * from './projectOverviewSchemaOnboardingStatusOneOf';
export * from './projectOverviewSchemaOnboardingStatusOneOfStatus';
export * from './projectOverviewSchemaOnboardingStatusOneOfThree';
export * from './projectOverviewSchemaOnboardingStatusOneOfThreeStatus';
export * from './projectRoleSchema'; export * from './projectRoleSchema';
export * from './projectRoleUsageSchema'; export * from './projectRoleUsageSchema';
export * from './projectSchema'; export * from './projectSchema';

View File

@ -7,6 +7,7 @@ import type { ProjectEnvironmentSchema } from './projectEnvironmentSchema';
import type { CreateFeatureNamingPatternSchema } from './createFeatureNamingPatternSchema'; import type { CreateFeatureNamingPatternSchema } from './createFeatureNamingPatternSchema';
import type { FeatureTypeCountSchema } from './featureTypeCountSchema'; import type { FeatureTypeCountSchema } from './featureTypeCountSchema';
import type { ProjectOverviewSchemaMode } from './projectOverviewSchemaMode'; import type { ProjectOverviewSchemaMode } from './projectOverviewSchemaMode';
import type { ProjectOverviewSchemaOnboardingStatus } from './projectOverviewSchemaOnboardingStatus';
import type { ProjectStatsSchema } from './projectStatsSchema'; import type { ProjectStatsSchema } from './projectStatsSchema';
/** /**
@ -50,6 +51,8 @@ export interface ProjectOverviewSchema {
mode?: ProjectOverviewSchemaMode; mode?: ProjectOverviewSchemaMode;
/** The name of this project */ /** The name of this project */
name: string; name: string;
/** The current onboarding status of the project. */
onboardingStatus: ProjectOverviewSchemaOnboardingStatus;
/** Project statistics */ /** Project statistics */
stats?: ProjectStatsSchema; stats?: ProjectStatsSchema;
/** /**

View File

@ -0,0 +1,14 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ProjectOverviewSchemaOnboardingStatusOneOf } from './projectOverviewSchemaOnboardingStatusOneOf';
import type { ProjectOverviewSchemaOnboardingStatusOneOfThree } from './projectOverviewSchemaOnboardingStatusOneOfThree';
/**
* The current onboarding status of the project.
*/
export type ProjectOverviewSchemaOnboardingStatus =
| ProjectOverviewSchemaOnboardingStatusOneOf
| ProjectOverviewSchemaOnboardingStatusOneOfThree;

View File

@ -0,0 +1,10 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ProjectOverviewSchemaOnboardingStatusOneOfStatus } from './projectOverviewSchemaOnboardingStatusOneOfStatus';
export type ProjectOverviewSchemaOnboardingStatusOneOf = {
status: ProjectOverviewSchemaOnboardingStatusOneOfStatus;
};

View File

@ -0,0 +1,14 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type ProjectOverviewSchemaOnboardingStatusOneOfStatus =
(typeof ProjectOverviewSchemaOnboardingStatusOneOfStatus)[keyof typeof ProjectOverviewSchemaOnboardingStatusOneOfStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ProjectOverviewSchemaOnboardingStatusOneOfStatus = {
'onboarding-started': 'onboarding-started',
onboarded: 'onboarded',
} as const;

View File

@ -0,0 +1,12 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus } from './projectOverviewSchemaOnboardingStatusOneOfThreeStatus';
export type ProjectOverviewSchemaOnboardingStatusOneOfThree = {
/** The name of the feature flag */
feature: string;
status: ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus;
};

View File

@ -0,0 +1,13 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus =
(typeof ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus)[keyof typeof ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ProjectOverviewSchemaOnboardingStatusOneOfThreeStatus = {
'first-flag-created': 'first-flag-created',
} as const;