mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
Fix/dora polish (#4645)
This PR includes: * Tests for retrieving lead time per feature toggle and project average * Feedback component
This commit is contained in:
parent
0b5a7b7d36
commit
26ade79d66
@ -37,6 +37,7 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi
|
||||
import { ImportModal } from './Import/ImportModal';
|
||||
import { IMPORT_BUTTON } from 'utils/testIds';
|
||||
import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics';
|
||||
|
||||
export const Project = () => {
|
||||
@ -61,18 +62,21 @@ export const Project = () => {
|
||||
path: basePath,
|
||||
name: 'overview',
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
{
|
||||
title: 'Health',
|
||||
path: `${basePath}/health`,
|
||||
name: 'health',
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
{
|
||||
title: 'Archive',
|
||||
path: `${basePath}/archive`,
|
||||
name: 'archive',
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
{
|
||||
title: 'Change requests',
|
||||
@ -80,24 +84,28 @@ export const Project = () => {
|
||||
name: 'change-request',
|
||||
isEnterprise: true,
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
{
|
||||
title: 'DORA Metrics',
|
||||
path: `${basePath}/dora`,
|
||||
title: 'Metrics',
|
||||
path: `${basePath}/metrics`,
|
||||
name: 'dora',
|
||||
flag: 'doraMetrics',
|
||||
new: true,
|
||||
},
|
||||
{
|
||||
title: 'Event log',
|
||||
path: `${basePath}/logs`,
|
||||
name: 'logs',
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
{
|
||||
title: 'Project settings',
|
||||
path: `${basePath}/settings`,
|
||||
name: 'settings',
|
||||
flag: undefined,
|
||||
new: false,
|
||||
},
|
||||
]
|
||||
.filter(tab => {
|
||||
@ -216,10 +224,28 @@ export const Project = () => {
|
||||
tab.isEnterprise ? 'end' : undefined
|
||||
}
|
||||
icon={
|
||||
(tab.isEnterprise &&
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={tab.new}
|
||||
show={
|
||||
<Badge
|
||||
sx={{
|
||||
position:
|
||||
'absolute',
|
||||
top: 10,
|
||||
right: 20,
|
||||
}}
|
||||
color="success"
|
||||
>
|
||||
New
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{(tab.isEnterprise &&
|
||||
isPro() &&
|
||||
enterpriseIcon) ||
|
||||
undefined
|
||||
undefined}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@ -260,7 +286,7 @@ export const Project = () => {
|
||||
element={<ChangeRequestOverview />}
|
||||
/>
|
||||
<Route path="settings/*" element={<ProjectSettings />} />
|
||||
<Route path="dora" element={<ProjectDoraMetrics />} />
|
||||
<Route path="metrics" element={<ProjectDoraMetrics />} />
|
||||
<Route path="*" element={<ProjectOverview />} />
|
||||
</Routes>
|
||||
<ImportModal
|
||||
|
@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Button, Divider, Typography, styled } from '@mui/material';
|
||||
import { PermMedia, Send } from '@mui/icons-material';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
const StyledOuterContainer = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
padding: theme.spacing(2, 4),
|
||||
borderRadius: theme.shape.borderRadiusLarge,
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledBtnContainer = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
marginTop: theme.spacing(3),
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledFlexBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing(1),
|
||||
marginRight: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const StyledIconWrapper = styled(Box)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
marginRight: theme.spacing(1),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const StyledHeader = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.mainHeader,
|
||||
marginBottom: theme.spacing(2),
|
||||
fontWeight: 'bold',
|
||||
}));
|
||||
|
||||
const StyledLink = styled('a')(({ theme }) => ({
|
||||
textDecoration: 'none',
|
||||
}));
|
||||
|
||||
export const ProjectDoraFeedback = () => {
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const { value, setValue } = createLocalStorage(
|
||||
`project:metrics:plausible`,
|
||||
{ sent: false }
|
||||
);
|
||||
const [metrics, setMetrics] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(metrics);
|
||||
}, [metrics]);
|
||||
|
||||
const onBtnClick = (type: string) => {
|
||||
try {
|
||||
trackEvent('project-metrics', {
|
||||
props: {
|
||||
eventType: type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!Boolean(metrics.sent)) {
|
||||
setMetrics({ sent: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error sending metrics');
|
||||
}
|
||||
};
|
||||
|
||||
const recipientEmail = 'recipient@example.com';
|
||||
const emailSubject = "I'd like to get involved";
|
||||
const emailBody = `Hello Unleash,\n\nI just saw the new metrics page you are experimenting with in Unleash. I'd like to be involved in user tests and give my feedback on this feature.\n\nRegards,\n`;
|
||||
|
||||
const mailtoURL = `mailto:${recipientEmail}?subject=${encodeURIComponent(
|
||||
emailSubject
|
||||
)}&body=${encodeURIComponent(emailBody)}`;
|
||||
|
||||
return (
|
||||
<StyledOuterContainer>
|
||||
<StyledHeader variant="h1">
|
||||
We are trying something experimental!
|
||||
</StyledHeader>
|
||||
<Typography>
|
||||
We are considering adding project metrics to see how a project
|
||||
performs. As a first step, we have added a{' '}
|
||||
<i>lead time for changes</i> indicator that is calculated per
|
||||
feature toggle based on the creation of the feature toggle and
|
||||
when it was first turned on in an environment of type
|
||||
production.
|
||||
</Typography>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={!metrics.sent}
|
||||
show={
|
||||
<StyledBtnContainer>
|
||||
{' '}
|
||||
<Typography>Is this useful to you?</Typography>
|
||||
<StyledBtnContainer>
|
||||
<Button
|
||||
sx={theme => ({
|
||||
marginRight: theme.spacing(1),
|
||||
})}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => onBtnClick('useful')}
|
||||
>
|
||||
Yes, I like the direction
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => onBtnClick('not useful')}
|
||||
>
|
||||
No, I don't see value in this
|
||||
</Button>
|
||||
</StyledBtnContainer>
|
||||
</StyledBtnContainer>
|
||||
}
|
||||
elseShow={
|
||||
<Typography sx={theme => ({ marginTop: theme.spacing(3) })}>
|
||||
Thank you for the feedback. Feel free to check out the
|
||||
sketches and leave comments, or get in touch with our UX
|
||||
team if you'd like to be involved in usertests and the
|
||||
development of this feature.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<StyledDivider />
|
||||
<StyledFlexBox>
|
||||
<StyledFlexBox>
|
||||
<StyledIconWrapper>
|
||||
<PermMedia />
|
||||
</StyledIconWrapper>
|
||||
<StyledLink
|
||||
href="https://app.mural.co/t/unleash2757/m/unleash2757/1694006366166/fae4aa4f796de214bdb3ae2d5ce9de934b68fdfb?sender=u777a1f5633477c329eae3448"
|
||||
target="_blank"
|
||||
rel="noopener noreferer"
|
||||
>
|
||||
View sketches
|
||||
</StyledLink>
|
||||
</StyledFlexBox>
|
||||
|
||||
<StyledFlexBox>
|
||||
<StyledIconWrapper>
|
||||
<Send />
|
||||
</StyledIconWrapper>
|
||||
<StyledLink href={mailtoURL}>
|
||||
Get involved with our UX team
|
||||
</StyledLink>
|
||||
</StyledFlexBox>
|
||||
</StyledFlexBox>
|
||||
</StyledOuterContainer>
|
||||
);
|
||||
};
|
@ -15,6 +15,7 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { ProjectDoraFeedback } from './ProjectDoraFeedback/ProjectDoraFeedback';
|
||||
|
||||
const resolveDoraMetrics = (input: number) => {
|
||||
const ONE_MONTH = 30;
|
||||
@ -42,7 +43,7 @@ export const ProjectDoraMetrics = () => {
|
||||
if (loading) {
|
||||
return Array(5).fill({
|
||||
name: 'Featurename',
|
||||
timeToProduction: 'Tag type for production',
|
||||
timeToProduction: 'Data for production',
|
||||
});
|
||||
}
|
||||
|
||||
@ -54,7 +55,7 @@ export const ProjectDoraMetrics = () => {
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
width: '50%',
|
||||
width: '40%',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
@ -90,7 +91,23 @@ export const ProjectDoraMetrics = () => {
|
||||
{original.timeToProduction} days
|
||||
</Box>
|
||||
),
|
||||
width: 150,
|
||||
width: 200,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Header: `Deviation`,
|
||||
id: 'Deviation from average',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
data-loading
|
||||
>
|
||||
{dora.projectAverage - original.timeToProduction} days
|
||||
</Box>
|
||||
),
|
||||
width: 300,
|
||||
disableGlobalFilter: true,
|
||||
disableSortBy: true,
|
||||
},
|
||||
@ -143,11 +160,13 @@ export const ProjectDoraMetrics = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectDoraFeedback />
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Lead time for changes (per feature toggle)`}
|
||||
title={`Lead time for changes (per release toggle)`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@ -175,19 +194,15 @@ export const ProjectDoraMetrics = () => {
|
||||
condition={globalFilter?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No tags found matching “
|
||||
No features with data found “
|
||||
{globalFilter}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No tags available. Get started by adding one.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -45,6 +45,7 @@ export type CustomEvents =
|
||||
| 'feature-type-edit'
|
||||
| 'strategy-variants'
|
||||
| 'search-filter-suggestions'
|
||||
| 'project-metrics'
|
||||
| 'open-integration';
|
||||
|
||||
export const usePlausibleTracker = () => {
|
||||
|
@ -8,11 +8,16 @@ export const projectDoraMetricsSchema = {
|
||||
required: ['features'],
|
||||
description: 'A projects dora metrics',
|
||||
properties: {
|
||||
projectAverage: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The average time it takes a feature toggle to be enabled in production. The measurement unit is days.',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/doraFeaturesSchema' },
|
||||
description:
|
||||
'An array of objects containing feature toggle name and timeToProduction values',
|
||||
'An array of objects containing feature toggle name and timeToProduction values. The measurement unit of timeToProduction is days.',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
@ -719,17 +719,33 @@ export default class ProjectService {
|
||||
}
|
||||
|
||||
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
|
||||
const featureToggleNames = (await this.featureToggleStore.getAll()).map(
|
||||
(feature) => feature.name,
|
||||
const activeFeatureToggles = (
|
||||
await this.featureToggleStore.getAll({ project: projectId })
|
||||
).map((feature) => feature.name);
|
||||
|
||||
const archivedFeatureToggles = (
|
||||
await this.featureToggleStore.getAll({
|
||||
project: projectId,
|
||||
archived: true,
|
||||
})
|
||||
).map((feature) => feature.name);
|
||||
|
||||
const featureToggleNames = [
|
||||
...activeFeatureToggles,
|
||||
...archivedFeatureToggles,
|
||||
];
|
||||
|
||||
const projectAverage = calculateAverageTimeToProd(
|
||||
await this.projectStatsStore.getTimeToProdDates(projectId),
|
||||
);
|
||||
|
||||
const avgTimeToProductionPerToggle =
|
||||
const toggleAverage =
|
||||
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
|
||||
projectId,
|
||||
featureToggleNames,
|
||||
);
|
||||
|
||||
return { features: avgTimeToProductionPerToggle };
|
||||
return { features: toggleAverage, projectAverage: projectAverage };
|
||||
}
|
||||
|
||||
async changeRole(
|
||||
|
@ -1603,3 +1603,219 @@ test('should get correct amount of project members for current and past window',
|
||||
expect(result.updates.projectActivityCurrentWindow).toBe(6);
|
||||
expect(result.updates.projectActivityPastWindow).toBe(0);
|
||||
});
|
||||
|
||||
test('should return average time to production per toggle', async () => {
|
||||
const project = {
|
||||
id: 'average-time-to-prod-per-toggle',
|
||||
name: 'average-time-to-prod-per-toggle',
|
||||
mode: 'open' as const,
|
||||
defaultStickiness: 'clientId',
|
||||
};
|
||||
|
||||
await projectService.createProject(project, user.id);
|
||||
|
||||
const toggles = [
|
||||
{ name: 'average-prod-time-pt', subdays: 7 },
|
||||
{ name: 'average-prod-time-pt-2', subdays: 14 },
|
||||
{ name: 'average-prod-time-pt-3', subdays: 40 },
|
||||
{ name: 'average-prod-time-pt-4', subdays: 15 },
|
||||
{ name: 'average-prod-time-pt-5', subdays: 2 },
|
||||
];
|
||||
|
||||
const featureToggles = await Promise.all(
|
||||
toggles.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
featureToggles.map((toggle) => {
|
||||
return stores.eventStore.store(
|
||||
new FeatureEnvironmentEvent({
|
||||
enabled: true,
|
||||
project: project.id,
|
||||
featureName: toggle.name,
|
||||
environment: 'default',
|
||||
createdBy: 'Fredrik',
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
toggles.map((toggle) =>
|
||||
updateFeature(toggle.name, {
|
||||
created_at: subDays(new Date(), toggle.subdays),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await projectService.getDoraMetrics(project.id);
|
||||
|
||||
expect(result.features).toHaveLength(5);
|
||||
expect(result.features[0].timeToProduction).toBeTruthy();
|
||||
expect(result.projectAverage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return average time to production per toggle for a specific project', async () => {
|
||||
const project1 = {
|
||||
id: 'average-time-to-prod-per-toggle-1',
|
||||
name: 'Project 1',
|
||||
mode: 'open' as const,
|
||||
defaultStickiness: 'clientId',
|
||||
};
|
||||
|
||||
const project2 = {
|
||||
id: 'average-time-to-prod-per-toggle-2',
|
||||
name: 'Project 2',
|
||||
mode: 'open' as const,
|
||||
defaultStickiness: 'clientId',
|
||||
};
|
||||
|
||||
await projectService.createProject(project1, user.id);
|
||||
await projectService.createProject(project2, user.id);
|
||||
|
||||
const togglesProject1 = [
|
||||
{ name: 'average-prod-time-pt-10', subdays: 7 },
|
||||
{ name: 'average-prod-time-pt-11', subdays: 14 },
|
||||
{ name: 'average-prod-time-pt-12', subdays: 40 },
|
||||
];
|
||||
|
||||
const togglesProject2 = [
|
||||
{ name: 'average-prod-time-pt-13', subdays: 15 },
|
||||
{ name: 'average-prod-time-pt-14', subdays: 2 },
|
||||
];
|
||||
|
||||
const featureTogglesProject1 = await Promise.all(
|
||||
togglesProject1.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project1.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const featureTogglesProject2 = await Promise.all(
|
||||
togglesProject2.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project2.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
featureTogglesProject1.map((toggle) => {
|
||||
return stores.eventStore.store(
|
||||
new FeatureEnvironmentEvent({
|
||||
enabled: true,
|
||||
project: project1.id,
|
||||
featureName: toggle.name,
|
||||
environment: 'default',
|
||||
createdBy: 'Fredrik',
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
featureTogglesProject2.map((toggle) => {
|
||||
return stores.eventStore.store(
|
||||
new FeatureEnvironmentEvent({
|
||||
enabled: true,
|
||||
project: project2.id,
|
||||
featureName: toggle.name,
|
||||
environment: 'default',
|
||||
createdBy: 'Fredrik',
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
togglesProject1.map((toggle) =>
|
||||
updateFeature(toggle.name, {
|
||||
created_at: subDays(new Date(), toggle.subdays),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
togglesProject2.map((toggle) =>
|
||||
updateFeature(toggle.name, {
|
||||
created_at: subDays(new Date(), toggle.subdays),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const resultProject1 = await projectService.getDoraMetrics(project1.id);
|
||||
const resultProject2 = await projectService.getDoraMetrics(project2.id);
|
||||
|
||||
expect(resultProject1.features).toHaveLength(3);
|
||||
expect(resultProject2.features).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should return average time to production per toggle and include archived toggles', async () => {
|
||||
const project1 = {
|
||||
id: 'average-time-to-prod-per-toggle-12',
|
||||
name: 'Project 1',
|
||||
mode: 'open' as const,
|
||||
defaultStickiness: 'clientId',
|
||||
};
|
||||
|
||||
await projectService.createProject(project1, user.id);
|
||||
|
||||
const togglesProject1 = [
|
||||
{ name: 'average-prod-time-pta-10', subdays: 7 },
|
||||
{ name: 'average-prod-time-pta-11', subdays: 14 },
|
||||
{ name: 'average-prod-time-pta-12', subdays: 40 },
|
||||
];
|
||||
|
||||
const featureTogglesProject1 = await Promise.all(
|
||||
togglesProject1.map((toggle) => {
|
||||
return featureToggleService.createFeatureToggle(
|
||||
project1.id,
|
||||
toggle,
|
||||
user,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
featureTogglesProject1.map((toggle) => {
|
||||
return stores.eventStore.store(
|
||||
new FeatureEnvironmentEvent({
|
||||
enabled: true,
|
||||
project: project1.id,
|
||||
featureName: toggle.name,
|
||||
environment: 'default',
|
||||
createdBy: 'Fredrik',
|
||||
tags: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
togglesProject1.map((toggle) =>
|
||||
updateFeature(toggle.name, {
|
||||
created_at: subDays(new Date(), toggle.subdays),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await featureToggleService.archiveToggle('average-prod-time-pta-12', user);
|
||||
|
||||
const resultProject1 = await projectService.getDoraMetrics(project1.id);
|
||||
|
||||
expect(resultProject1.features).toHaveLength(3);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user