1
0
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:
Mateusz Kwasniewski 2025-06-09 13:30:43 +02:00 committed by GitHub
parent c8933cc8e6
commit 444d3ef705
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 287 additions and 9 deletions

View File

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

View 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');
});
});

View 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,
};
});
}