From 1759e29729602156dcd2dccf19eea607c443d45b Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:26:00 +0000 Subject: [PATCH 01/10] feat(delightful-dashboards): add delightful-dashboard Growthbook flag [part 1] Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/constants/feature-flags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/constants/feature-flags.ts b/packages/shared/constants/feature-flags.ts index 3a75262453..daaf29ca35 100644 --- a/packages/shared/constants/feature-flags.ts +++ b/packages/shared/constants/feature-flags.ts @@ -33,6 +33,7 @@ export const featureFlags = { sidebarNavLabels: 'enable-sidebar-nav-labels' as const, fiveStarAdminRating: '5star-admin-rating' as const, workflowBuilderRedesign: 'workflow-builder-redesign' as const, + delightfulDashboard: 'delightful-dashboard' as const, } export enum AdminEmailPdfFeatureValue { From c506c71b7ec99288c27bcdbccc06fce564aeefae Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:26:37 +0000 Subject: [PATCH 02/10] feat(delightful-dashboards): add hook to fetch and decrypt responses [part 2] Streams encrypted responses and decrypts them client-side via a worker pool (reusing the shared killWorkers helper), cached by React Query. Rows are numbered by stream order. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../storage/useDecryptedResponsesQuery.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptedResponsesQuery.ts diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptedResponsesQuery.ts b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptedResponsesQuery.ts new file mode 100644 index 0000000000..6608a1940a --- /dev/null +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptedResponsesQuery.ts @@ -0,0 +1,162 @@ +import { useQuery, UseQueryResult } from 'react-query' +import { FormField } from '@opengovsg/formsg-sdk/dist/types' +import { formatInTimeZone } from 'date-fns-tz' + +import { + DateString, + SubmissionMetadata, + SubmissionType, +} from 'formsg-shared/types' + +import { env } from '~/env' + +import { + killWorkers, + makeWorkerApiAndCleanup, +} from '../../common/utils/decryptionWorker' +import { adminFormResponsesKeys } from '../../queries' + +import { getEncryptedResponsesStream } from './StorageResponsesService' +import { CleanableDecryptionWorkerApi } from './types' + +export type DecryptedResponse = { + decryptedResponses: FormField[] +} & SubmissionMetadata + +const decryptedResponsesKey = ( + formId: string, + dateRange?: { startDate?: DateString; endDate?: DateString }, +) => + [ + ...adminFormResponsesKeys.id(formId), + 'decrypted', + dateRange?.startDate ?? 'all', + dateRange?.endDate ?? 'all', + ] as const + +const NUM_WORKERS = window.navigator.hardwareConcurrency || 4 + +async function fetchAndDecryptResponses({ + formId, + secretKey, + startDate, + endDate, +}: { + formId: string + secretKey: string + startDate?: DateString + endDate?: DateString +}): Promise { + const workerPool: CleanableDecryptionWorkerApi[] = [] + + try { + for (let i = 0; i < NUM_WORKERS; i++) { + workerPool.push(makeWorkerApiAndCleanup()) + } + + const stream = await getEncryptedResponsesStream(formId, { + downloadAttachments: false, + startDate, + endDate, + }) + + const reader = stream.getReader() + let currentSubmissionIndex = 0 + + const submissionDecryptPromises: Promise[] = + [] + + let read: (result: ReadableStreamReadResult) => void + + await reader.read().then( + (read = async (result) => { + if (result.done) return + const { workerApi } = workerPool[currentSubmissionIndex % NUM_WORKERS] + const decryptResponsePromise = workerApi + .parseAndDecryptSubmissionData({ + submissionStreamDtoString: result.value, + secretKey, + // Resolved on the main thread so the worker need not import env.ts + // (which references window). + workerCtxOptions: { formsgSdkMode: env.formsgSdkMode }, + }) + .then((result) => { + if (result.isParseSuccessful && result.isDecryptionSuccessful) { + const submissionTime = formatInTimeZone( + result.parsedSubmission.created, + 'Asia/Singapore', + 'dd MMM yyyy hh:mm:ss a', + ) + return { + // Placeholder; the real '#' is assigned by display order below, + // since these promises resolve out of order. + number: 0, + decryptedResponses: result.decryptedResponses, + refNo: result.parsedSubmission._id, + submissionTime, + payments: null, + mrf: + result.parsedSubmission.submissionType === + SubmissionType.Multirespondent + ? result.parsedSubmission.mrfMeta + : undefined, + } as DecryptedResponse + } + return undefined + }) + submissionDecryptPromises.push(decryptResponsePromise) + currentSubmissionIndex++ + return reader.read().then(read) + }), + ) + + const results = await Promise.all(submissionDecryptPromises) + + // Number rows by final position (stream order) so '#' is sequential. + return results + .filter((result): result is DecryptedResponse => result !== undefined) + .map((result, index) => ({ ...result, number: index + 1 })) + } finally { + killWorkers(workerPool) + } +} + +interface UseDecryptedResponsesQueryParams { + formId: string + secretKey: string + startDate?: DateString + endDate?: DateString + enabled?: boolean +} + +/** + * Fetches and decrypts responses for the given form/date range, cached by + * React Query. Decryption is expensive, so results are cached and never + * auto-refetched. + */ +export const useDecryptedResponsesQuery = ({ + formId, + secretKey, + startDate, + endDate, + enabled = true, +}: UseDecryptedResponsesQueryParams): UseQueryResult => { + return useQuery( + decryptedResponsesKey(formId, { startDate, endDate }), + () => + fetchAndDecryptResponses({ + formId, + secretKey, + startDate, + endDate, + }), + { + enabled: enabled && !!formId && !!secretKey, + staleTime: Infinity, + cacheTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + ) +} From 7670909c630286e444072e5cb436b18ed3983f70 Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:27:38 +0000 Subject: [PATCH 03/10] feat(delightful-dashboards): add ResponsesTableV2 base table [part 3] Renders index, Response ID and timestamp columns alongside the selected field-value columns, with client-side pagination over the decrypted set. Uses a shared RESPONSE_ID_LABEL constant and i18n for the empty state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesTable/ResponsesTableV2.tsx | 282 ++++++++++++++++++ .../admin-form/responses/constants.ts | 1 + .../responses/responses-page/en-sg.ts | 1 + .../responses/responses-page/index.ts | 1 + 4 files changed, 285 insertions(+) create mode 100644 apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx new file mode 100644 index 0000000000..3569026b9a --- /dev/null +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -0,0 +1,282 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { + Column, + useFlexLayout, + usePagination, + useResizeColumns, + useTable, +} from 'react-table' +import { + Box, + Flex, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@chakra-ui/react' +import { FormField } from '@opengovsg/formsg-sdk/dist/types' + +import { + MRF_RESPONSE_TIMESTAMP_LABEL, + RESPONSE_ID_LABEL, +} from '~features/admin-form/responses/constants' + +import { DecryptedResponse } from '../../useDecryptedResponsesQuery' +import { getDecryptedResponseInstance } from '../../utils/getDecryptedResponseInstance' +import { useUnlockedResponses } from '../UnlockedResponsesProvider' + +// Base storage-mode columns, rendered alongside the selected field columns. +const BASE_RESPONSE_TABLE_COLUMNS: Column[] = [ + { + Header: '#', + accessor: 'number', + width: 50, + minWidth: 50, + maxWidth: 50, + }, + { + Header: RESPONSE_ID_LABEL, + id: RESPONSE_ID_LABEL, + accessor: 'refNo', + width: 300, + minWidth: 300, + disableResizing: true, + }, + { + Header: MRF_RESPONSE_TIMESTAMP_LABEL, + id: MRF_RESPONSE_TIMESTAMP_LABEL, + accessor: 'submissionTime', + width: 250, + minWidth: 250, + disableResizing: true, + }, +] + +/** + * Rows per page. The whole working set is decrypted client-side, but the table + * is not virtualised, so keep the mounted DOM small by paginating it locally. + */ +export const RESPONSES_TABLE_V2_PAGE_SIZE = 50 + +export const ResponsesTableV2 = ({ + selectedFields, + selectedSubmissionMetaFields, + decryptedResponses, +}: { + selectedFields: { + _id: string + title: string + }[] + selectedSubmissionMetaFields: { + _id: string + title: string + }[] + decryptedResponses: DecryptedResponse[] +}) => { + const { t } = useTranslation() + const { currentPage: currentPage1Indexed, onRowClick } = + useUnlockedResponses() + + const navigate = useNavigate() + + const currentPage = useMemo( + () => (currentPage1Indexed ?? 1) - 1, + [currentPage1Indexed], + ) + + const columns = useMemo(() => { + const filteredBaseColumns = BASE_RESPONSE_TABLE_COLUMNS.filter((column) => { + return ( + selectedSubmissionMetaFields.some( + (field) => field._id === column.Header, + ) || column.Header === '#' + ) + }) + + const selectFieldColumns = selectedFields.map((field) => ({ + Header: field.title, + id: field._id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + accessor: (row: any) => { + const response = row.decryptedResponses.find( + (response: FormField) => response._id === field._id, + ) + if (!response) return '' + try { + // Serialize the same way as the CSV export so answerArray-based + // fields (Checkbox, Table) render instead of showing up blank. + const instance = getDecryptedResponseInstance(response) + return Array.from({ length: instance.numCols }, (_, colIndex) => + instance.getAnswer(colIndex), + ).join(';') + } catch { + return response.answer ?? '' + } + }, + })) + + return [...filteredBaseColumns, ...selectFieldColumns] + }, [selectedSubmissionMetaFields, selectedFields]) + + const { + prepareRow, + getTableProps, + getTableBodyProps, + headerGroups, + page, + gotoPage, + } = useTable( + { + columns, + data: decryptedResponses, + initialState: { + pageIndex: currentPage, + pageSize: RESPONSES_TABLE_V2_PAGE_SIZE, + }, + }, + usePagination, + useResizeColumns, + useFlexLayout, + ) + + useEffect(() => { + gotoPage(currentPage) + }, [currentPage, gotoPage]) + + const handleRowClick = useCallback( + (submissionId: string, responseNumber: number) => { + onRowClick() + return navigate(submissionId, { + state: { + responseNumber, + }, + }) + }, + [navigate, onRowClick], + ) + + if (decryptedResponses.length === 0) { + return ( + + + {t( + 'features.adminForm.responses.responsesPage.storage.unlockedResponses.unlockedResponses.noResponsesToDisplay', + )} + + + ) + } + + return ( + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + + {page.map((row) => { + prepareRow(row) + return ( + + handleRowClick(row.original.refNo, row.original.number) + } + cursor="pointer" + _hover={{ + bg: 'primary.100', + }} + _active={{ + bg: 'primary.200', + }} + > + {row.cells.map((cell) => { + return ( + + ) + })} + + ) + })} + +
+ {column.render('Header')} + + {column.disableResizing ? null : ( + + )} +
+ {cell.render('Cell')} +
+ ) +} diff --git a/apps/frontend/src/features/admin-form/responses/constants.ts b/apps/frontend/src/features/admin-form/responses/constants.ts index 3630da79e2..694df29899 100644 --- a/apps/frontend/src/features/admin-form/responses/constants.ts +++ b/apps/frontend/src/features/admin-form/responses/constants.ts @@ -1,4 +1,5 @@ // TODO(#8204) Use text labels in i18n files +export const RESPONSE_ID_LABEL = 'Response ID' export const MRF_RESPONSE_TIMESTAMP_LABEL = 'Response timestamp' export const MRF_PENDING_RESPONSE_AT_LABEL = 'Pending response at' export const MRF_WORKFLOW_STATUS_LABEL = 'Workflow status' diff --git a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts index 664b8d70c4..4189bce289 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts @@ -90,6 +90,7 @@ export const enSG: ResponsesResponsesPage = { resultsFound: '{count, plural, =1 {result} other {results}} found', responsesToDate: '{count, plural, =1 {response} other {responses}} to date', + noResponsesToDisplay: 'No responses to display.', }, }, storageResponsesTab: { diff --git a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts index a140afa498..867ec7b6a0 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts @@ -77,6 +77,7 @@ export interface ResponsesResponsesPage { unlockedResponses: { resultsFound: string responsesToDate: string + noResponsesToDisplay: string } } storageResponsesTab: { From 485cfbb853692258beb61071281c7cbe46a7007a Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:28:11 +0000 Subject: [PATCH 04/10] feat(delightful-dashboards): add UnlockedResponsesV2 dashboard container [part 4] Header with response count, submission search, date range and download; gates decryption on the date-range count and paginates over the result. Over-cap strings routed through i18n. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UnlockedResponses/UnlockedResponsesV2.tsx | 244 ++++++++++++++++++ .../responses/responses-page/en-sg.ts | 3 + .../responses/responses-page/index.ts | 2 + 3 files changed, 249 insertions(+) create mode 100644 apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponsesV2.tsx diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponsesV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponsesV2.tsx new file mode 100644 index 0000000000..441867a5c7 --- /dev/null +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/UnlockedResponsesV2.tsx @@ -0,0 +1,244 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Box, + Container, + Flex, + Grid, + Skeleton, + Stack, + Text, +} from '@chakra-ui/react' + +import { BasicField, FormFieldDto } from 'formsg-shared/types' + +import { + DateRangePicker, + dateRangePickerHelper, +} from '~components/DateRangePicker' +import Pagination from '~components/Pagination' + +import { useAdminForm } from '~features/admin-form/common/queries' +import { + MRF_RESPONSE_TIMESTAMP_LABEL, + RESPONSE_ID_LABEL, +} from '~features/admin-form/responses/constants' + +import { useStorageResponsesContext } from '../StorageResponsesContext' +import { useDecryptedResponsesQuery } from '../useDecryptedResponsesQuery' + +import { + RESPONSES_TABLE_V2_PAGE_SIZE, + ResponsesTableV2, +} from './ResponsesTable/ResponsesTableV2' +import { DownloadButton } from './DownloadButton' +import { SubmissionSearchbar } from './SubmissionSearchbar' +import { useUnlockedResponses } from './UnlockedResponsesProvider' + +// Cap on responses the dashboard will decrypt/render in one date range; kept +// low as the table is not virtualised yet. +const RESPONSES_DASHBOARD_MAX_RESPONSE_COUNT = 1000 + +// Field types with no meaningful single-cell answer, excluded as columns. +const DASHBOARD_EXCLUDED_FIELD_TYPES = new Set([ + BasicField.Section, + BasicField.Statement, + BasicField.Signature, + BasicField.Attachment, + BasicField.Image, + BasicField.Address, + BasicField.Children, +]) + +const filterFieldsForDashboardView = (formFields: FormFieldDto[]) => + formFields.filter( + (field) => !DASHBOARD_EXCLUDED_FIELD_TYPES.has(field.fieldType), + ) + +// Always-shown meta columns; `_id` must match the column Header in +// ResponsesTableV2. +const getSubmissionMetaFieldsForDashboard = (): { + _id: string + title: string +}[] => [ + { + _id: RESPONSE_ID_LABEL, + title: RESPONSE_ID_LABEL, + }, + { + _id: MRF_RESPONSE_TIMESTAMP_LABEL, + title: 'Response Timestamp', + }, +] + +export const UnlockedResponsesV2 = (): JSX.Element | null => { + const { t } = useTranslation() + const { data: form, isLoading: isLoadingForm } = useAdminForm() + const { + secretKey, + isLoading: isLoadingSecretKey, + dateRange, + setDateRange, + dateRangeResponsesCount, + } = useStorageResponsesContext() + const { + count, + filteredCount, + currentPage, + setCurrentPage, + submissionId, + setSubmissionId, + isAnyFetching, + } = useUnlockedResponses() + const [startDate, endDate] = dateRange + + const responsesCount = dateRangeResponsesCount ?? 0 + const isOverResponseCap = + responsesCount > RESPONSES_DASHBOARD_MAX_RESPONSE_COUNT + + const { data: decryptedResponses = [], isFetching: isFetchingAndDecrypting } = + useDecryptedResponsesQuery({ + formId: form?._id ?? '', + secretKey: secretKey ?? '', + startDate, + endDate, + // Decrypting the working set is expensive, so never start over the cap + // (or when there is nothing to decrypt). + enabled: + !isLoadingForm && + !isLoadingSecretKey && + !!secretKey && + !!form && + !isOverResponseCap && + responsesCount > 0, + }) + + // Narrow to the searched response ID, if any. + const responsesToDisplay = useMemo( + () => + submissionId + ? decryptedResponses.filter( + (response) => response.refNo === submissionId, + ) + : decryptedResponses, + [decryptedResponses, submissionId], + ) + + const countToUse = submissionId ? filteredCount : count + + if (!secretKey || !form) return null + + const submissionMetaFields = getSubmissionMetaFieldsForDashboard() + const fieldsForDashboardView = filterFieldsForDashboardView( + form.form_fields ?? [], + ) + + return ( + + + + + + + {countToUse?.toLocaleString()} + {' '} + {t( + submissionId + ? 'features.adminForm.responses.responsesPage.storage.unlockedResponses.unlockedResponses.resultsFound' + : 'features.adminForm.responses.responsesPage.storage.unlockedResponses.unlockedResponses.responsesToDate', + { count: countToUse ?? 0 }, + )} + + + + + + + + + + + setDateRange( + dateRangePickerHelper.datePickerValueToDateString( + nextDateRange, + ), + ) + } + /> + + + + + + {isOverResponseCap ? ( + + + + {t( + 'features.adminForm.responses.responsesPage.storage.unlockedResponses.unlockedResponses.tooManyResponsesTitle', + )} + + + {t( + 'features.adminForm.responses.responsesPage.storage.unlockedResponses.unlockedResponses.tooManyResponsesBody', + { responseCount: responsesCount.toLocaleString() }, + )} + + + + ) : isFetchingAndDecrypting ? ( + + ) : ( + + )} + + + + + + + ) +} diff --git a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts index 4189bce289..346f14d906 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/en-sg.ts @@ -91,6 +91,9 @@ export const enSG: ResponsesResponsesPage = { responsesToDate: '{count, plural, =1 {response} other {responses}} to date', noResponsesToDisplay: 'No responses to display.', + tooManyResponsesTitle: 'Too many responses to display', + tooManyResponsesBody: + 'There are {responseCount} responses in this date range. Select a narrower date range.', }, }, storageResponsesTab: { diff --git a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts index 867ec7b6a0..df4c749923 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/responses/responses-page/index.ts @@ -78,6 +78,8 @@ export interface ResponsesResponsesPage { resultsFound: string responsesToDate: string noResponsesToDisplay: string + tooManyResponsesTitle: string + tooManyResponsesBody: string } } storageResponsesTab: { From 46ce590734aee920700251eaf13217139509f551 Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:28:23 +0000 Subject: [PATCH 05/10] feat(delightful-dashboards): gate Responses tab on the flag [part 5] Render UnlockedResponsesV2 when delightful-dashboard is enabled, else the existing UnlockedResponses. Off by default; this activates the dashboard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesPage/storage/StorageResponsesTab.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx index 1ca7102cff..8d1f3cd3c3 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/StorageResponsesTab.tsx @@ -1,23 +1,35 @@ import { useTranslation } from 'react-i18next' +import { useFeatureValue } from '@growthbook/growthbook-react' + +import { featureFlags } from 'formsg-shared/constants' import { FormActivationSvg } from '~features/admin-form/settings/components/FormActivationSvg' import { SecretKeyVerification } from '../../components/SecretKeyVerification' import { EmptyResponses } from '../common/EmptyResponses' +import { UnlockedResponsesV2 } from './UnlockedResponses/UnlockedResponsesV2' import { useStorageResponsesContext } from './StorageResponsesContext' import { UnlockedResponses } from './UnlockedResponses' export const StorageResponsesTab = (): JSX.Element => { const { t } = useTranslation() const { totalResponsesCount, secretKey } = useStorageResponsesContext() + const isDelightfulDashboardEnabled = useFeatureValue( + featureFlags.delightfulDashboard, + false, + ) if (totalResponsesCount === 0) { return } return secretKey ? ( - + isDelightfulDashboardEnabled ? ( + + ) : ( + + ) ) : ( } From 1af4f857f9f21236e94ab88956990e3d64e8e886 Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:29:26 +0000 Subject: [PATCH 06/10] feat(delightful-dashboards): make Response ID and timestamp columns resizable [part 6] Drop disableResizing on the two meta columns and relax their minWidth, and mark the fixed-width '#' column non-resizable so it has no dead handle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesTable/ResponsesTableV2.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx index 3569026b9a..716b22a47a 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -38,22 +38,23 @@ const BASE_RESPONSE_TABLE_COLUMNS: Column[] = [ width: 50, minWidth: 50, maxWidth: 50, + // Fixed-width index column; no resize handle. + disableResizing: true, }, { Header: RESPONSE_ID_LABEL, id: RESPONSE_ID_LABEL, accessor: 'refNo', - width: 300, - minWidth: 300, - disableResizing: true, + // Fits a full 24-char ObjectId; resizable down to minWidth. + width: 250, + minWidth: 100, }, { Header: MRF_RESPONSE_TIMESTAMP_LABEL, id: MRF_RESPONSE_TIMESTAMP_LABEL, accessor: 'submissionTime', width: 250, - minWidth: 250, - disableResizing: true, + minWidth: 100, }, ] From fe05a7cc52e6370d6d8cc070552a75d3ec642f7c Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:29:48 +0000 Subject: [PATCH 07/10] feat(delightful-dashboards): truncate long response cell values [part 7] Wrap cell content in a truncating Text so long values show an ellipsis on one line instead of wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesTable/ResponsesTableV2.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx index 716b22a47a..c78673b596 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -267,10 +267,13 @@ export const ResponsesTableV2 = ({ key={cell.getCellProps().key} display="flex" alignItems="center" - overflow="clip" - textOverflow="ellipsis" + overflow="hidden" > - {cell.render('Cell')} + {/* Truncate long values instead of wrapping; widen the + column to reveal more. */} + + {cell.render('Cell')} + ) })} From 5c3e9f0fc6244d42fda7149b45f603d393c82ae7 Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:30:10 +0000 Subject: [PATCH 08/10] fix(delightful-dashboards): extend row hover across horizontal scroll [part 8] Grow each row to max-content (floored at 100%) so the hover/active background spans columns scrolled into view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesTable/ResponsesTableV2.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx index c78673b596..e346d65109 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -240,11 +240,21 @@ export const ResponsesTableV2 = ({ {page.map((row) => { prepareRow(row) + const rowProps = row.getRowProps() return ( Date: Sun, 19 Jul 2026 15:31:50 +0000 Subject: [PATCH 09/10] feat(delightful-dashboards): show MRF workflow columns [part 9] For multi-respondent forms, render workflow-status, pending-response-at and reminders columns (matching the production order) before the field-value columns, from the decrypted response's mrf metadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResponsesTable/ResponsesTableV2.tsx | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx index e346d65109..c82eb1b69a 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -21,8 +21,17 @@ import { } from '@chakra-ui/react' import { FormField } from '@opengovsg/formsg-sdk/dist/types' +import { FormResponseMode, WorkflowStatus } from 'formsg-shared/types' + +import Badge from '~components/Badge' + +import { useAdminForm } from '~features/admin-form/common/queries' +import { getPendingResponseAtString } from '~features/admin-form/responses/common/utils/mrfSubmissionView' import { + MRF_PENDING_RESPONSE_AT_LABEL, + MRF_REMINDERS_LABEL, MRF_RESPONSE_TIMESTAMP_LABEL, + MRF_WORKFLOW_STATUS_LABEL, RESPONSE_ID_LABEL, } from '~features/admin-form/responses/constants' @@ -30,6 +39,8 @@ import { DecryptedResponse } from '../../useDecryptedResponsesQuery' import { getDecryptedResponseInstance } from '../../utils/getDecryptedResponseInstance' import { useUnlockedResponses } from '../UnlockedResponsesProvider' +import { SendReminderButton } from './SendReminderButton' + // Base storage-mode columns, rendered alongside the selected field columns. const BASE_RESPONSE_TABLE_COLUMNS: Column[] = [ { @@ -58,6 +69,104 @@ const BASE_RESPONSE_TABLE_COLUMNS: Column[] = [ }, ] +const WorkflowStatusBadge = ({ + workflowStatus, +}: { + workflowStatus?: WorkflowStatus +}) => { + const { t } = useTranslation() + if (!workflowStatus) return null + // Literal translation keys (the typed t() rejects a dynamic key). + const badges: Record< + WorkflowStatus, + { textColor: string; backgroundColor: string; label: string } + > = { + [WorkflowStatus.PENDING]: { + textColor: 'warning.700', + backgroundColor: 'warning.100', + label: t('features.common.pending'), + }, + [WorkflowStatus.COMPLETED]: { + textColor: 'success.700', + backgroundColor: 'success.100', + label: t('features.common.completed'), + }, + [WorkflowStatus.APPROVED]: { + textColor: 'success.700', + backgroundColor: 'success.100', + label: t('features.common.approved'), + }, + [WorkflowStatus.REJECTED]: { + textColor: 'danger.700', + backgroundColor: 'danger.100', + label: t('features.common.notApproved'), + }, + } + const badge = badges[workflowStatus] + return ( + + {badge.label} + + ) +} + +// Workflow-status and pending-response columns for MRF forms, read from the +// decrypted response's mrf metadata. +const MRF_WORKFLOW_COLUMNS: Column[] = [ + { + Header: MRF_WORKFLOW_STATUS_LABEL, + accessor: ({ mrf }) => ( + + ), + width: 160, + minWidth: 160, + }, + { + Header: MRF_PENDING_RESPONSE_AT_LABEL, + accessor: ({ mrf }) => { + const workflowStatus = mrf?.workflowStatus + const workflowCurrentStepNumber = mrf?.workflowCurrentStepNumber + const workflowNumTotalSteps = mrf?.workflowNumTotalSteps + if ( + workflowStatus === undefined || + workflowCurrentStepNumber === undefined || + workflowNumTotalSteps === undefined + ) { + return '' + } + return getPendingResponseAtString({ + workflowStatus, + workflowCurrentStepNumber, + workflowNumTotalSteps, + }) + }, + width: 180, + minWidth: 180, + }, +] + +// Reminders (Send-reminder action) column for MRF forms. +const MRF_REMINDERS_COLUMN: Column = { + Header: MRF_REMINDERS_LABEL, + Cell: ({ row }) => { + const isPending = + row.original.mrf?.workflowStatus === WorkflowStatus.PENDING + const hasNextStepRecipientEmails = + row.original.mrf?.hasNextStepRecipientEmails + return isPending && hasNextStepRecipientEmails ? ( + + ) : null + }, + width: 160, + minWidth: 160, +} + /** * Rows per page. The whole working set is decrypted client-side, but the table * is not virtualised, so keep the mounted DOM small by paginating it locally. @@ -83,6 +192,9 @@ export const ResponsesTableV2 = ({ const { currentPage: currentPage1Indexed, onRowClick } = useUnlockedResponses() + const { data: form } = useAdminForm() + const isMrf = form?.responseMode === FormResponseMode.Multirespondent + const navigate = useNavigate() const currentPage = useMemo( @@ -121,8 +233,27 @@ export const ResponsesTableV2 = ({ }, })) + if (isMrf) { + // Match the column order in production today: #, Response ID, Workflow + // status, Pending response at, Response timestamp, Reminders — then the + // field-value columns. + const timestampColumn = filteredBaseColumns.find( + (column) => column.Header === MRF_RESPONSE_TIMESTAMP_LABEL, + ) + const baseWithoutTimestamp = filteredBaseColumns.filter( + (column) => column.Header !== MRF_RESPONSE_TIMESTAMP_LABEL, + ) + return [ + ...baseWithoutTimestamp, + ...MRF_WORKFLOW_COLUMNS, + ...(timestampColumn ? [timestampColumn] : []), + MRF_REMINDERS_COLUMN, + ...selectFieldColumns, + ] + } + return [...filteredBaseColumns, ...selectFieldColumns] - }, [selectedSubmissionMetaFields, selectedFields]) + }, [isMrf, selectedSubmissionMetaFields, selectedFields]) const { prepareRow, From 0c3ddc376b3b9e25ee4c32fff4a486d7dc4c6510 Mon Sep 17 00:00:00 2001 From: TutnTut Date: Sun, 19 Jul 2026 15:32:22 +0000 Subject: [PATCH 10/10] fix(delightful-dashboards): keep MRF rows a consistent height [part 10] Pin MRF body rows to the reminder-button height (4rem) so rows with and without a reminder line up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx index c82eb1b69a..2c81e51520 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTableV2.tsx @@ -173,6 +173,11 @@ const MRF_REMINDERS_COLUMN: Column = { */ export const RESPONSES_TABLE_V2_PAGE_SIZE = 50 +// Fixed MRF row height so rows with a SendReminderButton (a full-height md +// button) match rows without one: 2.75rem button + the td's 2 x 0.625rem +// vertical padding. +const MRF_ROW_MIN_HEIGHT = '4rem' + export const ResponsesTableV2 = ({ selectedFields, selectedSubmissionMetaFields, @@ -386,6 +391,9 @@ export const ResponsesTableV2 = ({ minWidth: '100%', width: 'max-content', }} + // Keep every MRF row at the reminder-button height so rows with + // and without the button line up. + minH={isMrf ? MRF_ROW_MIN_HEIGHT : undefined} px={0} // Read from row.original, not row.values: the Response ID column // sets an explicit id, so row.values.refNo is undefined.