mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: improve feedback UX (#10099)
This commit is contained in:
parent
c8933cc8e6
commit
444d3ef705
@ -8,18 +8,44 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
import { Box, styled, Typography, useMediaQuery } from '@mui/material';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import theme from 'themes/theme';
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { FeedbackSchema } from 'openapi';
|
||||
import { getActiveExperiments } from './activeExperiments.ts';
|
||||
|
||||
interface IFeedbackSchemaCellProps {
|
||||
value?: string | null; // FIXME: proper type
|
||||
row: { original: FeedbackSchema };
|
||||
}
|
||||
|
||||
const StyledSectionHeader = styled(Typography)(({ theme }) => ({
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const AverageScore = styled('div')(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.mediumHeader,
|
||||
}));
|
||||
|
||||
const ActiveExperiments = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: theme.spacing(4),
|
||||
}));
|
||||
|
||||
const ActiveExperimentCard = styled('div')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.secondary.light,
|
||||
borderRadius: `${theme.shape.borderRadiusLarge}px`,
|
||||
padding: theme.spacing(3),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(10),
|
||||
}));
|
||||
|
||||
export const FeedbackList = () => {
|
||||
const { feedback } = useFeedbackPosted();
|
||||
|
||||
@ -27,7 +53,7 @@ export const FeedbackList = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
Header: 'Category',
|
||||
Header: 'Feature',
|
||||
accessor: 'category',
|
||||
Cell: ({
|
||||
row: { original: feedback },
|
||||
@ -37,7 +63,7 @@ export const FeedbackList = () => {
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'UserType',
|
||||
Header: 'User Type',
|
||||
accessor: 'userType',
|
||||
Cell: ({
|
||||
row: { original: feedback },
|
||||
@ -47,7 +73,7 @@ export const FeedbackList = () => {
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'DifficultyScore',
|
||||
Header: 'Score',
|
||||
accessor: 'difficultyScore',
|
||||
Cell: ({
|
||||
row: { original: feedback },
|
||||
@ -56,7 +82,7 @@ export const FeedbackList = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: 'Positive',
|
||||
Header: 'What do you like most?',
|
||||
accessor: 'positive',
|
||||
minWidth: 100,
|
||||
Cell: ({
|
||||
@ -68,7 +94,7 @@ export const FeedbackList = () => {
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Areas for improvement',
|
||||
Header: 'What should be improved?',
|
||||
accessor: 'areasForImprovement',
|
||||
minWidth: 100,
|
||||
Cell: ({
|
||||
@ -80,7 +106,7 @@ export const FeedbackList = () => {
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Created at',
|
||||
Header: 'Date',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
},
|
||||
@ -88,6 +114,8 @@ export const FeedbackList = () => {
|
||||
|
||||
const { data, getSearchText } = useSearch(columns, searchValue, feedback);
|
||||
|
||||
const activeExperiments = useMemo(() => getActiveExperiments(data), [data]);
|
||||
|
||||
const { headerGroups, rows, prepareRow } = useTable(
|
||||
{
|
||||
columns: columns as any,
|
||||
@ -119,7 +147,7 @@ export const FeedbackList = () => {
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Feedbacks posted (${rows.length})`}
|
||||
title={'Feedback'}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
@ -149,6 +177,31 @@ export const FeedbackList = () => {
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<StyledSectionHeader>Active experiments</StyledSectionHeader>
|
||||
<ActiveExperiments>
|
||||
{activeExperiments.length > 0 ? (
|
||||
activeExperiments.map((experiment) => (
|
||||
<ActiveExperimentCard key={experiment.category}>
|
||||
<Box>
|
||||
<StyledSectionHeader>
|
||||
{experiment.category}
|
||||
</StyledSectionHeader>
|
||||
<Box>{experiment.commentCount} comments</Box>
|
||||
</Box>
|
||||
<AverageScore>
|
||||
{experiment.averageScore}/7
|
||||
</AverageScore>
|
||||
</ActiveExperimentCard>
|
||||
))
|
||||
) : (
|
||||
<Box sx={{ py: 2 }}>
|
||||
No feedback data from the last three months
|
||||
</Box>
|
||||
)}
|
||||
</ActiveExperiments>
|
||||
<StyledSectionHeader>
|
||||
All feedback ({rows.length})
|
||||
</StyledSectionHeader>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
|
170
frontend/src/component/feedbackNew/activeExperiments.test.ts
Normal file
170
frontend/src/component/feedbackNew/activeExperiments.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { getActiveExperiments } from './activeExperiments.ts';
|
||||
import type { FeedbackSchema } from 'openapi';
|
||||
|
||||
describe('getActiveExperiments', () => {
|
||||
const now = new Date();
|
||||
const twoMonthsAgo = new Date(now);
|
||||
twoMonthsAgo.setMonth(now.getMonth() - 2);
|
||||
|
||||
const fourMonthsAgo = new Date(now);
|
||||
fourMonthsAgo.setMonth(now.getMonth() - 4);
|
||||
|
||||
const oneWeekAgo = new Date(now);
|
||||
oneWeekAgo.setDate(now.getDate() - 7);
|
||||
|
||||
const mockFeedbackData: FeedbackSchema[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: 'feature1',
|
||||
createdAt: now.toISOString(),
|
||||
difficultyScore: 5,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'feature1',
|
||||
createdAt: oneWeekAgo.toISOString(),
|
||||
difficultyScore: 3,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: 'feature2',
|
||||
createdAt: now.toISOString(),
|
||||
difficultyScore: null,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: 'feature2',
|
||||
createdAt: oneWeekAgo.toISOString(),
|
||||
difficultyScore: 6,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: 'feature3',
|
||||
createdAt: fourMonthsAgo.toISOString(),
|
||||
difficultyScore: 7,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(getActiveExperiments([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for null input', () => {
|
||||
expect(
|
||||
getActiveExperiments(null as unknown as FeedbackSchema[]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out feedback older than three months', () => {
|
||||
const result = getActiveExperiments(mockFeedbackData);
|
||||
expect(result.length).toBe(2); // Only feature1 and feature2 have recent feedback
|
||||
expect(
|
||||
result.find((item) => item.category === 'feature3'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should count comments correctly', () => {
|
||||
const result = getActiveExperiments(mockFeedbackData);
|
||||
const feature1 = result.find((item) => item.category === 'feature1');
|
||||
const feature2 = result.find((item) => item.category === 'feature2');
|
||||
|
||||
expect(feature1?.commentCount).toBe(2);
|
||||
expect(feature2?.commentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should calculate average score correctly', () => {
|
||||
const result = getActiveExperiments(mockFeedbackData);
|
||||
const feature1 = result.find((item) => item.category === 'feature1');
|
||||
|
||||
// (5 + 3) / 2 = 4.0
|
||||
expect(feature1?.averageScore).toBe('4.0');
|
||||
});
|
||||
|
||||
it('should handle null difficulty scores', () => {
|
||||
const result = getActiveExperiments(mockFeedbackData);
|
||||
const feature2 = result.find((item) => item.category === 'feature2');
|
||||
|
||||
// Only one valid score of 6
|
||||
expect(feature2?.averageScore).toBe('6.0');
|
||||
});
|
||||
|
||||
it('should include feedback between 1 and 3 months old', () => {
|
||||
const twoMonthOldData: FeedbackSchema[] = [
|
||||
{
|
||||
id: 7,
|
||||
category: 'feature4',
|
||||
createdAt: twoMonthsAgo.toISOString(),
|
||||
difficultyScore: 5,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
];
|
||||
|
||||
const result = getActiveExperiments(twoMonthOldData);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].category).toBe('feature4');
|
||||
});
|
||||
|
||||
it('should return N/A when all scores are null', () => {
|
||||
const allNullScores: FeedbackSchema[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: 'feature4',
|
||||
createdAt: now.toISOString(),
|
||||
difficultyScore: null,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
];
|
||||
|
||||
const result = getActiveExperiments(allNullScores);
|
||||
expect(result[0].averageScore).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should preserve the order of experiments as returned from the backend', () => {
|
||||
// Create test data with a specific order
|
||||
const orderedData: FeedbackSchema[] = [
|
||||
{
|
||||
id: 1,
|
||||
category: 'feature1',
|
||||
createdAt: now.toISOString(),
|
||||
difficultyScore: 5,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'feature2',
|
||||
createdAt: now.toISOString(),
|
||||
difficultyScore: 4,
|
||||
areasForImprovement: null,
|
||||
positive: null,
|
||||
userType: 'developer',
|
||||
},
|
||||
];
|
||||
|
||||
const result = getActiveExperiments(orderedData);
|
||||
|
||||
// The order should be preserved from the input data
|
||||
expect(result[0].category).toBe('feature1');
|
||||
expect(result[1].category).toBe('feature2');
|
||||
});
|
||||
});
|
55
frontend/src/component/feedbackNew/activeExperiments.ts
Normal file
55
frontend/src/component/feedbackNew/activeExperiments.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { FeedbackSchema } from 'openapi';
|
||||
|
||||
export interface ActiveExperiment {
|
||||
category: string;
|
||||
commentCount: number;
|
||||
averageScore: string;
|
||||
}
|
||||
|
||||
export function getActiveExperiments(
|
||||
feedbackData: FeedbackSchema[],
|
||||
): ActiveExperiment[] {
|
||||
if (!feedbackData || feedbackData.length === 0) return [];
|
||||
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const lastThreeMonthsFeedback = feedbackData.filter((item) => {
|
||||
const createdAt = new Date(item.createdAt);
|
||||
return createdAt >= threeMonthsAgo;
|
||||
});
|
||||
|
||||
const groupedByCategory = lastThreeMonthsFeedback.reduce(
|
||||
(acc: Record<string, FeedbackSchema[]>, item) => {
|
||||
const category = item.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return Object.entries(groupedByCategory).map(([category, items]) => {
|
||||
const commentCount = items.length;
|
||||
|
||||
const validScores = items
|
||||
.filter((item) => item.difficultyScore !== null)
|
||||
.map((item) => item.difficultyScore as number);
|
||||
|
||||
const averageScore =
|
||||
validScores.length > 0
|
||||
? (
|
||||
validScores.reduce((sum, score) => sum + score, 0) /
|
||||
validScores.length
|
||||
).toFixed(1)
|
||||
: 'N/A';
|
||||
|
||||
return {
|
||||
category,
|
||||
commentCount,
|
||||
averageScore,
|
||||
};
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user