Skip to content

Allow filtering courses, lessons, and questions when exporting#7968

Merged
donnapep merged 42 commits into
trunkfrom
add/partial-export
May 6, 2026
Merged

Allow filtering courses, lessons, and questions when exporting#7968
donnapep merged 42 commits into
trunkfrom
add/partial-export

Conversation

@donnapep
Copy link
Copy Markdown
Member

@donnapep donnapep commented May 1, 2026

Summary

  • Adds a per-type filter to the export setup screen so users can pick specific courses, lessons, and/or questions instead of always exporting every item of a type.
  • Each row also has an include/exclude checkbox so users can skip a CSV entirely.
  • A live summary above the Start Export button describes exactly what the export will produce, with totals fetched from the WP REST API.
  • Also fixes a pre-existing data-port bug surfaced by the new flow: Sensei_Data_Port_Manager::run_data_port_job() deferred persistence to shutdown, 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 each run().
  • Closes Allow Partial Export / Import  #6899.

Test plan

  • Visit Sensei LMS → Tools → Export Content. The screen shows three rows (Courses, Lessons, Questions), each with a checkbox and a filter field, and a summary panel above the Start Export button reading e.g. All 3 courses / 1 lesson / No questions.
  • Click Start Export with no changes. Confirm the resulting zip contains courses.csv, lessons.csv, and questions.csv with every published/draft item of each type — same content as today's all-three flow.
  • Pick a course in the Courses filter (type a substring, press Enter). The summary updates to 1 of N courses and the Courses CSV in the export contains only that course. Lessons and Questions CSVs are unchanged (no cross-type cascade).
  • Uncheck the Lessons row. The Lessons filter field disappears and the summary line reads Lessons — skipped. After exporting, no lessons.csv is in the zip.
  • Uncheck all three rows. Start Export is disabled.
  • Create a course, lesson, or question with HTML entities in the title (e.g., Algebra — Module 1 with an em-dash). Confirm the token in the filter field renders as Algebra — Module 1, not Algebra – Module 1.
  • After a completed export, only the bundle .zip is in Media Library — no orphan per-type CSVs left behind.

🤖 Generated with Claude Code

donnapep and others added 3 commits May 1, 2026 14:12
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>
Copilot AI review requested due to automatic review settings May 1, 2026 19:15
@donnapep donnapep self-assigned this May 1, 2026
@donnapep donnapep added this to the 4.26.0 milestone May 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

WordPress Playground Preview

The changes in this pull request can previewed and tested using a WordPress Playground instance.

Open WordPress Playground Preview

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread assets/data-port/export/export-select-content-page.js Outdated
Comment thread assets/data-port/export/export-select-content-page.js Outdated
Comment thread assets/data-port/export/export-select-content-page.js Outdated
Comment thread assets/data-port/export/export.scss Outdated
Comment thread assets/data-port/export/export-select-content-page.js
Comment thread includes/data-port/class-sensei-export-job.php
donnapep and others added 20 commits May 1, 2026 16:02
- 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>
donnapep and others added 14 commits May 5, 2026 12:13
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>
@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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread assets/data-port/export/post-token-field.js
Comment thread assets/data-port/export/post-token-field.js
Comment thread assets/data-port/export/store/actions.js Outdated
Comment thread assets/data-port/export/export-select-content-page.js
Comment thread includes/rest-api/class-sensei-rest-api-export-controller.php Outdated
Comment thread includes/data-port/class-sensei-export-job.php
donnapep and others added 5 commits May 5, 2026 16:00
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>
@donnapep donnapep merged commit 2d346be into trunk May 6, 2026
27 checks passed
@donnapep donnapep deleted the add/partial-export branch May 6, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow Partial Export / Import

2 participants