1
0
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:
Fredrik Strand Oseberg 2023-09-08 14:18:58 +02:00 committed by GitHub
parent 0b5a7b7d36
commit 26ade79d66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 499 additions and 59 deletions

View File

@ -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 &&
isPro() &&
enterpriseIcon) ||
undefined
<>
<ConditionallyRender
condition={tab.new}
show={
<Badge
sx={{
position:
'absolute',
top: 10,
right: 20,
}}
color="success"
>
New
</Badge>
}
/>
{(tab.isEnterprise &&
isPro() &&
enterpriseIcon) ||
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

View File

@ -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>
);
};

View File

@ -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,51 +160,49 @@ export const ProjectDoraMetrics = () => {
);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Lead time for changes (per feature toggle)`}
/>
}
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No tags found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No tags available. Get started by adding one.
</TablePlaceholder>
}
<>
<ProjectDoraFeedback />
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Lead time for changes (per release toggle)`}
/>
}
/>
</PageContent>
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No features with data found &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
/>
}
/>
</PageContent>
</>
);
};

View File

@ -45,6 +45,7 @@ export type CustomEvents =
| 'feature-type-edit'
| 'strategy-variants'
| 'search-filter-suggestions'
| 'project-metrics'
| 'open-integration';
export const usePlausibleTracker = () => {

View File

@ -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: {

View File

@ -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(

View File

@ -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);
});