From 444d3ef705e6c8118a2a75c12da262704198e311 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 9 Jun 2025 13:30:43 +0200 Subject: [PATCH] feat: improve feedback UX (#10099) --- .../component/feedbackNew/FeedbackList.tsx | 71 +++++++- .../feedbackNew/activeExperiments.test.ts | 170 ++++++++++++++++++ .../feedbackNew/activeExperiments.ts | 55 ++++++ 3 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 frontend/src/component/feedbackNew/activeExperiments.test.ts create mode 100644 frontend/src/component/feedbackNew/activeExperiments.ts diff --git a/frontend/src/component/feedbackNew/FeedbackList.tsx b/frontend/src/component/feedbackNew/FeedbackList.tsx index 24fe36422f..4797665387 100644 --- a/frontend/src/component/feedbackNew/FeedbackList.tsx +++ b/frontend/src/component/feedbackNew/FeedbackList.tsx @@ -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 = () => { { } > + Active experiments + + {activeExperiments.length > 0 ? ( + activeExperiments.map((experiment) => ( + + + + {experiment.category} + + {experiment.commentCount} comments + + + {experiment.averageScore}/7 + + + )) + ) : ( + + No feedback data from the last three months + + )} + + + All feedback ({rows.length}) + { + 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'); + }); +}); diff --git a/frontend/src/component/feedbackNew/activeExperiments.ts b/frontend/src/component/feedbackNew/activeExperiments.ts new file mode 100644 index 0000000000..2ddf61ebbd --- /dev/null +++ b/frontend/src/component/feedbackNew/activeExperiments.ts @@ -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, 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, + }; + }); +}