Allow filtering courses, lessons, and questions when exporting#7968
Merged
Conversation
Sensei_Export_Job stores an array of post IDs per content type
('course', 'lesson', 'question'). Sensei_Export_Task uses that array
as post__in on its WP_Query, so the resulting CSV contains only the
selected items. An empty array for a type means "export every item of
that type", preserving today's behaviour.
The REST start endpoint accepts an optional 'selections' payload
alongside content_types. Each CSV is independent — there's no
cross-type cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the type-checkbox setup with a per-row layout: each content
type has a checkbox that toggles whether its CSV is produced, and a
filter token field (FormTokenField backed by /wp/v2/{type}) that
limits the CSV to specific items when populated. Defaults are all
boxes checked and all filters empty, so today's "export everything"
flow stays one click. Submitting an export with all rows unchecked
is disabled.
Each row renders as a card that's visually emphasised when included
and dimmed when skipped, so the active state is obvious at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Contributor
There was a problem hiding this comment.
Pull request overview
Adds “partial export” support to Sensei’s content exporter so admins can (a) skip individual CSVs per type and (b) limit export scope to specific courses/lessons/questions via a tokenized picker, with a live “what will be exported” summary.
Changes:
- Extend export job + REST start endpoint to persist per-type selected IDs (
selections) and apply them to export queries. - Update exporter setup UI to include per-type include toggles, token-based filters, and a live summary with totals fetched via REST.
- Add/adjust unit + JS tests and changelog entry for the new selection behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
includes/data-port/class-sensei-export-job.php |
Persists per-type selection state and exposes get_selection() for tasks. |
includes/rest-api/class-sensei-rest-api-export-controller.php |
Accepts/sanitizes selections in start payload and stores them on the job. |
includes/data-port/export-tasks/class-sensei-export-task.php |
Applies post__in filtering when selections exist for a content type. |
assets/data-port/export/store/actions.js |
Sends selections in the start payload (omitting empty arrays). |
assets/data-port/export/store/index.test.js |
Adds tests asserting selections payload behavior. |
assets/data-port/export/export-select-content-page.js |
New setup UI with include toggles, token filters, totals fetching, and summary. |
assets/data-port/export/post-token-field.js |
New token field component backed by /wp/v2/{type} endpoints. |
assets/data-port/export/export-select-content-page.test.js |
Updates tests for new UI flow, summary rendering, and disabling behavior. |
assets/data-port/export/export-page.js |
Wires new onSubmit({ types, selections }) signature into store start(). |
assets/data-port/export/export-page.test.js |
Updates copy expectation (“Choose what to export.”). |
assets/data-port/export/export.scss |
Adds layout + summary styles for the new setup UI. |
tests/unit-tests/data-port/test-class-sensei-export-job.php |
Adds unit tests for selections normalization/deduping/defaults. |
changelog/add-partial-export |
Documents the new partial export capability. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Pluralize the "X of Y" summary based on the row total so total=1 doesn't render as "1 of 1 courses". - Drop the dead checkbox-label SCSS rule (no wrapper element matched it) and remove the bolding it intended; default weight reads cleanly. - Give the per-row FormTokenField an accessible name via a visually-hidden label so screen reader users can identify each filter. - Defensively normalize selections in Sensei_Export_Job::set_selections so direct callers get the same absint+filter behavior the REST controller uses.
The defensive is_array() guard was added in response to PR review, but Psalm flagged it as a contradiction against the previous @param array docblock. Document the parameter as untrusted input so the guard is meaningful to the analyzer.
Selection order from the FormTokenField isn't a meaningful contract for exports, and ORDER BY FIELD() can't use the primary-key index — so the default ID order is both faster and more predictable across batches.
Sensei_Export_Job::set_selections() already sanitizes its input, so the controller-side method was a verbatim duplicate of the model's pipeline. The model is the right boundary — any future caller (CLI, tests, other endpoints) gets the same protection.
Use the testMethodName_Scenario_ExpectedResult triple per the team's unit test conventions, replacing the single CamelCase blob.
content_types was fully derivable from the keys of selections after the partial-export change. Removing the duplicated concept gives a single source of truth across the wire format, the REST controller, the model, and the JS action. In-flight jobs persisted before this change carry a flat list of type names under the legacy 'content_types' state key. A read-path branch in get_selections_state() translates that shape losslessly via array_fill_keys() so resumed jobs continue to behave as before.
Each row had five separate summary helpers (summaryAll, summaryAllUnknown, summaryCountOf, summaryCount, summarySkipped) with the dispatch logic implicit in which helper got called. Group all per-row translated strings into a single i18n object and hoist the dispatch into summaryFor, so each row reads as a flat list of literal i18n calls and the count-vs-total branching lives in one place.
Move the per-content-type ROWS config out of the page component into its own constants.js sibling, matching the precedent in other data-port and admin modules. The page component shrinks to its React shell and the i18n-heavy config becomes navigable on its own.
Reorder the rules to match the visual order on the page (body → select content → summary → footer), and nest the visually-hidden FormTokenField label rule inside the row block it belongs to instead of repeating the parent selector at the top level.
Annotate the non-obvious branches in summaryFor, the X-WP-Total fetch pattern, and the ROWS-order preservation so future readers don't have to reverse-engineer the intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass help="" instead, per @wordpress/components 6.x guidance: the help prop now defaults to the previous how-to text, and an empty string suppresses it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the parallel selectedItems and suggestionItems state slices with a single accumulating itemsById Map plus an ephemeral suggestionIds array. The cache outlives any one search, so a token's title survives when the item drops out of the latest suggestion list — without the spread-and-dedupe step the old shape required on every render. Also drop the redundant nextSelectedItems tracking in onTokensChange: selected items are already in the cache (they came from a previous fetch), so onChange( nextIds ) is enough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walk through the non-obvious bits: the title-context fallback, the itemsById cache lifetime, the debounce + stale-response guard, the "#<id>" fallback for uncached ids, and each step of the label→id conversion in onTokensChange. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The help="" swap relied on guidance for a newer @wordpress/components release. The version shipped with Sensei still gates the how-to text on __experimentalShowHowTo (default true), so passing help="" left the "Separate with commas or the Enter key." text visible. Revert the prop to suppress it again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sensei_Data_Port_Job::get_state() returns [] for unset keys, so the is_array() check on the new selections state always passed and the legacy content_types branch never fired. Require a non-empty selections state before short-circuiting so in-flight jobs persisted under the pre-partial-export shape continue to export the right content types after upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The method pre-dates the partial-export work and was removed when content_types collapsed into selections. It is public on a class without an @internal marker, so third-party integrations may have built on it. Reinstate it as a thin delegate to set_selections() that emits a _deprecated_function() notice — preserving existing behaviour (every passed type exports all of its items) while pointing callers at the new API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testSetSelections_PopulatesContentTypesFromKeys is the dedicated test for keys-derive-content-types, so the get_content_types() assertions piggybacked onto the dedup/cast and missing-type tests added no new coverage. Remove them, and rename the missing-type test to reflect what its remaining assertion actually checks (a get_selection round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post__in clause that ships in this PR — restricting an export to the post IDs from the job's selection — had no test backing it. Add a courses-side test that creates two courses and asserts only the selected one ends up in the CSV. Extend the shared trait helper with an optional selections array so other subclass tests can reuse the same setup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing testStartJob still sent {content_types: [...]} and read
the legacy state key, both of which the controller stopped honoring
when the payload moved to {selections: {...}}. Rewrite it to use the
new shape and split out two more cases that lock down what this PR
actually shipped: the per-type post IDs round-trip onto the job, and
a missing-selections payload is rejected with a 400. Pull the shared
job-creation, dispatch, and reload boilerplate into private helpers
so each test reads as just its scenario.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The component had no direct test file. Cover the four behaviours that matter to the rest of the export flow without driving FormTokenField's suggestion-list UI (jsdom can't): the REST path it hits per content type, that pre-selected ids render as titles after the fetch resolves, that uncached ids fall back to "#<id>", and that the cache survives a later fetch that doesn't include the selected id — the invariant that the recent state simplification depends on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback can't be reached in the current export flow — selections start empty and only grow when the user picks a suggestion (already in the cache). The test was exercising defensive code for a feature that doesn't exist; revisit if we ever pre-populate selectedIds from saved presets or deep links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous version rerendered with identical props, so no second fetch fired and the assertion passed vacuously. Type into the field instead — the debounce + fetch effect actually runs, asserts a second apiFetch call, and only then checks that the cached token survives. Stub Element.prototype.scrollIntoView at suite level so jsdom doesn't choke when FormTokenField auto-selects the first match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original set_content_types took string[] without validating. The shim restored in 7064010 added an is_array() coercion that didn't match what we're replacing — and Psalm flagged the check as unreachable given the array docblock. Drop the coercion and keep the @param string[] declaration so the shim preserves the original behaviour exactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
start() unconditionally chained createJob() into startJob(), even when
the create request failed. sendRequest already called setError() with
the actual server message, but startJob then went through
sendJobRequest() which called setError('No job ID'), overwriting the
useful error with a generic one. createJob() also did setJob(undefined)
on failure, wiping the in-progress 'creating' state.
Fix both: bail out of start() when getJobId() returns nothing after
createJob (the error is already set), and skip the setJob(undefined)
write on failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set_selections() drops unknown keys and non-array values, so a payload
like { "selections": { "courses": [1] } } (typo, plural) survives the
controller's is_array() check but ends up storing nothing. The job
then completed as a no-op export with a 200 response.
After set_selections() runs, re-check get_content_types() and return
the same 400 if it's empty. Pull the WP_Error construction into a
helper so both rejection paths share one source. Test that an
unknown-key payload is now rejected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sensei_Export_Task processes posts in batches of 30. The post__in clause added in this PR combines with offset/posts_per_page in WP_Query, but no test ran the task across more than one batch with a selection. A regression in offset/IN-list interaction would silently truncate large exports without failing the suite. Add export_to_completion() to the shared trait helper — it re-instantiates the task each iteration so the constructor picks up the persisted completed-posts offset, mirroring what the real job runner does. Use it in a courses test that selects 35 items and asserts every one ends up in the CSV exactly once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selection plumbing was only verified for courses. A regression that mis-keyed the get_selection() lookup for non-course types (e.g. 'lessons' vs 'lesson') would have slipped through. Mirror the courses selection test in the lessons and questions suites, plus a stray trailing-newline cleanup in the package test that phpcbf fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
post__in silently returns an empty result for IDs that no longer match an exportable post (deleted, force-trashed, or wrong post type). The task would just produce a CSV with fewer rows than the user expected and no diagnostic. On the first batch, run a lightweight ids-only query against the selection and emit a job log notice naming any IDs that didn't come back. Future readers of the job log get an actionable explanation instead of an unexplained short export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both useEffect fetches in the export setup screen swallowed errors silently: a failing token suggestion request left the dropdown blank (indistinguishable from "no matches"), and a failing totals request quietly took the summary down to "All courses" instead of "All N courses". Either could mask a real regression for as long as it takes someone to notice the missing data. Keep the fallbacks (the UI stays usable in both cases) but log the underlying error to console.warn so a developer triaging an issue has somewhere to start. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'No content types selected' error string predated the partial
export work — the wire format is now keyed on per-type selections, so
that wording is misleading both when no types are picked and when an
API consumer sends an unknown key. Update the message and add an
explicit allow-list check that rejects payloads containing keys
outside { course, lesson, question } instead of silently dropping
them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
window.sensei_log_event was mocked but never inspected, so the analytics derivation in start() — pluralise type keys, sort, join with commas — was unverified. A regression that broke the pluralisation, sort, or event name would have shipped silently. Add direct assertions on the call in both happy-path tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original wording — "Any other value is treated as empty" — conflated two different normalisation paths: a non-array argument is replaced wholesale, while unknown keys and non-array per-type values are dropped during the per-type loop. Spell those out so callers can predict what survives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit 9223737.
@wordpress/components 6.8 deprecated the 36px default size for FormTokenField. Pass __next40pxDefaultSize so we adopt the new default size now and silence the deprecation notice ahead of the 7.1 removal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The unknown-key and post-normalisation re-checks only protected third-party callers from silently dropped fields, and the JS UI never trips them. Let `set_selections()` normalise the payload and leave the silent drops in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this, a 5xx (or any throw) from POST /export left
{ status: 'creating' } on the store with no id. The setup screen reads
that as "still loading" and disables Start Export until the page is
reloaded. Now createJob clears the synthetic state on failure and
re-applies the server error so the user can retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the id->title cache lived in PostTokenField's local state, so unchecking a row's checkbox unmounted the field and threw the cache away. Re-checking the row remounted with an empty cache, and any selected ids that didn't appear in the next suggestion fetch fell back to "#<id>". The cache now lives on ExportSelectContentPage (one Map per type) and is passed in via cachedItems / onItemsFetched, so titles survive across mount cycles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sensei_Data_Port_Manager::run_data_port_job()deferred persistence toshutdown, so when Action Scheduler claimed multiple actions in one PHP request the next action would reload stale state, recreate the per-type CSV attachments and reprocess posts — accumulating hundreds of orphan CSVs in the media library. Now persists immediately after eachrun().Test plan
All 3 courses / 1 lesson / No questions.courses.csv,lessons.csv, andquestions.csvwith every published/draft item of each type — same content as today's all-three flow.1 of N coursesand the Courses CSV in the export contains only that course. Lessons and Questions CSVs are unchanged (no cross-type cascade).Lessons — skipped. After exporting, nolessons.csvis in the zip.Algebra — Module 1with an em-dash). Confirm the token in the filter field renders asAlgebra — Module 1, notAlgebra – Module 1..zipis in Media Library — no orphan per-type CSVs left behind.🤖 Generated with Claude Code