Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For more information about each release including git tags and artifacts, see [R

- Improve list editing in the policy editor ([#1400](https://github.com/roostorg/coop/pull/1400) by [@taobojlen](https://github.com/taobojlen))
- Review console hides "Enqueue to NCMEC" when NCMEC reporting isn't enabled ([#1470](https://github.com/roostorg/coop/pull/1470) by [@serendipty01](https://github.com/serendipty01))
- Recent Decisions CSV export is generated on the server with no row limit and requires a start date ([#1419](https://github.com/roostorg/coop/pull/1419) by [@calebmcquaid](https://github.com/calebmcquaid))

### Removed

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type DateRangePickerProps } from '@/coop-ui/DateRangePicker';
import {
GQLDequeueManualReviewJobDocument,
GQLGetDecidedJobDocument,
Expand All @@ -19,11 +20,26 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type FC } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { MemoryRouter } from 'react-router';

import ManualReviewRecentDecisions from './ManualReviewRecentDecisions';

const TEST_RANGE = vi.hoisted(() => ({
from: new Date('2026-08-01T00:00:00.000Z'),
to: new Date('2026-08-31T00:00:00.000Z'),
}));

vi.mock('@/coop-ui/DateRangePicker', () => {
const DateRangePickerStub: FC<DateRangePickerProps> = ({ onUpdate }) => (
<button type="button" onClick={() => onUpdate?.({ range: TEST_RANGE })}>
Pick one month
</button>
);
return { DateRangePicker: DateRangePickerStub };
});

// jsdom implements neither the Pointer Events capture API nor
// `scrollIntoView`. The `Show` control (`@/coop-ui/Select`, a Radix select)
// opens on a native `pointerdown` and calls `hasPointerCapture` from that
Expand Down Expand Up @@ -240,6 +256,22 @@ async function applyQueueFilter(container: HTMLElement) {
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
}

async function applyFilters(container: HTMLElement, withQueue = false) {
fireEvent.click(await screen.findByText('Select any'));
fireEvent.click(await screen.findByText('Date Range'));
fireEvent.click(
await screen.findByRole('button', { name: 'Pick one month' }),
);
if (withQueue) {
fireEvent.click(await screen.findByText('Queues'));
const queueSelector = container.querySelector('.ant-select-selector');
expect(queueSelector).not.toBeNull();
fireEvent.mouseDown(queueSelector as Element);
fireEvent.click(await screen.findByText('Appeals'));
}
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
}

// Drives the `Show` control (a Radix `Select`, not a native <select>): open
// it via its trigger (labelled "Show"), then click the option with the given
// visible label.
Expand Down Expand Up @@ -415,6 +447,55 @@ describe('ManualReviewRecentDecisions', () => {
).toBeInTheDocument();
});

async function clickDownload() {
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
await userEvent.click(
await screen.findByRole('button', { name: 'Download' }),
);
const anchor = click.mock.contexts[0] as HTMLAnchorElement;
click.mockRestore();
return anchor;
}

const monthTimes = { startTime: TEST_RANGE.from, endTime: TEST_RANGE.to };

it('disables Download until a date range is set', async () => {
renderPage(activityMocks([]));

expect(
await screen.findByRole('button', { name: 'Download' }),
).toBeDisabled();
expect(
screen.getByText('Set a date range to download.'),
).toBeInTheDocument();
});

it('downloads the server export with the saved filters', async () => {
const { container } = renderPage([
...activityMocks([]),
activityMockFor(
{ ...monthTimes, view: 'DECISIONS', queueIds: ['q-appeals'] },
[],
),
]);
await applyFilters(container, true);
await screen.findByText(/manual actions have no queue/i);

const anchor = await clickDownload();
const url = new URL(anchor.href);

expect(anchor.hasAttribute('download')).toBe(true);
expect(url.pathname).toBe('/api/v1/moderation_activity/export');
expect(JSON.parse(url.searchParams.get('input')!)).toEqual({
view: 'DECISIONS',
queueIds: ['q-appeals'],
startTime: '2026-08-01T00:00:00.000Z',
endTime: '2026-08-31T00:00:00.000Z',
});
});

it('shows the item count on a bulk run', async () => {
renderPage(
activityMocks([manualActionRow({ itemCount: 84, failedCount: 0 })]),
Expand Down
159 changes: 26 additions & 133 deletions client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@ import {
} from '../../../utils/time';
import { jsonParse } from '../../../utils/typescript-types';
import { ITEM_TYPE_FRAGMENT } from '../rules/rule_form/RuleForm';
import {
buildActivityExportUrl,
exportRangeProblem,
toExportDecisions,
} from './activityExportUrl';
import { JOB_FRAGMENT } from './manual_review_job/jobFragment';
import ManualReviewJobReview from './manual_review_job/ManualReviewJobReview';
import ManualActionItemsPanel from './ManualActionItemsPanel';
import ManualReviewRecentDecisionsFilter, {
RecentDecisionsFilterInput,
} from './ManualReviewRecentDecisionsFilter';
import ManualReviewRecentDecisionSummary from './ManualReviewRecentDecisionSummary';
import {
buildActivityCsv,
escapeCsvField,
type CsvRow,
} from './moderationActivityCsv';
import { getDecisionTimingFields } from './mrtAnalyticsUtils';
import { escapeCsvField } from './moderationActivityCsv';

gql`
${ITEM_TYPE_FRAGMENT}
Expand Down Expand Up @@ -265,9 +265,6 @@ const columnLabels: Record<ColumnId, string> = {

const DECISION_REASON_PREVIEW_LENGTH = 50;

// Runaway guard for both CSV exports: stop paging after this many requests
// even if the server keeps returning more (e.g. `nextCursor` never goes
// null, or the offset loop never runs dry).
const CSV_MAX_PAGES = 100;

// Feed view: which rows `recentModerationActivity` returns. Persisted so a
Expand Down Expand Up @@ -435,10 +432,6 @@ export default function ManualReviewRecentDecisions() {
{ loading: activityLoading, error: activityError, data: activityData },
] = useGQLGetRecentModerationActivityLazyQuery();

// Separate lazy-query instances from `getModerationActivity` above so a CSV
// export's paging loop doesn't clobber the table's own query state (loading
// flag, cached data) while it runs.
const [getActivityForDownload] = useGQLGetRecentModerationActivityLazyQuery();
const [getSkipsForRecentDecisions] =
useGQLGetSkipsForRecentDecisionsLazyQuery();

Expand All @@ -462,10 +455,6 @@ export default function ManualReviewRecentDecisions() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<string[]>([]);

// CSV export loading flags — separate from `activityLoading` (the table's
// own query) since a download's paging loop runs on its own lazy-query
// instance and shouldn't make the table look like it's refetching.
const [downloadingActivity, setDownloadingActivity] = useState(false);
const [downloadingSkips, setDownloadingSkips] = useState(false);

// Shared by both the cursor-paged activity feed and the offset-paged skips
Expand Down Expand Up @@ -880,68 +869,6 @@ export default function ManualReviewRecentDecisions() {
[getActionName],
);

// Normalizes one merged-feed row into the flat, string-only shape the CSV
// export writes out. Kept separate from `NormalizedActivityRow` (used by
// the table) since the CSV needs raw item/failed counts rather than the
// table's pre-rendered JSX.
const toCsvRow = useCallback(
(row: ActivityRow): CsvRow => {
if (row.__typename === 'ManualActionRow') {
return {
origin: 'Manual Action',
outcome: getActionColorNamePairs(row.actionIds).map(
({ name }) => name,
),
policies: row.policyIds.map((id) => getPolicyName(id)),
reviewer: getReviewerName(row.reviewerId),
queue: '',
jobCreatedAt: '',
claimedAt: '',
time: parseDatetimeToReadableStringInUTC(new Date(row.ts)),
waitTimeSeconds: '',
handleTimeSeconds: '',
reason: row.actorNote ?? null,
itemCount: row.itemCount,
failedCount: row.failedCount,
link: '',
};
}
return {
origin: 'Review Job',
outcome: row.decisions.flatMap((it) =>
getDecisionColorNamePairs(it, false).map(({ name }) => name),
),
policies: row.decisions.flatMap((it) => getPoliciesFromDecision(it)),
reviewer: getReviewerName(row.reviewerId),
queue: row.queueId ? getQueueName(row.queueId) : '',
jobCreatedAt: row.jobCreatedAt
? parseDatetimeToReadableStringInUTC(new Date(row.jobCreatedAt))
: '',
claimedAt: row.assignedAt
? parseDatetimeToReadableStringInUTC(new Date(row.assignedAt))
: '',
time: parseDatetimeToReadableStringInUTC(new Date(row.ts)),
...getDecisionTimingFields({
createdAt: row.ts,
assignedAt: row.assignedAt,
jobCreatedAt: row.jobCreatedAt,
}),
reason: row.decisionReason ?? null,
itemCount: null,
failedCount: null,
link: `${HOST_URL}/dashboard/manual_review/recent?jobId=${row.jobId ?? ''}`,
};
},
[
getActionColorNamePairs,
getDecisionColorNamePairs,
getPoliciesFromDecision,
getPolicyName,
getQueueName,
getReviewerName,
],
);

const tableRows: NormalizedActivityRow[] | undefined = useMemo(() => {
if (!activityData || !orgLookupData?.myOrg) {
return undefined;
Expand Down Expand Up @@ -1111,65 +1038,31 @@ export default function ManualReviewRecentDecisions() {
</Button>
);

// Activity export: pages the same cursor-paged query the table uses, via
// its own lazy-query instance (`getActivityForDownload`), looping until
// `nextCursor` is null. Capped at `CSV_MAX_PAGES` as a runaway guard.
const exportProblem = exportRangeProblem(unsavedFilterValue?.dateRange);
const downloadButton = (
<Button
className="rounded"
loading={downloadingActivity}
onClick={async () => {
setDownloadingActivity(true);
try {
const collected: CsvRow[] = [];
let downloadCursor: string | undefined;

for (let page = 0; page < CSV_MAX_PAGES; page++) {
const result = await getActivityForDownload({
variables: {
input: buildActivityInput(
unsavedFilterValue ?? {},
downloadCursor,
effectiveView,
),
},
});
const feed = result.data?.recentModerationActivity;
if (!feed) {
break;
}
collected.push(...feed.rows.map(toCsvRow));
if (!feed.nextCursor) {
break;
}
downloadCursor = feed.nextCursor;
}

const includeActions = effectiveView !== 'DECISIONS';
const blob = new Blob([buildActivityCsv(collected, includeActions)], {
type: 'text/csv',
});
const url = URL.createObjectURL(blob);
<div className="flex flex-col gap-1">
<Button
className="rounded"
disabled={exportProblem !== undefined}
onClick={() => {
const a = document.createElement('a');
a.href = url;
a.download = includeActions
? 'moderation-activity.csv'
: 'decisions.csv';
a.download = '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Download button now navigates to the export URL and never looks at the response. If the server returns a 4xx or 5xx, the user gets no in-page error and no loading state, and the browser's download handling decides what happens. Consider fetching the URL, checking response.ok, and only then creating the Blob and anchor, with the loading flag restored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx, line 1049:

<comment>The Download button now navigates to the export URL and never looks at the response. If the server returns a 4xx or 5xx, the user gets no in-page error and no loading state, and the browser's download handling decides what happens. Consider fetching the URL, checking `response.ok`, and only then creating the Blob and anchor, with the loading flag restored.</comment>

<file context>
@@ -1111,65 +1038,31 @@ export default function ManualReviewRecentDecisions() {
-          a.download = includeActions
-            ? 'moderation-activity.csv'
-            : 'decisions.csv';
+          a.download = '';
+          a.href = buildActivityExportUrl({
+            ...buildFilterInput(unsavedFilterValue ?? {}),
</file context>

a.href = buildActivityExportUrl({
...buildFilterInput(unsavedFilterValue ?? {}),
decisions: toExportDecisions(unsavedFilterValue?.decisions),
view: effectiveView,
});
a.click();
URL.revokeObjectURL(url);
} finally {
setDownloadingActivity(false);
}
}}
>
Download
</Button>
}}
>
Download
</Button>
{exportProblem !== undefined ? (
<div className="text-xs text-slate-400">{exportProblem}</div>
) : null}
</div>
);

// Skips export: `getSkipsForRecentDecisions` is untouched in the schema
// and still takes `{ filter, page }` — an offset, not the activity feed's
// cursor. It shares filters with the activity feed, not a paging
// position, so this loop is entirely separate from the one above.
const downloadSkips = (
<Button
className="rounded"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export default function ManualReviewRecentDecisionsFilter(props: {
const numberOfSavedFilters = Object.keys(input).length;

return (
<div className="flex items-center self-center text-start">
<div className="flex items-center text-start">
<div className="pr-2 font-semibold text-slate-500 whitespace-nowrap">
Filter by
</div>
Expand Down
48 changes: 48 additions & 0 deletions client/src/webpages/dashboard/mrt/activityExportUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { jsonStringify } from '../../../utils/typescript-types';
import { exportRangeProblem, toExportDecisions } from './activityExportUrl';
import { type DecisionOrAction } from './ManualReviewRecentDecisionsFilter';

describe('exportRangeProblem', () => {
const now = new Date('2026-10-09T12:00:00Z');
const tooLong = 'Downloads are limited to 400 days. Narrow the date range.';

it.each([
[undefined, 'Set a date range to download.'],
[{}, 'Set a date range to download.'],
[
{
startDate: new Date('2025-01-01T00:00:00Z'),
endDate: new Date('2026-02-05T00:00:00Z'),
},
undefined,
],
[
{
startDate: new Date('2025-01-01T00:00:00Z'),
endDate: new Date('2026-02-06T00:00:00Z'),
},
tooLong,
],
[{ startDate: new Date('2025-09-01T00:00:00Z') }, tooLong],
])('%o → %s', (range, expected) => {
expect(exportRangeProblem(range, now)).toBe(expected);
});
});

describe('toExportDecisions', () => {
it('sends each picked decision as its type, with the action for custom actions', () => {
const picked: DecisionOrAction[] = [
{ type: 'IGNORE' },
{ type: 'AUTOMATIC_CLOSE' },
{ type: 'CUSTOM_ACTION', actionId: 'act-1' },
{ type: 'ACCEPT_APPEAL', appealId: 'ap-1', actionIds: [] },
];

expect(toExportDecisions(picked.map((it) => jsonStringify(it)))).toEqual([
{ type: 'IGNORE' },
{ type: 'AUTOMATIC_CLOSE' },
{ type: 'CUSTOM_ACTION', actionIds: ['act-1'] },
{ type: 'ACCEPT_APPEAL' },
]);
});
});
Loading
Loading