From 5e9d219a29dea7514db3eea05b077cb165b6bce8 Mon Sep 17 00:00:00 2001 From: John Grimes Date: Thu, 19 Feb 2026 21:38:11 +1000 Subject: [PATCH 01/18] feat: Add _typeFilter support to bulk export operation Implement the _typeFilter parameter from the FHIR Bulk Data Access specification. Filters use FHIR search queries to select resources during export, with multiple filters per type combined using OR logic. Includes parsing, validation (strict/lenient), implicit type inclusion, and integration with the export execution pipeline via PathlingContext.searchToColumn(). --- .../.openspec.yaml | 2 + .../design.md | 68 ++++++ .../proposal.md | 30 +++ .../specs/bulk-export-type-filter/spec.md | 132 +++++++++++ .../tasks.md | 31 +++ .../specs/bulk-export-type-filter/spec.md | 132 +++++++++++ .../operations/bulkexport/ExportExecutor.java | 38 ++++ .../bulkexport/ExportOperationValidator.java | 203 ++++++++++++++++- .../operations/bulkexport/ExportRequest.java | 5 + .../bulkexport/GroupExportProvider.java | 9 +- .../bulkexport/PatientExportProvider.java | 26 ++- .../bulkexport/SystemExportProvider.java | 8 +- .../ExportOperationExecutorTest.java | 145 ++++++++++++ .../bulkexport/ExportOperationIT.java | 209 +++++++++++++++++ .../bulkexport/ExportOperationTest.java | 19 +- .../ExportOperationValidatorTest.java | 211 ++++++++++++++++++ .../security/SecurityTestForOperations.java | 3 + .../pathling/util/ExportOperationUtil.java | 3 + .../pathling/util/ExportRequestBuilder.java | 2 + site/docs/server/operations/export.md | 59 ++++- 20 files changed, 1310 insertions(+), 25 deletions(-) create mode 100644 openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/design.md create mode 100644 openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/proposal.md create mode 100644 openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/specs/bulk-export-type-filter/spec.md create mode 100644 openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/tasks.md create mode 100644 openspec/specs/bulk-export-type-filter/spec.md diff --git a/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/.openspec.yaml b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/.openspec.yaml new file mode 100644 index 0000000000..d299748398 --- /dev/null +++ b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-19 diff --git a/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/design.md b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/design.md new file mode 100644 index 0000000000..a16c4d37b6 --- /dev/null +++ b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/design.md @@ -0,0 +1,68 @@ +## Context + +The Pathling server implements the FHIR Bulk Data Access specification for exporting large datasets. Currently, `_typeFilter` is listed in `ExportOperationValidator.UNSUPPORTED_QUERY_PARAMS` and is rejected (strict mode) or silently ignored (lenient mode). + +The server already has mature infrastructure for translating FHIR search parameters into SparkSQL Column expressions. `PathlingContext.searchToColumn()` provides a public API that takes a resource type and a search query string, returning a SparkSQL Column expression. This is backed by `SearchColumnBuilder` and `FhirSearch` in the `fhirpath` module, and is the same infrastructure used by the `SearchProvider` for the standard FHIR search operation. `PathlingContext` is already injected into `ExportExecutor`. The bulk export execution pipeline operates on `QueryableDataSource`, which supports per-resource-type dataset transformations via its `map(BiFunction, Dataset>)` method. + +The `_typeFilter` parameter format is `[ResourceType]?[search-params]` (e.g., `MedicationRequest?status=active`). Multiple `_typeFilter` values targeting the same resource type are combined with OR logic per the specification. + +## Goals / Non-goals + +**Goals:** + +- Accept and validate `_typeFilter` values on all three export endpoints. +- Parse the resource type prefix and search query from each `_typeFilter` value. +- Apply search filters during export execution using `PathlingContext.searchToColumn()`. +- Support all search parameter types already supported by the search infrastructure (token, string, date, number, quantity, reference, URI). +- Handle lenient mode consistently with existing parameter handling patterns. +- Implicitly include resource types referenced in `_typeFilter` when `_type` is not specified, per the Bulk Data specification. + +**Non-goals:** + +- Supporting `_include` or `_sort` within `_typeFilter` queries (explicitly excluded by the specification). +- Supporting composite or special search parameter types (not yet implemented in the search infrastructure). +- Extending the search infrastructure with new capabilities; only using what already exists. + +## Decisions + +### 1. Use `PathlingContext.searchToColumn()` for filter translation + +**Decision:** Use the existing `PathlingContext.searchToColumn()` method to translate `_typeFilter` search queries into SparkSQL Column expressions. `PathlingContext` is already injected into `ExportExecutor`, so no new dependencies are needed. + +**Rationale:** This is the public API for search-to-column translation, backed by `SearchColumnBuilder` and `FhirSearch`. It handles all standard FHIR search parameter types, modifiers, complex type expansion (HumanName, Address), and escaping. Building a separate filter mechanism would duplicate logic and diverge from the behaviour of the standard search operation. + +**Alternative considered:** Implementing a simpler string-based filter mechanism. Rejected because it would not support the full range of FHIR search semantics. + +### 2. Parse `_typeFilter` in `ExportOperationValidator` + +**Decision:** Parse and validate `_typeFilter` values in the validator, producing a `Map>` keyed by resource type code. Each value is a list of search query strings (multiple filters for the same type are OR'd). The raw query strings are stored rather than parsed `FhirSearch` objects, since `PathlingContext.searchToColumn()` accepts strings directly. + +**Rationale:** Keeps validation and parsing co-located with existing parameter processing. The parsed searches can be validated early (e.g., unknown resource types, invalid search parameters) before the async job starts. + +**Alternative considered:** Parsing in `ExportExecutor`. Rejected because validation errors should be reported synchronously before the 202 Accepted response, consistent with how `_type` validation works. + +### 3. Store parsed type filters in `ExportRequest` + +**Decision:** Add a `Map> typeFilters` field to the `ExportRequest` record, keyed by resource type code. Values are search query strings (e.g., `"code=8867-4&date=ge2024-01-01"`). + +**Rationale:** The record already carries all other parsed export parameters. Storing the query strings keeps the request record simple and aligns with `PathlingContext.searchToColumn()` which accepts strings directly. + +### 4. Apply filters in `ExportExecutor` using `QueryableDataSource.map()` + +**Decision:** Add an `applyTypeFilters` step in the `ExportExecutor.execute()` pipeline, after resource type filtering and date filtering but before patient compartment filtering. For each resource type with filters, call `PathlingContext.searchToColumn()` for each query string and combine with OR, then apply as a dataset filter. + +**Rationale:** Placing the filter after resource type filtering ensures we only build search columns for resource types that are actually being exported. Placing it before patient compartment filtering maintains the existing pipeline ordering where broad filters apply first. + +### 5. Implicit resource type inclusion from `_typeFilter` + +**Decision:** When `_type` is not specified but `_typeFilter` is, the resource types mentioned in `_typeFilter` are treated as the effective type filter. When both are specified, `_typeFilter` entries for types not in `_type` are silently ignored in lenient mode or rejected in strict mode. + +**Rationale:** This follows the Bulk Data specification: "If no `_type` parameter is provided, the server SHALL include resources of all types that are referenced in any `_typeFilter` parameter." + +## Risks / Trade-offs + +- **Unknown search parameters:** Some search parameters may not be supported by the search infrastructure (e.g., composite, special types). If a `_typeFilter` references an unsupported parameter, the server will return an error in strict mode or skip the filter in lenient mode. + +- **Performance:** Complex `_typeFilter` queries with many criteria may generate large SparkSQL expressions. Mitigation: Spark's query optimiser handles predicate pushdown effectively, and the filter is applied before data is written to the output files. + +- **No server-side search parameter documentation:** The server does not currently advertise supported search parameters in its CapabilityStatement. Clients may need to discover supported parameters through trial and error. This is an existing limitation and out of scope for this change. diff --git a/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/proposal.md b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/proposal.md new file mode 100644 index 0000000000..9d72f6ee7b --- /dev/null +++ b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/proposal.md @@ -0,0 +1,30 @@ +## Why + +The `_typeFilter` parameter is a standard part of the FHIR Bulk Data Access specification (v3.0.0) that allows clients to apply FHIR search queries to filter exported resources. Pathling already supports the `_type` parameter for coarse resource type filtering, but lacks the ability to apply fine-grained search criteria during export (e.g., only active medications, or observations from a specific date range). This limits the usefulness of bulk export for clients that need targeted subsets of data without downloading and post-filtering entire resource collections. The server currently rejects `_typeFilter` in strict mode and ignores it in lenient mode. + +## What changes + +- Parse and validate `_typeFilter` values in the export operation validator, extracting the resource type prefix and search query string from each filter (format: `[ResourceType]?[search-params]`). +- Add `_typeFilter` to the `ExportRequest` record so that parsed type filters are available during execution. +- Add a `_typeFilter` parameter to all three export provider signatures (`SystemExportProvider`, `PatientExportProvider`, `GroupExportProvider`). +- Apply type filters during export execution in `ExportExecutor` using the existing `SearchColumnBuilder` infrastructure, which already translates FHIR search queries into SparkSQL Column expressions. +- Remove `_typeFilter` from the `UNSUPPORTED_QUERY_PARAMS` set. +- Multiple `_typeFilter` values targeting the same resource type are combined with OR logic, per the specification. +- When `_typeFilter` is provided without a corresponding `_type` entry, the resource types from `_typeFilter` are implicitly included, per the specification. + +## Capabilities + +### New capabilities + +- `bulk-export-type-filter`: Support for the `_typeFilter` parameter in the bulk export operation, enabling FHIR search-based filtering of exported resources. + +### Modified capabilities + +(none) + +## Impact + +- **Server module**: Changes to export providers, validator, request record, and executor. +- **API surface**: New query parameter accepted on all three `$export` endpoints. +- **Dependencies**: Leverages existing `SearchColumnBuilder` and `FhirSearch` from the `fhirpath` module (no new dependencies). +- **Backwards compatibility**: Fully backwards compatible. Existing requests without `_typeFilter` behave identically. diff --git a/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/specs/bulk-export-type-filter/spec.md b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/specs/bulk-export-type-filter/spec.md new file mode 100644 index 0000000000..b21dc2596a --- /dev/null +++ b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/specs/bulk-export-type-filter/spec.md @@ -0,0 +1,132 @@ +## ADDED Requirements + +### Requirement: Accept \_typeFilter parameter on export endpoints + +The server SHALL accept the `_typeFilter` query parameter on all three export endpoints (`/$export`, `/Patient/$export`, `/Group/[id]/$export`). Each `_typeFilter` value SHALL be a string in the format `[ResourceType]?[search-params]`, where `[ResourceType]` is a valid FHIR resource type code and `[search-params]` is a valid FHIR search query string. + +#### Scenario: Valid \_typeFilter on system export + +- **WHEN** a client sends `GET /$export?_type=Observation&_typeFilter=Observation?code=8867-4` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +#### Scenario: Valid \_typeFilter on patient export + +- **WHEN** a client sends `GET /Patient/$export?_type=MedicationRequest&_typeFilter=MedicationRequest?status=active` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +#### Scenario: Valid \_typeFilter on group export + +- **WHEN** a client sends `GET /Group/123/$export?_type=Condition&_typeFilter=Condition?code=73211009` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +### Requirement: Parse \_typeFilter resource type and search query + +The server SHALL parse each `_typeFilter` value by splitting on the first `?` character. The portion before the `?` SHALL be interpreted as the resource type code. The portion after the `?` SHALL be interpreted as a FHIR search query string. + +#### Scenario: Parse resource type and search query + +- **WHEN** a `_typeFilter` value is `Observation?code=8867-4&date=ge2024-01-01` +- **THEN** the server SHALL parse resource type as `Observation` and search query as `code=8867-4&date=ge2024-01-01` + +#### Scenario: Reject \_typeFilter without question mark separator + +- **WHEN** a `_typeFilter` value does not contain a `?` character (e.g., `Observation`) +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome describing the invalid format + +#### Scenario: Reject \_typeFilter with empty search query + +- **WHEN** a `_typeFilter` value has an empty search query after the `?` (e.g., `Observation?`) +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome describing the invalid format + +### Requirement: Validate \_typeFilter resource types + +The server SHALL validate that the resource type in each `_typeFilter` value is a valid, supported FHIR resource type. Invalid resource types SHALL be handled according to the strict/lenient mode. + +#### Scenario: Invalid resource type in strict mode + +- **WHEN** a `_typeFilter` value references an invalid resource type (e.g., `FakeResource?status=active`) without lenient handling +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome + +#### Scenario: Invalid resource type in lenient mode + +- **WHEN** a `_typeFilter` value references an invalid resource type with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the `_typeFilter` value and include an informational OperationOutcome issue in the response + +### Requirement: Validate \_typeFilter search parameters + +The server SHALL validate that the search parameters within each `_typeFilter` value are recognised search parameters for the specified resource type. Unknown search parameters SHALL be handled according to the strict/lenient mode. + +#### Scenario: Unknown search parameter in strict mode + +- **WHEN** a `_typeFilter` value contains an unknown search parameter (e.g., `Patient?unknownParam=value`) without lenient handling +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome + +#### Scenario: Unknown search parameter in lenient mode + +- **WHEN** a `_typeFilter` value contains an unknown search parameter with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the individual `_typeFilter` value and include an informational OperationOutcome issue + +### Requirement: Apply \_typeFilter as row-level search filter + +The server SHALL apply each `_typeFilter` value as a row-level filter during export execution. Only resources matching the search criteria SHALL be included in the export output for that resource type. + +#### Scenario: Filter observations by code + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4` +- **THEN** the exported Observation resources SHALL include only those with a code matching `8867-4`, and the output SHALL NOT contain Observation resources with other codes + +#### Scenario: Filter with multiple search criteria (AND logic) + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&date=ge2024-01-01` +- **THEN** the exported Observation resources SHALL include only those matching both criteria (code `8867-4` AND date on or after 2024-01-01) + +#### Scenario: No matching resources + +- **WHEN** an export is requested with a `_typeFilter` that matches no resources +- **THEN** the export SHALL complete successfully, and the output manifest SHALL not include a file entry for that resource type + +### Requirement: Combine multiple \_typeFilter values for same resource type with OR logic + +When multiple `_typeFilter` values target the same resource type, the server SHALL combine them with OR logic. A resource is included if it matches any one of the filters for its type. + +#### Scenario: OR combination of filters for same type + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&_typeFilter=Observation?code=8310-5` +- **THEN** the exported Observation resources SHALL include those matching code `8867-4` OR code `8310-5` + +### Requirement: Implicit resource type inclusion from \_typeFilter + +When `_type` is not provided but `_typeFilter` is, the server SHALL include the resource types referenced in the `_typeFilter` values as the effective resource type filter. + +#### Scenario: \_typeFilter without \_type implies resource types + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&_typeFilter=Condition?code=73211009` and no `_type` parameter +- **THEN** the export SHALL include Observation and Condition resources (filtered by their respective criteria) and SHALL NOT include other resource types + +### Requirement: \_typeFilter consistency with \_type parameter + +When both `_type` and `_typeFilter` are provided, `_typeFilter` values that reference resource types not listed in `_type` SHALL be handled according to the strict/lenient mode. + +#### Scenario: \_typeFilter type not in \_type in strict mode + +- **WHEN** an export is requested with `_type=Patient&_typeFilter=Observation?code=8867-4` without lenient handling +- **THEN** the server SHALL return 400 Bad Request because `Observation` is not included in `_type` + +#### Scenario: \_typeFilter type not in \_type in lenient mode + +- **WHEN** an export is requested with `_type=Patient&_typeFilter=Observation?code=8867-4` with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the `Observation` type filter, export only Patient resources, and include an informational OperationOutcome issue + +#### Scenario: \_typeFilter matches subset of \_type + +- **WHEN** an export is requested with `_type=Patient,Observation&_typeFilter=Observation?code=8867-4` +- **THEN** the export SHALL include all Patient resources (unfiltered) and only Observation resources matching code `8867-4` + +### Requirement: \_typeFilter values with resource types not in Patient compartment are handled during patient and group exports + +For patient-level and group-level exports, `_typeFilter` values that reference resource types not in the Patient compartment SHALL be handled consistently with existing `_type` behaviour (silently ignored). + +#### Scenario: Non-compartment resource type in patient export \_typeFilter + +- **WHEN** a patient export is requested with `_typeFilter=Organization?name=test` +- **THEN** the server SHALL silently ignore the `Organization` type filter since Organization is not in the Patient compartment diff --git a/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/tasks.md b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/tasks.md new file mode 100644 index 0000000000..3f324ccbbf --- /dev/null +++ b/openspec/changes/archive/2026-02-19-add-type-filter-to-bulk-export/tasks.md @@ -0,0 +1,31 @@ +## 1. Request model + +- [x] 1.1 Add `typeFilters` field (`Map>`) to `ExportRequest` record, keyed by resource type code with search query strings as values + +## 2. Parsing and validation + +- [x] 2.1 Write unit tests for `_typeFilter` parsing and validation in `ExportOperationValidatorTest` (format validation, resource type validation, search parameter validation, strict/lenient handling, consistency with `_type`, implicit type inclusion) +- [x] 2.2 Implement `_typeFilter` parsing in `ExportOperationValidator` (split on `?`, extract resource type and search query string, validate resource type) +- [x] 2.3 Remove `_typeFilter` from `UNSUPPORTED_QUERY_PARAMS` set +- [x] 2.4 Implement implicit resource type inclusion when `_type` is absent but `_typeFilter` is present +- [x] 2.5 Implement consistency validation between `_type` and `_typeFilter` (strict/lenient) + +## 3. Export providers + +- [x] 3.1 Add `_typeFilter` `@OperationParam` to `SystemExportProvider.export()` and update `preAsyncValidate()` +- [x] 3.2 Add `_typeFilter` `@OperationParam` to `PatientExportProvider.exportAllPatients()` and `exportSinglePatient()` and update `preAsyncValidate()` +- [x] 3.3 Add `_typeFilter` `@OperationParam` to `GroupExportProvider.exportGroup()` and update `preAsyncValidate()` +- [x] 3.4 Pass `_typeFilter` through validator methods (`validateRequest` and `validatePatientExportRequest`) + +## 4. Export execution + +- [x] 4.1 Write unit tests for `_typeFilter` application in `ExportExecutor` (single filter, multiple filters OR logic, no matching resources, filter for type not in export) +- [x] 4.2 Implement `applyTypeFilters` method in `ExportExecutor` using `PathlingContext.searchToColumn()` to generate Spark filter columns +- [x] 4.3 Integrate `applyTypeFilters` into the `execute()` pipeline (after date filtering, before patient compartment filtering) + +## 5. Integration tests + +- [x] 5.1 Add integration tests for `_typeFilter` on system-level export (valid filter, filter reduces output, multiple filters OR logic) +- [x] 5.2 Add integration tests for `_typeFilter` on patient-level export +- [x] 5.3 Add integration tests for `_typeFilter` strict/lenient error handling (invalid format, unknown resource type, type not in `_type`, unknown search parameter) +- [x] 5.4 Add integration test for `_typeFilter` without `_type` (implicit type inclusion) diff --git a/openspec/specs/bulk-export-type-filter/spec.md b/openspec/specs/bulk-export-type-filter/spec.md new file mode 100644 index 0000000000..b21dc2596a --- /dev/null +++ b/openspec/specs/bulk-export-type-filter/spec.md @@ -0,0 +1,132 @@ +## ADDED Requirements + +### Requirement: Accept \_typeFilter parameter on export endpoints + +The server SHALL accept the `_typeFilter` query parameter on all three export endpoints (`/$export`, `/Patient/$export`, `/Group/[id]/$export`). Each `_typeFilter` value SHALL be a string in the format `[ResourceType]?[search-params]`, where `[ResourceType]` is a valid FHIR resource type code and `[search-params]` is a valid FHIR search query string. + +#### Scenario: Valid \_typeFilter on system export + +- **WHEN** a client sends `GET /$export?_type=Observation&_typeFilter=Observation?code=8867-4` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +#### Scenario: Valid \_typeFilter on patient export + +- **WHEN** a client sends `GET /Patient/$export?_type=MedicationRequest&_typeFilter=MedicationRequest?status=active` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +#### Scenario: Valid \_typeFilter on group export + +- **WHEN** a client sends `GET /Group/123/$export?_type=Condition&_typeFilter=Condition?code=73211009` with `Prefer: respond-async` and `Accept: application/fhir+json` +- **THEN** the server SHALL return 202 Accepted with a `Content-Location` header + +### Requirement: Parse \_typeFilter resource type and search query + +The server SHALL parse each `_typeFilter` value by splitting on the first `?` character. The portion before the `?` SHALL be interpreted as the resource type code. The portion after the `?` SHALL be interpreted as a FHIR search query string. + +#### Scenario: Parse resource type and search query + +- **WHEN** a `_typeFilter` value is `Observation?code=8867-4&date=ge2024-01-01` +- **THEN** the server SHALL parse resource type as `Observation` and search query as `code=8867-4&date=ge2024-01-01` + +#### Scenario: Reject \_typeFilter without question mark separator + +- **WHEN** a `_typeFilter` value does not contain a `?` character (e.g., `Observation`) +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome describing the invalid format + +#### Scenario: Reject \_typeFilter with empty search query + +- **WHEN** a `_typeFilter` value has an empty search query after the `?` (e.g., `Observation?`) +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome describing the invalid format + +### Requirement: Validate \_typeFilter resource types + +The server SHALL validate that the resource type in each `_typeFilter` value is a valid, supported FHIR resource type. Invalid resource types SHALL be handled according to the strict/lenient mode. + +#### Scenario: Invalid resource type in strict mode + +- **WHEN** a `_typeFilter` value references an invalid resource type (e.g., `FakeResource?status=active`) without lenient handling +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome + +#### Scenario: Invalid resource type in lenient mode + +- **WHEN** a `_typeFilter` value references an invalid resource type with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the `_typeFilter` value and include an informational OperationOutcome issue in the response + +### Requirement: Validate \_typeFilter search parameters + +The server SHALL validate that the search parameters within each `_typeFilter` value are recognised search parameters for the specified resource type. Unknown search parameters SHALL be handled according to the strict/lenient mode. + +#### Scenario: Unknown search parameter in strict mode + +- **WHEN** a `_typeFilter` value contains an unknown search parameter (e.g., `Patient?unknownParam=value`) without lenient handling +- **THEN** the server SHALL return 400 Bad Request with an OperationOutcome + +#### Scenario: Unknown search parameter in lenient mode + +- **WHEN** a `_typeFilter` value contains an unknown search parameter with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the individual `_typeFilter` value and include an informational OperationOutcome issue + +### Requirement: Apply \_typeFilter as row-level search filter + +The server SHALL apply each `_typeFilter` value as a row-level filter during export execution. Only resources matching the search criteria SHALL be included in the export output for that resource type. + +#### Scenario: Filter observations by code + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4` +- **THEN** the exported Observation resources SHALL include only those with a code matching `8867-4`, and the output SHALL NOT contain Observation resources with other codes + +#### Scenario: Filter with multiple search criteria (AND logic) + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&date=ge2024-01-01` +- **THEN** the exported Observation resources SHALL include only those matching both criteria (code `8867-4` AND date on or after 2024-01-01) + +#### Scenario: No matching resources + +- **WHEN** an export is requested with a `_typeFilter` that matches no resources +- **THEN** the export SHALL complete successfully, and the output manifest SHALL not include a file entry for that resource type + +### Requirement: Combine multiple \_typeFilter values for same resource type with OR logic + +When multiple `_typeFilter` values target the same resource type, the server SHALL combine them with OR logic. A resource is included if it matches any one of the filters for its type. + +#### Scenario: OR combination of filters for same type + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&_typeFilter=Observation?code=8310-5` +- **THEN** the exported Observation resources SHALL include those matching code `8867-4` OR code `8310-5` + +### Requirement: Implicit resource type inclusion from \_typeFilter + +When `_type` is not provided but `_typeFilter` is, the server SHALL include the resource types referenced in the `_typeFilter` values as the effective resource type filter. + +#### Scenario: \_typeFilter without \_type implies resource types + +- **WHEN** an export is requested with `_typeFilter=Observation?code=8867-4&_typeFilter=Condition?code=73211009` and no `_type` parameter +- **THEN** the export SHALL include Observation and Condition resources (filtered by their respective criteria) and SHALL NOT include other resource types + +### Requirement: \_typeFilter consistency with \_type parameter + +When both `_type` and `_typeFilter` are provided, `_typeFilter` values that reference resource types not listed in `_type` SHALL be handled according to the strict/lenient mode. + +#### Scenario: \_typeFilter type not in \_type in strict mode + +- **WHEN** an export is requested with `_type=Patient&_typeFilter=Observation?code=8867-4` without lenient handling +- **THEN** the server SHALL return 400 Bad Request because `Observation` is not included in `_type` + +#### Scenario: \_typeFilter type not in \_type in lenient mode + +- **WHEN** an export is requested with `_type=Patient&_typeFilter=Observation?code=8867-4` with `Prefer: handling=lenient` +- **THEN** the server SHALL ignore the `Observation` type filter, export only Patient resources, and include an informational OperationOutcome issue + +#### Scenario: \_typeFilter matches subset of \_type + +- **WHEN** an export is requested with `_type=Patient,Observation&_typeFilter=Observation?code=8867-4` +- **THEN** the export SHALL include all Patient resources (unfiltered) and only Observation resources matching code `8867-4` + +### Requirement: \_typeFilter values with resource types not in Patient compartment are handled during patient and group exports + +For patient-level and group-level exports, `_typeFilter` values that reference resource types not in the Patient compartment SHALL be handled consistently with existing `_type` behaviour (silently ignored). + +#### Scenario: Non-compartment resource type in patient export \_typeFilter + +- **WHEN** a patient export is requested with `_typeFilter=Organization?name=test` +- **THEN** the server SHALL silently ignore the `Organization` type filter since Organization is not in the Patient compartment diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportExecutor.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportExecutor.java index 0c1e0b0620..91a92f27f7 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportExecutor.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportExecutor.java @@ -134,6 +134,7 @@ public ExportResponse execute( mapped = applyResourceTypeFiltering(exportRequest, mapped); mapped = applySinceDateFilter(exportRequest, mapped); mapped = applyUntilDateFilter(exportRequest, mapped); + mapped = applyTypeFilters(exportRequest, mapped); // Apply patient compartment filtering for patient-level and group-level exports. if (exportRequest.exportLevel() != ExportLevel.SYSTEM) { @@ -369,6 +370,43 @@ private static QueryableDataSource applyUntilDateFilter( return mapped; } + /** + * Applies _typeFilter search-based filtering to the exported resources. For each resource type + * that has filters, generates a Spark Column from each search query string and combines them with + * OR logic. Resource types without filters are passed through unmodified. + * + * @param exportRequest the export request containing the type filters + * @param dataSource the data source to filter + * @return the filtered data source + */ + @Nonnull + private QueryableDataSource applyTypeFilters( + @Nonnull final ExportRequest exportRequest, @Nonnull final QueryableDataSource dataSource) { + if (exportRequest.typeFilters().isEmpty()) { + return dataSource; + } + + return dataSource.map( + (resourceType, rowDataset) -> { + final java.util.List filters = exportRequest.typeFilters().get(resourceType); + if (filters == null || filters.isEmpty()) { + return rowDataset; + } + + // Convert each search query string to a Spark Column and combine with OR. + Column combined = null; + for (final String searchQuery : filters) { + final Column filterColumn = pathlingContext.searchToColumn(resourceType, searchQuery); + combined = combined == null ? filterColumn : combined.or(filterColumn); + } + log.debug( + "Applying _typeFilter for resource type {} with {} filter(s)", + resourceType, + filters.size()); + return rowDataset.filter(combined); + }); + } + @Nonnull private static QueryableDataSource applySinceDateFilter( @Nonnull final ExportRequest exportRequest, @Nonnull final QueryableDataSource mapped) { diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidator.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidator.java index cdaa1396f3..7410b672e4 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidator.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidator.java @@ -38,6 +38,7 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -64,7 +65,7 @@ public class ExportOperationValidator { /** Query parameters that are not supported by the export operation. */ public static final Set UNSUPPORTED_QUERY_PARAMS = - Set.of("includeAssociatedData", "_typeFilter", "organizeOutputBy"); + Set.of("includeAssociatedData", "organizeOutputBy"); @Nonnull private final FhirContext fhirContext; @@ -88,6 +89,27 @@ public ExportOperationValidator( this.patientCompartmentService = patientCompartmentService; } + /** + * Validates a system-level export request (backwards-compatible overload without _typeFilter). + * + * @param requestDetails the request details + * @param outputFormat the output format parameter + * @param since the since parameter + * @param until the until parameter + * @param type the type parameter + * @param elements the elements parameter + * @return the pre-async validation result + */ + public PreAsyncValidation.PreAsyncValidationResult validateRequest( + @Nonnull final RequestDetails requestDetails, + @Nullable final String outputFormat, + @Nullable final InstantType since, + @Nullable final InstantType until, + @Nullable final List type, + @Nullable final List elements) { + return validateRequest(requestDetails, outputFormat, since, until, type, null, elements); + } + /** * Validates a system-level export request. * @@ -96,6 +118,7 @@ public ExportOperationValidator( * @param since the since parameter * @param until the until parameter * @param type the type parameter + * @param typeFilter the type filter parameter * @param elements the elements parameter * @return the pre-async validation result */ @@ -105,14 +128,30 @@ public PreAsyncValidation.PreAsyncValidationResult validateReques @Nullable final InstantType since, @Nullable final InstantType until, @Nullable final List type, + @Nullable final List typeFilter, @Nullable final List elements) { final boolean lenient = requestDetails.getHeaders(FhirServer.PREFER_LENIENT_HEADER.headerName()).stream() .anyMatch(FhirServer.PREFER_LENIENT_HEADER::validValue); final List typeList = type != null ? type : new ArrayList<>(); + final List typeFilterList = typeFilter != null ? typeFilter : new ArrayList<>(); final List elementsList = elements != null ? elements : new ArrayList<>(); + // Parse and validate _typeFilter values. + final List typeFilterIssues = + new ArrayList<>(); + final Map> parsedTypeFilters = + parseTypeFilters(typeFilterList, typeList, lenient, typeFilterIssues, false); + + // If _type is absent but _typeFilter is present, implicitly include referenced types. + final List effectiveTypeList; + if (typeList.isEmpty() && !parsedTypeFilters.isEmpty()) { + effectiveTypeList = new ArrayList<>(parsedTypeFilters.keySet()); + } else { + effectiveTypeList = typeList; + } + final ExportRequest exportRequest = createExportRequest( requestDetails.getCompleteUrl(), @@ -121,18 +160,47 @@ public PreAsyncValidation.PreAsyncValidationResult validateReques outputFormat, since, until, - typeList, + effectiveTypeList, + parsedTypeFilters, elementsList); final List issues = Stream.of( OperationValidation.validateAcceptHeader(requestDetails, lenient), OperationValidation.validatePreferHeader(requestDetails, lenient), - validateUnsupportedQueryParams(requestDetails, lenient)) + validateUnsupportedQueryParams(requestDetails, lenient), + typeFilterIssues) .flatMap(Collection::stream) .toList(); return new PreAsyncValidation.PreAsyncValidationResult<>(exportRequest, issues); } + /** + * Validates a patient-level or group-level export request (backwards-compatible overload without + * _typeFilter). + * + * @param requestDetails the request details + * @param exportLevel the export level (PATIENT_TYPE, PATIENT_INSTANCE, or GROUP) + * @param patientIds the patient IDs to export (empty for all patients) + * @param outputFormat the output format parameter + * @param since the since parameter + * @param until the until parameter + * @param type the type parameter + * @param elements the elements parameter + * @return the pre-async validation result + */ + public PreAsyncValidation.PreAsyncValidationResult validatePatientExportRequest( + @Nonnull final RequestDetails requestDetails, + @Nonnull final ExportLevel exportLevel, + @Nonnull final Set patientIds, + @Nullable final String outputFormat, + @Nullable final InstantType since, + @Nullable final InstantType until, + @Nullable final List type, + @Nullable final List elements) { + return validatePatientExportRequest( + requestDetails, exportLevel, patientIds, outputFormat, since, until, type, null, elements); + } + /** * Validates a patient-level or group-level export request. * @@ -143,6 +211,7 @@ public PreAsyncValidation.PreAsyncValidationResult validateReques * @param since the since parameter * @param until the until parameter * @param type the type parameter + * @param typeFilter the type filter parameter * @param elements the elements parameter * @return the pre-async validation result */ @@ -154,14 +223,30 @@ public PreAsyncValidation.PreAsyncValidationResult validatePatien @Nullable final InstantType since, @Nullable final InstantType until, @Nullable final List type, + @Nullable final List typeFilter, @Nullable final List elements) { final boolean lenient = requestDetails.getHeaders(FhirServer.PREFER_LENIENT_HEADER.headerName()).stream() .anyMatch(FhirServer.PREFER_LENIENT_HEADER::validValue); final List typeList = type != null ? type : new ArrayList<>(); + final List typeFilterList = typeFilter != null ? typeFilter : new ArrayList<>(); final List elementsList = elements != null ? elements : new ArrayList<>(); + // Parse and validate _typeFilter values, filtering non-compartment types for patient exports. + final List typeFilterIssues = + new ArrayList<>(); + final Map> parsedTypeFilters = + parseTypeFilters(typeFilterList, typeList, lenient, typeFilterIssues, true); + + // If _type is absent but _typeFilter is present, implicitly include referenced types. + final List effectiveTypeList; + if (typeList.isEmpty() && !parsedTypeFilters.isEmpty()) { + effectiveTypeList = new ArrayList<>(parsedTypeFilters.keySet()); + } else { + effectiveTypeList = typeList; + } + final ExportRequest exportRequest = createPatientExportRequest( requestDetails.getCompleteUrl(), @@ -170,7 +255,8 @@ public PreAsyncValidation.PreAsyncValidationResult validatePatien outputFormat, since, until, - typeList, + effectiveTypeList, + parsedTypeFilters, elementsList, exportLevel, patientIds); @@ -179,7 +265,8 @@ public PreAsyncValidation.PreAsyncValidationResult validatePatien Stream.of( OperationValidation.validateAcceptHeader(requestDetails, lenient), OperationValidation.validatePreferHeader(requestDetails, lenient), - validateUnsupportedQueryParams(requestDetails, lenient)) + validateUnsupportedQueryParams(requestDetails, lenient), + typeFilterIssues) .flatMap(Collection::stream) .toList(); return new PreAsyncValidation.PreAsyncValidationResult<>(exportRequest, issues); @@ -221,6 +308,106 @@ private List validateUnsupporte } } + /** + * Parses and validates _typeFilter values, returning a map keyed by resource type code. + * + * @param typeFilterList the raw _typeFilter parameter values + * @param typeList the _type parameter values (for consistency checking) + * @param lenient whether lenient handling is enabled + * @param issues mutable list to collect informational issues for lenient mode + * @param filterNonCompartment whether to silently filter out non-compartment resource types + * @return a map of resource type code to list of search query strings + */ + @Nonnull + private Map> parseTypeFilters( + @Nonnull final List typeFilterList, + @Nonnull final List typeList, + final boolean lenient, + @Nonnull final List issues, + final boolean filterNonCompartment) { + if (typeFilterList.isEmpty()) { + return Map.of(); + } + + // Resolve the effective _type set for consistency checking. + final Set typeSet = + typeList.stream() + .map(String::strip) + .filter(Predicate.not(String::isEmpty)) + .flatMap(string -> Arrays.stream(string.split(","))) + .collect(Collectors.toSet()); + + final Map> result = new java.util.LinkedHashMap<>(); + + for (final String filterValue : typeFilterList) { + // Split on the first '?' to separate resource type from search query. + final int questionMarkIndex = filterValue.indexOf('?'); + if (questionMarkIndex < 0) { + throw new InvalidRequestException( + "_typeFilter value '%s' has invalid format. Expected format: [ResourceType]?[search-params]" + .formatted(filterValue)); + } + + final String resourceTypeCode = filterValue.substring(0, questionMarkIndex); + final String searchQuery = filterValue.substring(questionMarkIndex + 1); + + if (searchQuery.isEmpty()) { + throw new InvalidRequestException( + "_typeFilter value '%s' has an empty search query.".formatted(filterValue)); + } + + // Validate the resource type. + final Optional validatedType = mapTypeQueryParam(resourceTypeCode, lenient); + if (validatedType.isEmpty()) { + // In lenient mode, mapTypeQueryParam returns empty and logs. Add an informational issue. + issues.add( + new OperationOutcome.OperationOutcomeIssueComponent() + .setCode(OperationOutcome.IssueType.INFORMATIONAL) + .setSeverity(OperationOutcome.IssueSeverity.INFORMATION) + .setDetails( + new CodeableConcept() + .setText( + "_typeFilter references unknown resource type '%s'. Ignoring because lenient handling is enabled." + .formatted(resourceTypeCode)))); + continue; + } + + final String resolvedType = validatedType.get(); + + // For patient/group exports, filter out non-compartment resource types silently. + if (filterNonCompartment && !patientCompartmentService.isInPatientCompartment(resolvedType)) { + log.info( + "_typeFilter resource type '{}' is not in the Patient compartment. Ignoring.", + resolvedType); + continue; + } + + // Check consistency with _type parameter if _type was provided. + if (!typeSet.isEmpty() && !typeSet.contains(resolvedType)) { + if (!lenient) { + throw new InvalidRequestException( + "_typeFilter references resource type '%s' which is not included in _type parameter." + .formatted(resolvedType)); + } else { + issues.add( + new OperationOutcome.OperationOutcomeIssueComponent() + .setCode(OperationOutcome.IssueType.INFORMATIONAL) + .setSeverity(OperationOutcome.IssueSeverity.INFORMATION) + .setDetails( + new CodeableConcept() + .setText( + "_typeFilter references resource type '%s' which is not included in _type. Ignoring because lenient handling is enabled." + .formatted(resolvedType)))); + continue; + } + } + + result.computeIfAbsent(resolvedType, k -> new ArrayList<>()).add(searchQuery); + } + + return result; + } + /** * Creates an ExportRequest for system-level exports. * @@ -231,6 +418,7 @@ private List validateUnsupporte * @param since the since parameter * @param until the until parameter * @param type the type parameter + * @param typeFilters the parsed type filters * @param elements the elements parameter * @return the export request */ @@ -242,6 +430,7 @@ public ExportRequest createExportRequest( @Nullable final InstantType since, @Nullable final InstantType until, @Nonnull final List type, + @Nonnull final Map> typeFilters, @Nonnull final List elements) { if (outputFormat == null) { log.debug("No _outputFormat specified, defaulting to ndjson."); @@ -280,6 +469,7 @@ public ExportRequest createExportRequest( since, until, resourceFilter, + typeFilters, fhirElements, lenient, ExportLevel.SYSTEM, @@ -296,6 +486,7 @@ public ExportRequest createExportRequest( * @param since the since parameter * @param until the until parameter * @param type the type parameter + * @param typeFilters the parsed type filters * @param elements the elements parameter * @param exportLevel the export level * @param patientIds the patient IDs to export @@ -309,6 +500,7 @@ public ExportRequest createPatientExportRequest( @Nullable final InstantType since, @Nullable final InstantType until, @Nonnull final List type, + @Nonnull final Map> typeFilters, @Nonnull final List elements, @Nonnull final ExportLevel exportLevel, @Nonnull final Set patientIds) { @@ -353,6 +545,7 @@ public ExportRequest createPatientExportRequest( since, until, resourceFilter, + typeFilters, fhirElements, lenient, exportLevel, diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportRequest.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportRequest.java index bd1db06b84..67f0aa8b2f 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportRequest.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/ExportRequest.java @@ -20,6 +20,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.List; +import java.util.Map; import java.util.Set; import org.hl7.fhir.r4.model.InstantType; @@ -37,6 +38,9 @@ * @param includeResourceTypeFilters When provided, resources will be included in the response if * their resource type is listed here. Uses String resource type codes to support both standard * FHIR resource types and custom types like ViewDefinition. + * @param typeFilters Search-based filters for exported resources, keyed by resource type code. Each + * value is a list of FHIR search query strings. Multiple filters for the same resource type are + * combined with OR logic. * @param elements When provided, the listed FHIR resource elements will be the only ones returned * in the resources (alongside mandatory elements). * @param lenient Lenient handling enabled. @@ -52,6 +56,7 @@ public record ExportRequest( @Nullable InstantType since, @Nullable InstantType until, @Nonnull List includeResourceTypeFilters, + @Nonnull Map> typeFilters, @Nonnull List elements, boolean lenient, @Nonnull ExportLevel exportLevel, diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/GroupExportProvider.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/GroupExportProvider.java index f3fb81d27d..09b6b76af7 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/GroupExportProvider.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/GroupExportProvider.java @@ -20,6 +20,7 @@ import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.ELEMENTS_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.OUTPUT_FORMAT_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.SINCE_PARAM_NAME; +import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.TYPE_FILTER_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.TYPE_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.UNTIL_PARAM_NAME; @@ -97,6 +98,7 @@ public GroupExportProvider( * @param since the since date parameter * @param until the until date parameter * @param type the type parameter + * @param typeFilter the type filter parameter * @param elements the elements parameter * @param requestDetails the request details * @return the binary result, or null if the job was cancelled @@ -112,6 +114,7 @@ public Parameters exportGroup( @Nullable @OperationParam(name = SINCE_PARAM_NAME) final InstantType since, @Nullable @OperationParam(name = UNTIL_PARAM_NAME) final InstantType until, @Nullable @OperationParam(name = TYPE_PARAM_NAME) final List type, + @Nullable @OperationParam(name = TYPE_FILTER_PARAM_NAME) final List typeFilter, @Nullable @OperationParam(name = ELEMENTS_PARAM_NAME) final List elements, @Nonnull final ServletRequestDetails requestDetails) { return exportOperationHelper.executeExport(requestDetails); @@ -122,13 +125,14 @@ public Parameters exportGroup( @Nonnull public PreAsyncValidationResult preAsyncValidate( @Nonnull final ServletRequestDetails servletRequestDetails, @Nonnull final Object[] args) { - // args = [groupId, outputFormat, since, until, type, elements, requestDetails] + // args = [groupId, outputFormat, since, until, type, typeFilter, elements, requestDetails] final IdType groupId = (IdType) args[0]; final String outputFormat = (String) args[1]; final InstantType since = (InstantType) args[2]; final InstantType until = (InstantType) args[3]; final List type = (List) args[4]; - final List elements = (List) args[5]; + final List typeFilter = (List) args[5]; + final List elements = (List) args[6]; // Extract patient IDs from the group during validation. final Set patientIds = @@ -142,6 +146,7 @@ public PreAsyncValidationResult preAsyncValidate( since, until, type, + typeFilter, elements); } } diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/PatientExportProvider.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/PatientExportProvider.java index b6a2f53955..9d22f2dce3 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/PatientExportProvider.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/PatientExportProvider.java @@ -20,6 +20,7 @@ import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.ELEMENTS_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.OUTPUT_FORMAT_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.SINCE_PARAM_NAME; +import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.TYPE_FILTER_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.TYPE_PARAM_NAME; import static au.csiro.pathling.operations.bulkexport.SystemExportProvider.UNTIL_PARAM_NAME; @@ -82,6 +83,7 @@ public PatientExportProvider( * @param since the since date parameter * @param until the until date parameter * @param type the type parameter + * @param typeFilter the type filter parameter * @param elements the elements parameter * @param requestDetails the request details * @return the binary result, or null if the job was cancelled @@ -96,6 +98,7 @@ public Parameters exportAllPatients( @Nullable @OperationParam(name = SINCE_PARAM_NAME) final InstantType since, @Nullable @OperationParam(name = UNTIL_PARAM_NAME) final InstantType until, @Nullable @OperationParam(name = TYPE_PARAM_NAME) final List type, + @Nullable @OperationParam(name = TYPE_FILTER_PARAM_NAME) final List typeFilter, @Nullable @OperationParam(name = ELEMENTS_PARAM_NAME) final List elements, @Nonnull final ServletRequestDetails requestDetails) { return exportOperationHelper.executeExport(requestDetails); @@ -110,6 +113,7 @@ public Parameters exportAllPatients( * @param since the since date parameter * @param until the until date parameter * @param type the type parameter + * @param typeFilter the type filter parameter * @param elements the elements parameter * @param requestDetails the request details * @return the binary result, or null if the job was cancelled @@ -125,6 +129,7 @@ public Parameters exportSinglePatient( @Nullable @OperationParam(name = SINCE_PARAM_NAME) final InstantType since, @Nullable @OperationParam(name = UNTIL_PARAM_NAME) final InstantType until, @Nullable @OperationParam(name = TYPE_PARAM_NAME) final List type, + @Nullable @OperationParam(name = TYPE_FILTER_PARAM_NAME) final List typeFilter, @Nullable @OperationParam(name = ELEMENTS_PARAM_NAME) final List elements, @Nonnull final ServletRequestDetails requestDetails) { return exportOperationHelper.executeExport(requestDetails); @@ -136,8 +141,8 @@ public Parameters exportSinglePatient( public PreAsyncValidationResult preAsyncValidate( @Nonnull final ServletRequestDetails servletRequestDetails, @Nonnull final Object[] args) { // Determine if this is a type-level or instance-level operation based on args. - // Type-level: args = [outputFormat, since, until, type, elements, requestDetails] - // Instance-level: args = [patientId, outputFormat, since, until, type, elements, + // Type-level: args = [outputFormat, since, until, type, typeFilter, elements, requestDetails] + // Instance-level: args = [patientId, outputFormat, since, until, type, typeFilter, elements, // requestDetails] final boolean isInstanceLevel = args.length > 0 && args[0] instanceof IdType; @@ -147,6 +152,7 @@ public PreAsyncValidationResult preAsyncValidate( final InstantType since; final InstantType until; final List type; + final List typeFilter; final List elements; if (isInstanceLevel) { @@ -157,7 +163,8 @@ public PreAsyncValidationResult preAsyncValidate( since = (InstantType) args[2]; until = (InstantType) args[3]; type = (List) args[4]; - elements = (List) args[5]; + typeFilter = (List) args[5]; + elements = (List) args[6]; } else { exportLevel = ExportLevel.PATIENT_TYPE; patientIds = Set.of(); @@ -165,10 +172,19 @@ public PreAsyncValidationResult preAsyncValidate( since = (InstantType) args[1]; until = (InstantType) args[2]; type = (List) args[3]; - elements = (List) args[4]; + typeFilter = (List) args[4]; + elements = (List) args[5]; } return exportOperationValidator.validatePatientExportRequest( - servletRequestDetails, exportLevel, patientIds, outputFormat, since, until, type, elements); + servletRequestDetails, + exportLevel, + patientIds, + outputFormat, + since, + until, + type, + typeFilter, + elements); } } diff --git a/server/src/main/java/au/csiro/pathling/operations/bulkexport/SystemExportProvider.java b/server/src/main/java/au/csiro/pathling/operations/bulkexport/SystemExportProvider.java index d13735dbc9..850024de42 100644 --- a/server/src/main/java/au/csiro/pathling/operations/bulkexport/SystemExportProvider.java +++ b/server/src/main/java/au/csiro/pathling/operations/bulkexport/SystemExportProvider.java @@ -51,6 +51,9 @@ public class SystemExportProvider implements PreAsyncValidation { /** The name of the type parameter. */ public static final String TYPE_PARAM_NAME = "_type"; + /** The name of the type filter parameter. */ + public static final String TYPE_FILTER_PARAM_NAME = "_typeFilter"; + /** The name of the elements parameter. */ public static final String ELEMENTS_PARAM_NAME = "_elements"; @@ -79,6 +82,7 @@ public SystemExportProvider( * @param since the since date parameter (validated in pre-async validation) * @param until the until date parameter (validated in pre-async validation) * @param type the type parameter (validated in pre-async validation) + * @param typeFilter the type filter parameter (validated in pre-async validation) * @param elements the elements parameter (validated in pre-async validation) * @param requestDetails the request details * @return the binary result, or null if the job was cancelled @@ -93,6 +97,7 @@ public Parameters export( @Nullable @OperationParam(name = SINCE_PARAM_NAME) final InstantType since, @Nullable @OperationParam(name = UNTIL_PARAM_NAME) final InstantType until, @Nullable @OperationParam(name = TYPE_PARAM_NAME) final List type, + @Nullable @OperationParam(name = TYPE_FILTER_PARAM_NAME) final List typeFilter, @Nullable @OperationParam(name = ELEMENTS_PARAM_NAME) final List elements, @Nonnull final ServletRequestDetails requestDetails) { return exportOperationHelper.executeExport(requestDetails); @@ -109,6 +114,7 @@ public PreAsyncValidationResult preAsyncValidate( (InstantType) args[1], (InstantType) args[2], (List) args[3], - (List) args[4]); + (List) args[4], + (List) args[5]); } } diff --git a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationExecutorTest.java b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationExecutorTest.java index 75dd0289f2..a40863e11f 100644 --- a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationExecutorTest.java +++ b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationExecutorTest.java @@ -50,8 +50,10 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -557,6 +559,7 @@ void testExportWithParquetFormatWritesParquetFiles() throws IOException { null, null, List.of(), + Map.of(), List.of(), false, ExportRequest.ExportLevel.SYSTEM, @@ -580,6 +583,147 @@ void testExportWithParquetFormatWritesParquetFiles() throws IOException { assertThat(parquetFiles.length).isGreaterThan(0); } + // Tests for _typeFilter. + + @Test + void testTypeFilterAppliesSingleFilter() throws IOException { + // A single _typeFilter should filter the exported resources to only those matching the search + // criteria. + final Patient activePatient = new Patient(); + activePatient.setId("active-1"); + activePatient.setActive(true); + final Patient inactivePatient = new Patient(); + inactivePatient.setId("inactive-1"); + inactivePatient.setActive(false); + + exportExecutor = create_exec(activePatient, inactivePatient); + + final ExportRequest req = + new ExportRequest( + BASE + "_type=Patient&_typeFilter=Patient?active=true", + "http://localhost:8080/fhir", + ExportOutputFormat.NDJSON, + null, + null, + List.of("Patient"), + Map.of("Patient", List.of("active=true")), + List.of(), + false, + ExportRequest.ExportLevel.SYSTEM, + Set.of()); + + final TestExportResponse response = execute(req); + assertThat(response.exportResponse().getWriteDetails().fileInfos()).hasSize(1); + final Patient result = + readNdjson( + parser, + response.exportResponse().getWriteDetails().fileInfos().getFirst(), + Patient.class); + assertThat(result.getActive()).isTrue(); + } + + @Test + void testTypeFilterCombinesMultipleFiltersWithOrLogic() throws IOException { + // Multiple _typeFilter values for the same resource type should be combined with OR logic. + final Patient activePatient = new Patient(); + activePatient.setId("active-1"); + activePatient.setActive(true); + activePatient.setGender(org.hl7.fhir.r4.model.Enumerations.AdministrativeGender.MALE); + final Patient inactivePatient = new Patient(); + inactivePatient.setId("inactive-1"); + inactivePatient.setActive(false); + inactivePatient.setGender(org.hl7.fhir.r4.model.Enumerations.AdministrativeGender.FEMALE); + + exportExecutor = create_exec(activePatient, inactivePatient); + + // Two filters for Patient: active=true OR gender=female. Both patients should match. + final ExportRequest req = + new ExportRequest( + BASE + + "_type=Patient&_typeFilter=Patient?active=true&_typeFilter=Patient?gender=female", + "http://localhost:8080/fhir", + ExportOutputFormat.NDJSON, + null, + null, + List.of("Patient"), + Map.of("Patient", List.of("active=true", "gender=female")), + List.of(), + false, + ExportRequest.ExportLevel.SYSTEM, + Set.of()); + + final TestExportResponse response = execute(req); + // All file infos should be for Patient. + assertThat(response.exportResponse().getWriteDetails().fileInfos()) + .allMatch(fi -> fi.fhirResourceType().equals("Patient")); + // Read all NDJSON files and count total resources across all partitions. + long totalLines = 0; + for (final var fi : response.exportResponse().getWriteDetails().fileInfos()) { + final String content = Files.readString(Paths.get(URI.create(fi.absoluteUrl()))); + totalLines += content.lines().filter(line -> !line.isBlank()).count(); + } + assertThat(totalLines).isEqualTo(2); + } + + @Test + void testTypeFilterDoesNotAffectResourceTypesWithoutFilter() throws IOException { + // Resource types that are exported but have no _typeFilter should not be filtered. + final Patient patient = new Patient(); + patient.setId("patient-1"); + patient.setActive(true); + final Observation observation = new Observation(); + observation.setId("obs-1"); + observation.setStatus(Observation.ObservationStatus.FINAL); + + exportExecutor = create_exec(patient, observation); + + // Only filter Patient resources, not Observation. + final ExportRequest req = + new ExportRequest( + BASE + "_type=Patient,Observation&_typeFilter=Patient?active=true", + "http://localhost:8080/fhir", + ExportOutputFormat.NDJSON, + null, + null, + List.of("Patient", "Observation"), + Map.of("Patient", List.of("active=true")), + List.of(), + false, + ExportRequest.ExportLevel.SYSTEM, + Set.of()); + + final TestExportResponse response = execute(req); + // Both resource types should be present in the output. + assertThat(response.exportResponse().getWriteDetails().fileInfos()).hasSize(2); + } + + @Test + void testTypeFilterWithEmptyMapDoesNotAffectExport() { + // An empty typeFilters map should not affect the export (no filtering applied). + final Patient patient = new Patient(); + patient.setId("patient-1"); + patient.setActive(true); + + exportExecutor = createExecutor(patient); + + final ExportRequest req = + new ExportRequest( + BASE, + "http://localhost:8080/fhir", + ExportOutputFormat.NDJSON, + null, + null, + List.of(), + Map.of(), + List.of(), + false, + ExportRequest.ExportLevel.SYSTEM, + Set.of()); + + final TestExportResponse response = execute(req); + assertThat(response.exportResponse().getWriteDetails().fileInfos()).hasSize(1); + } + @Test void testExportWithParquetFormatHasCorrectOutputFormat() { final Patient patient = new Patient(); @@ -596,6 +740,7 @@ void testExportWithParquetFormatHasCorrectOutputFormat() { null, null, List.of("Patient"), + Map.of(), List.of(), false, ExportRequest.ExportLevel.SYSTEM, diff --git a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationIT.java b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationIT.java index 62892917ae..17d7444c1d 100644 --- a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationIT.java +++ b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationIT.java @@ -436,4 +436,213 @@ private boolean getParameterBooleanValue(final JsonNode parameters, final String } return false; } + + // _typeFilter integration tests. + + /** + * Kicks off an export request using the URI builder to properly handle URL encoding of + * _typeFilter values (which contain '?' and '=' characters). + */ + private String kickOffExportWithTypeFilter( + final String basePath, final String typeFilter, @Nullable final String type) { + final String pollUrl = + webTestClient + .get() + .uri( + uriBuilder -> { + uriBuilder + .path(basePath) + .queryParam("_outputFormat", "application/fhir+ndjson") + .queryParam("_since", "2017-01-01T00:00:00Z") + .queryParam("_typeFilter", typeFilter); + if (type != null) { + uriBuilder.queryParam("_type", type); + } + return uriBuilder.build(); + }) + .header("Accept", "application/fhir+json") + .header("Prefer", "respond-async") + .exchange() + .expectStatus() + .is2xxSuccessful() + .expectHeader() + .exists("Content-Location") + .returnResult(String.class) + .getResponseHeaders() + .getFirst("Content-Location"); + assertNotNull(pollUrl); + return pollUrl; + } + + @Test + void testExportWithTypeFilterOnSystemLevel() { + // A system-level export with _typeFilter should only return resources matching the search + // criteria. Using gender=male to filter Patient resources. + final String pollUrl = + kickOffExportWithTypeFilter("/fhir/$export", "Patient?gender=male", "Patient"); + await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> + doPolling( + webTestClient, + pollUrl, + result -> { + assertNotNull(result.getResponseBody()); + assertTypeFilterResult(result.getResponseBody(), "Patient", "male"); + })); + } + + @Test + void testExportWithTypeFilterOnPatientLevel() { + // A patient-level export with _typeFilter should also filter resources appropriately. + final String pollUrl = + kickOffExportWithTypeFilter("/fhir/Patient/$export", "Patient?gender=male", "Patient"); + await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> + doPolling( + webTestClient, + pollUrl, + result -> { + assertNotNull(result.getResponseBody()); + assertTypeFilterResult(result.getResponseBody(), "Patient", "male"); + })); + } + + @Test + void testExportWithTypeFilterInvalidFormatReturnsError() { + // A _typeFilter without a '?' separator should be rejected in strict mode. + webTestClient + .get() + .uri( + uriBuilder -> + uriBuilder + .path("/fhir/$export") + .queryParam("_outputFormat", "application/fhir+ndjson") + .queryParam("_since", "2017-01-01T00:00:00Z") + .queryParam("_typeFilter", "InvalidFormat") + .build()) + .header("Accept", "application/fhir+json") + .header("Prefer", "respond-async") + .exchange() + .expectStatus() + .isBadRequest(); + } + + @Test + void testExportWithTypeFilterInvalidFormatLenientAlsoFails() { + // Format validation (missing '?') is always strict, even in lenient mode. + webTestClient + .get() + .uri( + uriBuilder -> + uriBuilder + .path("/fhir/$export") + .queryParam("_outputFormat", "application/fhir+ndjson") + .queryParam("_since", "2017-01-01T00:00:00Z") + .queryParam("_typeFilter", "InvalidFormat") + .build()) + .header("Accept", "application/fhir+json") + .header("Prefer", "respond-async, handling=lenient") + .exchange() + .expectStatus() + .isBadRequest(); + } + + @Test + void testExportWithTypeFilterImplicitTypeInclusion() { + // When _type is absent but _typeFilter is present, resource types should be implicitly included + // from the _typeFilter values. + final String pollUrl = + kickOffExportWithTypeFilter("/fhir/$export", "Patient?gender=male", null); + await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .until( + () -> + doPolling( + webTestClient, + pollUrl, + result -> { + assertNotNull(result.getResponseBody()); + // Only Patient should be in the output since _typeFilter implicitly sets + // the type. + assertTypeFilterResult(result.getResponseBody(), "Patient", "male"); + })); + } + + /** + * Asserts that a completed export response contains only the specified resource type and that all + * Patient resources have the expected gender. + */ + private void assertTypeFilterResult( + final String responseBody, final String expectedType, final String expectedGender) { + try { + final ObjectMapper objectMapper = new ObjectMapper(); + final JsonNode node = objectMapper.readTree(responseBody); + + assertThat(node.get("resourceType").asText()).isEqualTo("Parameters"); + final JsonNode parameters = node.get("parameter"); + + // Extract output file information. + final List fileInfos = + StreamSupport.stream(parameters.spliterator(), false) + .filter(param -> "output".equals(param.get("name").asText())) + .map( + outputParam -> { + final JsonNode parts = outputParam.get("part"); + String type = null; + String url = null; + for (final JsonNode part : parts) { + final String partName = part.get("name").asText(); + if ("type".equals(partName)) { + type = + part.has("valueCode") + ? part.get("valueCode").asText() + : part.get("valueString").asText(); + } else if ("url".equals(partName)) { + url = part.get("valueUri").asText(); + } + } + assertNotNull(type); + assertNotNull(url); + return new FileInformation(type, url); + }) + .toList(); + + assertThat(fileInfos).isNotEmpty(); + // All output should be for the expected resource type. + assertThat(fileInfos).allMatch(fi -> fi.fhirResourceType().equals(expectedType)); + + // Download and verify the content. + for (final FileInformation fileInfo : fileInfos) { + final EntityExchangeResult fileResult = + webTestClient + .get() + .uri(fileInfo.absoluteUrl()) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .returnResult(); + final byte[] bytes = fileResult.getResponseBodyContent(); + assertThat(bytes).isNotNull(); + final String content = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + final List resources = + ExportOperationUtil.parseNdjson(parser, content, expectedType); + assertThat(resources).isNotEmpty(); + // Verify all patients have the expected gender. + for (final Resource resource : resources) { + assertThat(resource).isInstanceOf(Patient.class); + assertThat(((Patient) resource).getGender().toCode()).isEqualTo(expectedGender); + } + } + } catch (final IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationTest.java b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationTest.java index 31190ca0a9..993602196e 100644 --- a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationTest.java +++ b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationTest.java @@ -345,6 +345,8 @@ private static Stream provideParameters() { arguments(NDJSON, now, List.of("Patient"), Map.of(), false, List.of(), true), arguments(NDJSON, now, List.of("Patient123"), Map.of(), false, List.of(), false), arguments(NDJSON, now, List.of("Patient123"), Map.of(), true, List.of(), true), + // _typeFilter is now a supported parameter, so it should not trigger unsupported param + // errors. arguments( NDJSON, now, @@ -352,16 +354,14 @@ private static Stream provideParameters() { queryParameter("_typeFilter", "test"), false, List.of(), - false), + true), arguments( NDJSON, now, List.of(), queryParameter("_typeFilter", "test"), true, - List.of( - "The query parameter '_typeFilter' is not supported. Ignoring because lenient" - + " handling is enabled."), + List.of(), true)); } @@ -443,12 +443,21 @@ void testInputModelMapping( since, until, type, + Map.of(), emptyList)) .isExactlyInstanceOf(InvalidRequestException.class); } else { final ExportRequest expectedRequest = exportOperationValidator.createExportRequest( - originalRequest, serverBaseUrl, false, outputFormat, since, until, type, emptyList); + originalRequest, + serverBaseUrl, + false, + outputFormat, + since, + until, + type, + Map.of(), + emptyList); assertThat(expectedRequest).isEqualTo(expectedMappedRequest); } } diff --git a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidatorTest.java b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidatorTest.java index 87b7ae3f5f..de5abeccfc 100644 --- a/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidatorTest.java +++ b/server/src/test/java/au/csiro/pathling/operations/bulkexport/ExportOperationValidatorTest.java @@ -352,4 +352,215 @@ void validateRequest_shouldAcceptValidResourceTypeAndElement() { assertThat(result.result().elements().get(0).resourceTypeCode()).isEqualTo("Patient"); }); } + + // _typeFilter tests. + + @Test + @DisplayName("validateRequest should parse valid _typeFilter into typeFilters map") + void validateRequest_shouldParseValidTypeFilter() { + // A valid _typeFilter value should be parsed into the typeFilters map on the ExportRequest. + final List typeFilter = List.of("Observation?code=8867-4"); + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().typeFilters()).containsKey("Observation"); + assertThat(result.result().typeFilters().get("Observation")).containsExactly("code=8867-4"); + } + + @Test + @DisplayName("validateRequest should parse multiple _typeFilter values for same resource type") + void validateRequest_shouldParseMultipleTypeFiltersForSameType() { + // Multiple _typeFilter values targeting the same resource type should be grouped together. + final List typeFilter = List.of("Observation?code=8867-4", "Observation?code=8310-5"); + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().typeFilters()).containsKey("Observation"); + assertThat(result.result().typeFilters().get("Observation")) + .containsExactly("code=8867-4", "code=8310-5"); + } + + @Test + @DisplayName("validateRequest should parse _typeFilter values for different resource types") + void validateRequest_shouldParseTypeFiltersForDifferentTypes() { + // _typeFilter values for different resource types should be separated into different keys. + final List typeFilter = List.of("Observation?code=8867-4", "Condition?code=73211009"); + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().typeFilters()).hasSize(2); + assertThat(result.result().typeFilters().get("Observation")).containsExactly("code=8867-4"); + assertThat(result.result().typeFilters().get("Condition")).containsExactly("code=73211009"); + } + + @Test + @DisplayName("validateRequest should reject _typeFilter without question mark separator") + void validateRequest_shouldRejectTypeFilterWithoutQuestionMark() { + // A _typeFilter value must contain a '?' separator. + final List typeFilter = List.of("Observation"); + + assertThatThrownBy( + () -> + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null)) + .isInstanceOf(InvalidRequestException.class) + .hasMessageContaining("_typeFilter") + .hasMessageContaining("format"); + } + + @Test + @DisplayName("validateRequest should reject _typeFilter with empty search query") + void validateRequest_shouldRejectTypeFilterWithEmptySearchQuery() { + // A _typeFilter value must have a non-empty search query after the '?'. + final List typeFilter = List.of("Observation?"); + + assertThatThrownBy( + () -> + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null)) + .isInstanceOf(InvalidRequestException.class) + .hasMessageContaining("_typeFilter") + .hasMessageContaining("empty"); + } + + @Test + @DisplayName( + "validateRequest should reject _typeFilter with invalid resource type in strict mode") + void validateRequest_shouldRejectTypeFilterWithInvalidResourceType() { + // An invalid resource type in _typeFilter should be rejected in strict mode. + final List typeFilter = List.of("FakeResource?status=active"); + + assertThatThrownBy( + () -> + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null)) + .isInstanceOf(InvalidRequestException.class) + .hasMessageContaining("FakeResource"); + } + + @Test + @DisplayName( + "validateRequest should ignore _typeFilter with invalid resource type in lenient mode") + void validateRequest_shouldIgnoreTypeFilterWithInvalidResourceTypeInLenientMode() { + // In lenient mode, an invalid resource type in _typeFilter should be ignored. + when(requestDetails.getHeaders(FhirServer.PREFER_LENIENT_HEADER.headerName())) + .thenReturn(List.of("handling=lenient")); + final List typeFilter = List.of("FakeResource?status=active"); + + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().typeFilters()).isEmpty(); + assertThat(result.warnings()).isNotEmpty(); + } + + @Test + @DisplayName("validateRequest should reject _typeFilter type not in _type in strict mode") + void validateRequest_shouldRejectTypeFilterTypeNotInType() { + // When _type is specified and _typeFilter references a type not in _type, reject in strict + // mode. + final List type = List.of("Patient"); + final List typeFilter = List.of("Observation?code=8867-4"); + + assertThatThrownBy( + () -> + validator.validateRequest(requestDetails, null, null, null, type, typeFilter, null)) + .isInstanceOf(InvalidRequestException.class) + .hasMessageContaining("_typeFilter") + .hasMessageContaining("Observation") + .hasMessageContaining("_type"); + } + + @Test + @DisplayName("validateRequest should ignore _typeFilter type not in _type in lenient mode") + void validateRequest_shouldIgnoreTypeFilterTypeNotInTypeInLenientMode() { + // In lenient mode, _typeFilter entries for types not in _type should be silently ignored. + when(requestDetails.getHeaders(FhirServer.PREFER_LENIENT_HEADER.headerName())) + .thenReturn(List.of("handling=lenient")); + final List type = List.of("Patient"); + final List typeFilter = List.of("Observation?code=8867-4"); + + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, type, typeFilter, null); + + assertThat(result.result().typeFilters()).isEmpty(); + assertThat(result.warnings()).isNotEmpty(); + } + + @Test + @DisplayName("validateRequest should accept _typeFilter that matches subset of _type") + void validateRequest_shouldAcceptTypeFilterMatchingSubsetOfType() { + // When _type includes multiple types and _typeFilter targets a subset, it should be accepted. + final List type = List.of("Patient", "Observation"); + final List typeFilter = List.of("Observation?code=8867-4"); + + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, type, typeFilter, null); + + assertThat(result.result().typeFilters()).containsKey("Observation"); + assertThat(result.result().includeResourceTypeFilters()) + .containsExactlyInAnyOrder("Patient", "Observation"); + } + + @Test + @DisplayName( + "validateRequest should implicitly include resource types from _typeFilter when _type is" + + " absent") + void validateRequest_shouldImplicitlyIncludeTypesFromTypeFilter() { + // When _type is not provided but _typeFilter is, the resource types from _typeFilter should + // become the effective type filter. + final List typeFilter = List.of("Observation?code=8867-4", "Condition?code=73211009"); + + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().includeResourceTypeFilters()) + .containsExactlyInAnyOrder("Observation", "Condition"); + assertThat(result.result().typeFilters()).hasSize(2); + } + + @Test + @DisplayName("validateRequest should accept null _typeFilter parameter") + void validateRequest_shouldAcceptNullTypeFilter() { + // Null _typeFilter should result in an empty typeFilters map. + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, null, null); + + assertThat(result.result().typeFilters()).isEmpty(); + } + + @Test + @DisplayName( + "validateRequest should handle _typeFilter with multiple search criteria (compound query)") + void validateRequest_shouldHandleTypeFilterWithMultipleSearchCriteria() { + // A _typeFilter value can have multiple search criteria separated by '&'. + final List typeFilter = List.of("Observation?code=8867-4&date=ge2024-01-01"); + + final PreAsyncValidationResult result = + validator.validateRequest(requestDetails, null, null, null, null, typeFilter, null); + + assertThat(result.result().typeFilters()).containsKey("Observation"); + assertThat(result.result().typeFilters().get("Observation")) + .containsExactly("code=8867-4&date=ge2024-01-01"); + } + + @Test + @DisplayName( + "validateRequest should handle _typeFilter for patient export with non-compartment type") + void validateRequest_shouldFilterNonCompartmentTypeFilterInPatientExport() { + // For patient-level exports, _typeFilter values referencing non-compartment resource types + // should be silently ignored. + final List typeFilter = List.of("Organization?name=test"); + + final PreAsyncValidationResult result = + validator.validatePatientExportRequest( + requestDetails, + ExportRequest.ExportLevel.PATIENT_TYPE, + java.util.Set.of(), + null, + null, + null, + null, + typeFilter, + null); + + assertThat(result.result().typeFilters()).isEmpty(); + } } diff --git a/server/src/test/java/au/csiro/pathling/security/SecurityTestForOperations.java b/server/src/test/java/au/csiro/pathling/security/SecurityTestForOperations.java index 0c482bc120..b97021c515 100644 --- a/server/src/test/java/au/csiro/pathling/security/SecurityTestForOperations.java +++ b/server/src/test/java/au/csiro/pathling/security/SecurityTestForOperations.java @@ -48,6 +48,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -159,6 +160,7 @@ JsonNode performExport( null, null, type, + Map.of(), List.of(), lenient, ExportLevel.SYSTEM, @@ -172,6 +174,7 @@ JsonNode performExport( exportRequest.until(), type, null, + null, requestDetails); try { // Convert Parameters to JSON using FHIR context. diff --git a/server/src/test/java/au/csiro/pathling/util/ExportOperationUtil.java b/server/src/test/java/au/csiro/pathling/util/ExportOperationUtil.java index 7226bbfe1b..f639487234 100644 --- a/server/src/test/java/au/csiro/pathling/util/ExportOperationUtil.java +++ b/server/src/test/java/au/csiro/pathling/util/ExportOperationUtil.java @@ -41,6 +41,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -126,6 +127,7 @@ public static ExportRequest req( since, null, includeResourceTypeFilters, + Map.of(), List.of(), false, ExportLevel.SYSTEM, @@ -194,6 +196,7 @@ public static ExportRequest req( since, until, List.of(), + Map.of(), fhirElements, false, ExportLevel.SYSTEM, diff --git a/server/src/test/java/au/csiro/pathling/util/ExportRequestBuilder.java b/server/src/test/java/au/csiro/pathling/util/ExportRequestBuilder.java index 0df35b4908..6f62ce3778 100644 --- a/server/src/test/java/au/csiro/pathling/util/ExportRequestBuilder.java +++ b/server/src/test/java/au/csiro/pathling/util/ExportRequestBuilder.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Set; import org.hl7.fhir.r4.model.Enumerations.ResourceType; import org.hl7.fhir.r4.model.InstantType; @@ -174,6 +175,7 @@ public ExportRequest build() { since, until, List.copyOf(includeResourceTypeFilters), + Map.of(), List.copyOf(elements), false, ExportLevel.SYSTEM, diff --git a/site/docs/server/operations/export.md b/site/docs/server/operations/export.md index b9771bfe3c..a466cccf02 100644 --- a/site/docs/server/operations/export.md +++ b/site/docs/server/operations/export.md @@ -22,13 +22,58 @@ Pathling supports export at multiple levels: ## Parameters -| Name | Cardinality | Type | Description | -| --------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `_outputFormat` | 0..1 | string | The format for exported files. Accepts `application/fhir+ndjson`, `application/ndjson`, `ndjson`, `application/vnd.apache.parquet`, or `parquet`. Defaults to `application/fhir+ndjson`. See [Output formats](#output-formats). | -| `_since` | 0..1 | instant | Only include resources where `meta.lastUpdated` is after this time. | -| `_until` | 0..1 | instant | Only include resources where `meta.lastUpdated` is before this time. | -| `_type` | 0..\* | string | Comma-delimited list of resource types to export. If omitted, all supported types are exported. Invalid types cause an error unless the `Prefer: handling=lenient` header is included. | -| `_elements` | 0..\* | string | Comma-delimited list of elements to include. Specify as `[type].[element]` (e.g., `Patient.name`) or `[element]` for all types. Only top-level elements are supported. Mandatory elements are always included. | +| Name | Cardinality | Type | Description | +| --------------- | ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_outputFormat` | 0..1 | string | The format for exported files. Accepts `application/fhir+ndjson`, `application/ndjson`, `ndjson`, `application/vnd.apache.parquet`, or `parquet`. Defaults to `application/fhir+ndjson`. See [Output formats](#output-formats). | +| `_since` | 0..1 | instant | Only include resources where `meta.lastUpdated` is after this time. | +| `_until` | 0..1 | instant | Only include resources where `meta.lastUpdated` is before this time. | +| `_type` | 0..\* | string | Comma-delimited list of resource types to export. If omitted, all supported types are exported. Invalid types cause an error unless the `Prefer: handling=lenient` header is included. | +| `_typeFilter` | 0..\* | string | FHIR search queries to filter exported resources by type. Each value has the format `[ResourceType]?[search-params]` (e.g., `Patient?gender=male`). Multiple filters for the same type are combined with OR logic. See [Type filters](#type-filters). | +| `_elements` | 0..\* | string | Comma-delimited list of elements to include. Specify as `[type].[element]` (e.g., `Patient.name`) or `[element]` for all types. Only top-level elements are supported. Mandatory elements are always included. | + +## Type filters + +The `_typeFilter` parameter allows you to apply FHIR search queries to filter +resources during export. Each filter specifies a resource type and a search query +string, separated by `?`. + +For example, to export only active medication requests and male patients: + +```http +GET [base]/$export?_type=MedicationRequest,Patient&_typeFilter=MedicationRequest?status=active&_typeFilter=Patient?gender=male HTTP/1.1 +Accept: application/fhir+json +Prefer: respond-async +``` + +### Multiple filters per type + +When multiple `_typeFilter` values target the same resource type, they are +combined using OR logic. For example, the following request exports patients who +are either male or born after 2000: + +```http +GET [base]/$export?_type=Patient&_typeFilter=Patient?gender=male&_typeFilter=Patient?birthdate=gt2000-01-01 HTTP/1.1 +``` + +### Implicit type inclusion + +If `_typeFilter` is provided without `_type`, the resource types referenced in +the filters are automatically included in the export. For example, this request +exports only Patient resources: + +```http +GET [base]/$export?_typeFilter=Patient?gender=male HTTP/1.1 +``` + +### Validation + +- Each `_typeFilter` value must contain a `?` separating the resource type from + the search query. +- The resource type must be a valid FHIR resource type supported by the server. +- If `_type` is also specified, the resource types in `_typeFilter` must be a + subset of `_type`. In strict mode, a mismatch causes an error. With + `Prefer: handling=lenient`, mismatched filters are silently ignored. +- Search parameters must be valid for the specified resource type. ## Output formats From 8be0a699d9e52ac197a28f984b62bdc43b3cc5e2 Mon Sep 17 00:00:00 2001 From: John Grimes Date: Fri, 20 Feb 2026 07:51:23 +1000 Subject: [PATCH 02/18] feat: Add type filter UI to export form Extract reusable SearchParamsInput component from the resources page and add a structured type filter section to the export form. Each entry allows selecting a resource type and configuring search parameters from the CapabilityStatement. Type filters are serialised as _typeFilter query parameters in the bulk export API request. --- .../.openspec.yaml | 2 + .../design.md | 114 ++++++ .../proposal.md | 50 +++ .../specs/export-type-filter-ui/spec.md | 166 ++++++++ .../specs/search-parameter-form/spec.md | 89 +++++ .../tasks.md | 43 +++ openspec/specs/export-type-filter-ui/spec.md | 166 ++++++++ openspec/specs/search-parameter-form/spec.md | 143 +++---- ui/src/api/__tests__/bulkExport.test.ts | 96 +++++ ui/src/api/bulkExport.ts | 39 +- ui/src/api/index.ts | 1 + ui/src/api/utils.ts | 12 +- ui/src/components/SearchParamsInput.tsx | 165 ++++++++ .../__tests__/SearchParamsInput.test.tsx | 275 +++++++++++++ ui/src/components/export/ExportCard.tsx | 3 +- ui/src/components/export/ExportForm.tsx | 144 ++++++- ui/src/components/export/ExportOptions.tsx | 38 +- .../__tests__/ExportFormTypeFilters.test.tsx | 360 ++++++++++++++++++ .../export/__tests__/ExportOptions.test.tsx | 165 +------- ui/src/components/import/ImportPnpForm.tsx | 17 - .../import/__tests__/ImportPnpForm.test.tsx | 103 ----- ui/src/components/resources/ResourceCard.tsx | 4 +- .../resources/ResourceSearchForm.tsx | 90 +---- .../__tests__/ResourceSearchForm.test.tsx | 38 +- ui/src/hooks/useBulkExport.ts | 9 +- ui/src/pages/Export.tsx | 16 +- ui/src/types/__tests__/export.test.ts | 61 ++- ui/src/types/export.ts | 39 ++ ui/src/types/exportOptions.ts | 6 - 29 files changed, 1897 insertions(+), 557 deletions(-) create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/design.md create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/proposal.md create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/export-type-filter-ui/spec.md create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/search-parameter-form/spec.md create mode 100644 openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/tasks.md create mode 100644 openspec/specs/export-type-filter-ui/spec.md create mode 100644 ui/src/components/SearchParamsInput.tsx create mode 100644 ui/src/components/__tests__/SearchParamsInput.test.tsx create mode 100644 ui/src/components/export/__tests__/ExportFormTypeFilters.test.tsx diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/.openspec.yaml b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/.openspec.yaml new file mode 100644 index 0000000000..d299748398 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-19 diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/design.md b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/design.md new file mode 100644 index 0000000000..61c59d4832 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/design.md @@ -0,0 +1,114 @@ +## Context + +The export tab currently accepts resource type selection, date filters, elements, +and output format. The `_typeFilter` bulk export parameter allows per-resource-type +search filtering (e.g. `Patient?active=true`), but the UI only has a hidden raw +text field for this. + +The Resources page already has a search parameters component within +`ResourceSearchForm` that renders parameter dropdown + value rows using data +from the CapabilityStatement. This component is currently embedded in the form +and cannot be reused elsewhere. + +## Goals / Non-Goals + +**Goals:** + +- Extract the search parameter rows into a reusable `SearchParamsInput` component + that can be consumed by both the resources page and the export form. +- Add a "Type filters" section to `ExportForm` where users can add type filter + entries, each consisting of a resource type selector paired with a + `SearchParamsInput` for that type's search parameters. +- Wire the type filter data through to the bulk export API as `_typeFilter` query + parameters. + +**Non-Goals:** + +- Server-side `_typeFilter` parsing and filtering (already covered by the + `bulk-export-type-filter` spec and a separate implementation effort). +- Supporting `includeAssociatedData` in the UI. +- Changing the search parameters behaviour on the Resources page (beyond + refactoring the component extraction). + +## Decisions + +### Extract SearchParamsInput as a shared component + +The search parameter rows (dropdown + value + add/remove) will be extracted into +`ui/src/components/SearchParamsInput.tsx`. This component will accept: + +- `availableParams: SearchParamCapability[]` — the parameters to show in the + dropdown. +- `rows: SearchParamRowData[]` — the current row state. +- `onChange: (rows: SearchParamRowData[]) => void` — callback when rows change. + +The component will own the "Add parameter" button, row rendering, and help text. +`ResourceSearchForm` will supply `availableParams` derived from the selected +resource type and manage the row state externally. + +**Rationale:** Lifting state management (rows) to the parent keeps the component +stateless and composable. The export form needs to manage multiple independent +`SearchParamsInput` instances (one per type filter entry), each with separate +row state. + +### Type filter section in ExportForm + +Each type filter entry in the export form will be a card-like row containing: + +1. A resource type dropdown (populated from the same list as the resource type + picker). +2. A `SearchParamsInput` for that resource type's search parameters. +3. A remove button. + +An "Add type filter" button will append a new entry. The section will appear +between `ExportOptions` and the submit button, and will be labelled "Type +filters". Help text will explain the `_typeFilter` semantics. + +**Rationale:** This approach mirrors the `ResourceType?search-params` format of +`_typeFilter` directly in the UI. Each entry maps to one `_typeFilter` value. + +### Type filter data model + +Type filters will be stored as an array on `ExportRequest`: + +```typescript +typeFilters?: Array<{ + resourceType: string; + params: Record; +}> +``` + +At submission, each entry will be serialised to the `_typeFilter` query format: +`ResourceType?param1=value1¶m2=value2`. Multiple entries for the same +resource type will produce multiple `_typeFilter` values (OR logic on the +server). + +### Remove typeFilters from ExportOptionsValues + +The raw `typeFilters` string field will be removed from `ExportOptionsValues` and +`ExportOptions` since the new structured UI replaces it entirely. The +`showExtendedOptions` prop and the `includeAssociatedData` field will also be +removed since they have no consumers. + +### API layer changes + +`BulkExportBaseOptions` will gain a `typeFilters?: string[]` field containing +pre-serialised `_typeFilter` strings. `buildExportParams` will be updated to +produce `_typeFilter` query parameters. Since `_typeFilter` can appear multiple +times, `buildExportParams` will return `URLSearchParams` instead of +`Record` to support repeated keys. + +## Risks / Trade-offs + +- **CapabilityStatement required for search params in export:** The export form + needs search parameter definitions from the CapabilityStatement to populate + dropdowns. The `Export` page already fetches capabilities, so this data is + available. If the server declares no search params for a resource type, the + user can still add a type filter entry but the dropdown will be empty (matching + the resources page behaviour). → Acceptable; users can always fall back to not + using type filters. + +- **Serialisation complexity:** Building `_typeFilter` strings from structured + data must handle edge cases (empty values, multiple values for the same param). + → Mitigated by reusing the same row-filtering logic already proven in + `ResourceSearchForm` (skip rows with empty param name or value). diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/proposal.md b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/proposal.md new file mode 100644 index 0000000000..13f86db5f2 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/proposal.md @@ -0,0 +1,50 @@ +## Why + +The bulk export `_typeFilter` parameter allows users to filter exported resources +using standard FHIR search parameters (e.g. `Patient?active=true`). The server +already has a spec for this (`bulk-export-type-filter`), but the admin UI export +form hides the type filter input behind an unused `showExtendedOptions` flag, and +the existing implementation is just a raw text field. The Resources page already +has a well-designed search parameters component with dropdown-based parameter +selection and type badges. Extracting and reusing this component would give users +a guided, structured way to build type filters in the export tab. + +## What Changes + +- Extract the search parameter row UI (parameter name dropdown + value input + + add/remove controls) from `ResourceSearchForm` into a reusable + `SearchParamsInput` component. +- Refactor `ResourceSearchForm` to use the extracted component. +- Add a type filters section to `ExportForm` that pairs a resource type selector + with the extracted search parameters component, allowing users to build + `_typeFilter` expressions per resource type. +- Wire `typeFilters` through `ExportRequest`, `BulkExportRequest`, and + `BulkExportBaseOptions` to the bulk export API kick-off functions as + `_typeFilter` query parameters. +- Remove the hidden raw text field for type filters from `ExportOptions`. + +## Capabilities + +### New Capabilities + +- `export-type-filter-ui`: UI support for building and submitting `_typeFilter` + parameters in the export form, including a reusable search parameters input + component extracted from the resources page. + +### Modified Capabilities + +- `search-parameter-form`: The search parameter row UI is extracted into a + standalone reusable component; the resources page is refactored to consume it. + +## Impact + +- `ui/src/components/resources/ResourceSearchForm.tsx` — refactored to use + extracted component. +- `ui/src/components/export/ExportForm.tsx` — gains type filter section. +- `ui/src/components/export/ExportOptions.tsx` — raw type filter text field + removed. +- `ui/src/types/export.ts` — `ExportRequest` gains `typeFilters` field. +- `ui/src/hooks/useBulkExport.ts` — `BulkExportRequest` gains `typeFilters`. +- `ui/src/api/bulkExport.ts` — `BulkExportBaseOptions` gains `typeFilters`; + kick-off functions send `_typeFilter` query params. +- New shared component file(s) for the extracted search parameters input. diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/export-type-filter-ui/spec.md b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/export-type-filter-ui/spec.md new file mode 100644 index 0000000000..2ece5f7fa3 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/export-type-filter-ui/spec.md @@ -0,0 +1,166 @@ +## ADDED Requirements + +### Requirement: Type filters section in export form + +The export form SHALL include a "Type filters" section positioned between the +export options and the submit button. The section SHALL display a heading, help +text explaining that type filters restrict exported resources using FHIR search +parameters, and an "Add type filter" button. + +#### Scenario: Initial export form has no type filter entries + +- **WHEN** the export form is first rendered +- **THEN** the type filters section SHALL be displayed with a heading, help text, + and an "Add type filter" button, but no type filter entries + +#### Scenario: User adds a type filter entry + +- **WHEN** the user clicks the "Add type filter" button +- **THEN** a new type filter entry SHALL be appended containing a resource type + dropdown (defaulting to no selection), a search parameters input with one empty + row, and a remove button + +### Requirement: Type filter entry resource type selection + +Each type filter entry SHALL include a resource type dropdown populated with the +available resource types from the server capabilities. When the user selects a +resource type, the search parameters input for that entry SHALL update to show +the search parameters available for the selected type. + +#### Scenario: Resource type selection populates search parameters + +- **WHEN** the user selects "Observation" in a type filter entry's resource type + dropdown +- **THEN** the search parameters dropdown for that entry SHALL display the search + parameters declared for Observation in the CapabilityStatement + +#### Scenario: Changing resource type resets search parameter rows + +- **WHEN** the user has entered search parameter values for a type filter entry + and then changes the resource type +- **THEN** all search parameter rows for that entry SHALL be reset to a single + empty row + +### Requirement: Type filter entry search parameters + +Each type filter entry SHALL include a search parameters input that allows the +user to add parameter name-value rows, identical in behaviour to the search +parameters section on the Resources page. Each row SHALL have a parameter name +dropdown and a value text input. An "Add parameter" button SHALL allow adding +rows, and each row SHALL have a remove button (disabled when only one row +remains). + +#### Scenario: Add search parameter row within a type filter entry + +- **WHEN** the user clicks the "Add parameter" button within a type filter entry +- **THEN** a new parameter row SHALL be appended to that entry's search + parameters input + +#### Scenario: Remove search parameter row within a type filter entry + +- **WHEN** the user clicks the remove button on a parameter row within a type + filter entry and there is more than one row +- **THEN** that row SHALL be removed from the entry + +### Requirement: Remove type filter entry + +The user SHALL be able to remove any type filter entry by clicking its remove +button. + +#### Scenario: Remove a type filter entry + +- **WHEN** the user clicks the remove button on a type filter entry +- **THEN** that entry SHALL be removed from the type filters section + +### Requirement: Type filters included in export request + +When the user submits the export form, type filter entries with a selected +resource type and at least one non-empty search parameter row SHALL be serialised +into `_typeFilter` query parameters in the format +`ResourceType?param1=value1¶m2=value2` and included in the export API +request. Entries with no resource type selected or with all empty parameter rows +SHALL be excluded. + +#### Scenario: Export with a single type filter + +- **WHEN** the user adds a type filter entry with resource type "Patient" and + parameter "active" with value "true", and submits the export +- **THEN** the export API request SHALL include the query parameter + `_typeFilter=Patient?active=true` + +#### Scenario: Export with multiple type filters for different types + +- **WHEN** the user adds a type filter entry for "Patient" with parameter + "active" = "true" and another entry for "Observation" with parameter "code" = + "8867-4", and submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Patient?active=true` and `_typeFilter=Observation?code=8867-4` + +#### Scenario: Export with multiple type filters for the same type (OR logic) + +- **WHEN** the user adds two type filter entries both for "Observation", one with + parameter "code" = "8867-4" and another with parameter "code" = "8310-5", and + submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Observation?code=8867-4` and + `_typeFilter=Observation?code=8310-5` + +#### Scenario: Type filter entry with multiple search parameters (AND logic) + +- **WHEN** the user adds a type filter entry for "Observation" with parameters + "code" = "8867-4" and "date" = "ge2024-01-01", and submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Observation?code=8867-4&date=ge2024-01-01` + +#### Scenario: Incomplete type filter entries are excluded + +- **WHEN** the user adds a type filter entry with no resource type selected and + submits the export +- **THEN** that entry SHALL be excluded from the export API request + +#### Scenario: Type filter entry with empty parameter rows is excluded + +- **WHEN** the user adds a type filter entry with resource type "Patient" but + all parameter rows have empty names or values, and submits the export +- **THEN** that entry SHALL be excluded from the export API request + +### Requirement: ExportRequest type extended for type filters + +The `ExportRequest` interface SHALL include a `typeFilters` field of type +`Array<{ resourceType: string; params: Record }>` to carry +structured type filter data from the form to the export card. + +#### Scenario: ExportRequest with type filters + +- **WHEN** an export is submitted with a type filter for "Patient" with parameter + "active" = "true" +- **THEN** the `ExportRequest` object SHALL contain + `typeFilters: [{ resourceType: "Patient", params: { active: ["true"] } }]` + +### Requirement: BulkExportRequest and API support for typeFilters + +The `BulkExportRequest` interface SHALL include a `typeFilters` field of type +`string[]` containing pre-serialised `_typeFilter` strings. The +`BulkExportBaseOptions` interface SHALL include a `typeFilters` field of type +`string[]`. The `buildExportParams` function SHALL include `_typeFilter` entries +in the query parameters sent to the server, supporting multiple values. + +#### Scenario: API request includes typeFilter query parameters + +- **WHEN** a bulk export is kicked off with + `typeFilters: ["Patient?active=true", "Observation?code=8867-4"]` +- **THEN** the HTTP request to the server SHALL include query parameters + `_typeFilter=Patient?active=true&_typeFilter=Observation?code=8867-4` + +### Requirement: Remove raw typeFilters and showExtendedOptions from ExportOptions + +The `ExportOptions` component SHALL NOT render the raw type filters text field +or the include associated data text field. The `showExtendedOptions` prop SHALL +be removed. The `typeFilters` and `includeAssociatedData` fields SHALL be removed +from the `ExportOptionsValues` interface and `DEFAULT_EXPORT_OPTIONS`. + +#### Scenario: ExportOptions does not render type filters text field + +- **WHEN** the `ExportOptions` component is rendered +- **THEN** there SHALL be no text field for type filters or include associated + data diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/search-parameter-form/spec.md b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/search-parameter-form/spec.md new file mode 100644 index 0000000000..f6defb5d57 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/specs/search-parameter-form/spec.md @@ -0,0 +1,89 @@ +## MODIFIED Requirements + +### Requirement: Search parameter section in search form + +The resource search form SHALL include a "Search parameters" section positioned +between the resource type selector and the FHIRPath filters section. This +section SHALL render a `SearchParamsInput` component that allows users to add +rows, where each row consists of a parameter name dropdown and a value text +input. The search form SHALL manage the row state and pass the available +parameters and rows to the `SearchParamsInput` component. + +#### Scenario: Initial form state + +- **WHEN** the search form is first rendered +- **THEN** the search parameters section SHALL be displayed using the + `SearchParamsInput` component with one empty parameter row + +#### Scenario: User adds a search parameter row + +- **WHEN** the user clicks the "Add parameter" button +- **THEN** a new parameter row SHALL be appended with an empty parameter + dropdown and an empty value input + +#### Scenario: User removes a search parameter row + +- **WHEN** the user clicks the remove button on a parameter row and there is + more than one row +- **THEN** that row SHALL be removed from the form + +#### Scenario: Last parameter row cannot be removed + +- **WHEN** there is only one parameter row +- **THEN** the remove button on that row SHALL be disabled + +## ADDED Requirements + +### Requirement: Reusable SearchParamsInput component + +The search parameter rows UI (parameter dropdown, value input, add/remove +controls, and help text) SHALL be provided as a reusable `SearchParamsInput` +component at `ui/src/components/SearchParamsInput.tsx`. The component SHALL +accept the following props: + +- `availableParams`: array of `{ name: string; type: string }` objects to + populate the parameter dropdown. +- `rows`: array of `{ id: number; paramName: string; value: string }` objects + representing the current row state. +- `onChange`: callback invoked with the updated rows array when the user adds, + removes, or edits a row. +- `onKeyDown` (optional): keyboard event handler to attach to value inputs. + +The component SHALL be stateless, receiving all state from the parent and +reporting changes via the `onChange` callback. + +#### Scenario: SearchParamsInput renders rows from props + +- **WHEN** `SearchParamsInput` is rendered with `rows` containing two entries + and `availableParams` containing three parameters +- **THEN** two parameter rows SHALL be displayed, each with a dropdown + containing three options and a value text input + +#### Scenario: SearchParamsInput reports row addition + +- **WHEN** the user clicks the "Add parameter" button within `SearchParamsInput` +- **THEN** the `onChange` callback SHALL be invoked with the current rows plus a + new empty row appended + +#### Scenario: SearchParamsInput reports row removal + +- **WHEN** the user clicks the remove button on a row and there is more than one + row +- **THEN** the `onChange` callback SHALL be invoked with the clicked row removed + +#### Scenario: SearchParamsInput disables remove on last row + +- **WHEN** `SearchParamsInput` is rendered with a single row +- **THEN** the remove button SHALL be disabled + +#### Scenario: SearchParamsInput reports parameter name change + +- **WHEN** the user selects a parameter name from the dropdown +- **THEN** the `onChange` callback SHALL be invoked with the updated row + reflecting the new parameter name + +#### Scenario: SearchParamsInput reports value change + +- **WHEN** the user types a value in a row's text input +- **THEN** the `onChange` callback SHALL be invoked with the updated row + reflecting the new value diff --git a/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/tasks.md b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/tasks.md new file mode 100644 index 0000000000..2af14d0fca --- /dev/null +++ b/openspec/changes/archive/2026-02-20-add-type-filter-to-export-tab/tasks.md @@ -0,0 +1,43 @@ +## 1. Extract reusable SearchParamsInput component + +- [x] 1.1 Create `SearchParamsInput` component at + `ui/src/components/SearchParamsInput.tsx` with props for `availableParams`, + `rows`, `onChange`, and optional `onKeyDown`; extract the row rendering, + add/remove logic, and help text from `ResourceSearchForm` +- [x] 1.2 Write tests for `SearchParamsInput` covering row rendering, add, remove, + remove-disabled-on-last-row, parameter name change, and value change +- [x] 1.3 Refactor `ResourceSearchForm` to use `SearchParamsInput`, managing row + state and passing `availableParams` derived from the selected resource type +- [x] 1.4 Verify existing `ResourceSearchForm` tests still pass + +## 2. Clean up ExportOptions + +- [x] 2.1 Remove `typeFilters` and `includeAssociatedData` from + `ExportOptionsValues`, `DEFAULT_EXPORT_OPTIONS`, and remove the + `showExtendedOptions` prop and its associated UI from `ExportOptions` +- [x] 2.2 Update any existing tests affected by the removed fields + +## 3. Add type filter section to ExportForm + +- [x] 3.1 Add `typeFilters` field to `ExportRequest` type as + `Array<{ resourceType: string; params: Record }>` +- [x] 3.2 Add type filter state management to `ExportForm` (array of entries, each + with resource type, search param rows, and a unique ID) +- [x] 3.3 Render the type filters section in `ExportForm` with "Add type filter" + button, per-entry resource type dropdown, `SearchParamsInput`, and remove button +- [x] 3.4 Pass `searchParams` (from CapabilityStatement) into `ExportForm` so + dropdowns can be populated per resource type +- [x] 3.5 Serialise type filter entries into the `ExportRequest` on form submission +- [x] 3.6 Write tests for `ExportForm` type filter rendering, add/remove entries, + resource type selection resetting params, and submission serialisation + +## 4. Wire type filters through API layer + +- [x] 4.1 Add `typeFilters?: string[]` to `BulkExportRequest` and + `BulkExportBaseOptions` +- [x] 4.2 Update `buildExportParams` to emit `_typeFilter` query parameters, + switching to `URLSearchParams` to support repeated keys +- [x] 4.3 Update `ExportCard` to serialise `ExportRequest.typeFilters` into + `_typeFilter` strings and pass them to `useBulkExport` +- [x] 4.4 Write tests for `buildExportParams` with type filters and for the + serialisation logic diff --git a/openspec/specs/export-type-filter-ui/spec.md b/openspec/specs/export-type-filter-ui/spec.md new file mode 100644 index 0000000000..2ece5f7fa3 --- /dev/null +++ b/openspec/specs/export-type-filter-ui/spec.md @@ -0,0 +1,166 @@ +## ADDED Requirements + +### Requirement: Type filters section in export form + +The export form SHALL include a "Type filters" section positioned between the +export options and the submit button. The section SHALL display a heading, help +text explaining that type filters restrict exported resources using FHIR search +parameters, and an "Add type filter" button. + +#### Scenario: Initial export form has no type filter entries + +- **WHEN** the export form is first rendered +- **THEN** the type filters section SHALL be displayed with a heading, help text, + and an "Add type filter" button, but no type filter entries + +#### Scenario: User adds a type filter entry + +- **WHEN** the user clicks the "Add type filter" button +- **THEN** a new type filter entry SHALL be appended containing a resource type + dropdown (defaulting to no selection), a search parameters input with one empty + row, and a remove button + +### Requirement: Type filter entry resource type selection + +Each type filter entry SHALL include a resource type dropdown populated with the +available resource types from the server capabilities. When the user selects a +resource type, the search parameters input for that entry SHALL update to show +the search parameters available for the selected type. + +#### Scenario: Resource type selection populates search parameters + +- **WHEN** the user selects "Observation" in a type filter entry's resource type + dropdown +- **THEN** the search parameters dropdown for that entry SHALL display the search + parameters declared for Observation in the CapabilityStatement + +#### Scenario: Changing resource type resets search parameter rows + +- **WHEN** the user has entered search parameter values for a type filter entry + and then changes the resource type +- **THEN** all search parameter rows for that entry SHALL be reset to a single + empty row + +### Requirement: Type filter entry search parameters + +Each type filter entry SHALL include a search parameters input that allows the +user to add parameter name-value rows, identical in behaviour to the search +parameters section on the Resources page. Each row SHALL have a parameter name +dropdown and a value text input. An "Add parameter" button SHALL allow adding +rows, and each row SHALL have a remove button (disabled when only one row +remains). + +#### Scenario: Add search parameter row within a type filter entry + +- **WHEN** the user clicks the "Add parameter" button within a type filter entry +- **THEN** a new parameter row SHALL be appended to that entry's search + parameters input + +#### Scenario: Remove search parameter row within a type filter entry + +- **WHEN** the user clicks the remove button on a parameter row within a type + filter entry and there is more than one row +- **THEN** that row SHALL be removed from the entry + +### Requirement: Remove type filter entry + +The user SHALL be able to remove any type filter entry by clicking its remove +button. + +#### Scenario: Remove a type filter entry + +- **WHEN** the user clicks the remove button on a type filter entry +- **THEN** that entry SHALL be removed from the type filters section + +### Requirement: Type filters included in export request + +When the user submits the export form, type filter entries with a selected +resource type and at least one non-empty search parameter row SHALL be serialised +into `_typeFilter` query parameters in the format +`ResourceType?param1=value1¶m2=value2` and included in the export API +request. Entries with no resource type selected or with all empty parameter rows +SHALL be excluded. + +#### Scenario: Export with a single type filter + +- **WHEN** the user adds a type filter entry with resource type "Patient" and + parameter "active" with value "true", and submits the export +- **THEN** the export API request SHALL include the query parameter + `_typeFilter=Patient?active=true` + +#### Scenario: Export with multiple type filters for different types + +- **WHEN** the user adds a type filter entry for "Patient" with parameter + "active" = "true" and another entry for "Observation" with parameter "code" = + "8867-4", and submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Patient?active=true` and `_typeFilter=Observation?code=8867-4` + +#### Scenario: Export with multiple type filters for the same type (OR logic) + +- **WHEN** the user adds two type filter entries both for "Observation", one with + parameter "code" = "8867-4" and another with parameter "code" = "8310-5", and + submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Observation?code=8867-4` and + `_typeFilter=Observation?code=8310-5` + +#### Scenario: Type filter entry with multiple search parameters (AND logic) + +- **WHEN** the user adds a type filter entry for "Observation" with parameters + "code" = "8867-4" and "date" = "ge2024-01-01", and submits the export +- **THEN** the export API request SHALL include + `_typeFilter=Observation?code=8867-4&date=ge2024-01-01` + +#### Scenario: Incomplete type filter entries are excluded + +- **WHEN** the user adds a type filter entry with no resource type selected and + submits the export +- **THEN** that entry SHALL be excluded from the export API request + +#### Scenario: Type filter entry with empty parameter rows is excluded + +- **WHEN** the user adds a type filter entry with resource type "Patient" but + all parameter rows have empty names or values, and submits the export +- **THEN** that entry SHALL be excluded from the export API request + +### Requirement: ExportRequest type extended for type filters + +The `ExportRequest` interface SHALL include a `typeFilters` field of type +`Array<{ resourceType: string; params: Record }>` to carry +structured type filter data from the form to the export card. + +#### Scenario: ExportRequest with type filters + +- **WHEN** an export is submitted with a type filter for "Patient" with parameter + "active" = "true" +- **THEN** the `ExportRequest` object SHALL contain + `typeFilters: [{ resourceType: "Patient", params: { active: ["true"] } }]` + +### Requirement: BulkExportRequest and API support for typeFilters + +The `BulkExportRequest` interface SHALL include a `typeFilters` field of type +`string[]` containing pre-serialised `_typeFilter` strings. The +`BulkExportBaseOptions` interface SHALL include a `typeFilters` field of type +`string[]`. The `buildExportParams` function SHALL include `_typeFilter` entries +in the query parameters sent to the server, supporting multiple values. + +#### Scenario: API request includes typeFilter query parameters + +- **WHEN** a bulk export is kicked off with + `typeFilters: ["Patient?active=true", "Observation?code=8867-4"]` +- **THEN** the HTTP request to the server SHALL include query parameters + `_typeFilter=Patient?active=true&_typeFilter=Observation?code=8867-4` + +### Requirement: Remove raw typeFilters and showExtendedOptions from ExportOptions + +The `ExportOptions` component SHALL NOT render the raw type filters text field +or the include associated data text field. The `showExtendedOptions` prop SHALL +be removed. The `typeFilters` and `includeAssociatedData` fields SHALL be removed +from the `ExportOptionsValues` interface and `DEFAULT_EXPORT_OPTIONS`. + +#### Scenario: ExportOptions does not render type filters text field + +- **WHEN** the `ExportOptions` component is rendered +- **THEN** there SHALL be no text field for type filters or include associated + data diff --git a/openspec/specs/search-parameter-form/spec.md b/openspec/specs/search-parameter-form/spec.md index 043db3b847..f6defb5d57 100644 --- a/openspec/specs/search-parameter-form/spec.md +++ b/openspec/specs/search-parameter-form/spec.md @@ -1,40 +1,19 @@ -## ADDED Requirements - -### Requirement: Search parameters extracted from CapabilityStatement - -The `parseCapabilities` function SHALL extract search parameter definitions from -each resource's `searchParam` array in the CapabilityStatement. Each search -parameter SHALL include its name and type. The `ResourceCapability` interface -SHALL be extended to include a `searchParams` field containing an array of -`{ name: string; type: string }` objects. - -#### Scenario: CapabilityStatement contains search parameters for Patient - -- **WHEN** the CapabilityStatement declares search parameters `gender` (token), - `birthdate` (date), and `name` (string) for the Patient resource -- **THEN** the parsed `ResourceCapability` for Patient SHALL include a - `searchParams` array containing entries for `gender` with type `token`, - `birthdate` with type `date`, and `name` with type `string` - -#### Scenario: CapabilityStatement has resource with no search parameters - -- **WHEN** the CapabilityStatement declares a resource type with no - `searchParam` entries -- **THEN** the parsed `ResourceCapability` for that resource SHALL have an - empty `searchParams` array +## MODIFIED Requirements ### Requirement: Search parameter section in search form The resource search form SHALL include a "Search parameters" section positioned between the resource type selector and the FHIRPath filters section. This -section SHALL allow users to add rows, where each row consists of a parameter -name dropdown and a value text input. +section SHALL render a `SearchParamsInput` component that allows users to add +rows, where each row consists of a parameter name dropdown and a value text +input. The search form SHALL manage the row state and pass the available +parameters and rows to the `SearchParamsInput` component. #### Scenario: Initial form state - **WHEN** the search form is first rendered -- **THEN** the search parameters section SHALL be displayed with a heading, an - "Add parameter" button, and one empty parameter row +- **THEN** the search parameters section SHALL be displayed using the + `SearchParamsInput` component with one empty parameter row #### Scenario: User adds a search parameter row @@ -53,88 +32,58 @@ name dropdown and a value text input. - **WHEN** there is only one parameter row - **THEN** the remove button on that row SHALL be disabled -### Requirement: Parameter dropdown populated by resource type - -The parameter name dropdown in each search parameter row SHALL display the -search parameters available for the currently selected resource type, as -extracted from the CapabilityStatement. Each option SHALL show the parameter -name, with the parameter type displayed as a badge next to it. - -#### Scenario: Parameter list updates on resource type change - -- **WHEN** the user changes the selected resource type from Patient to - Observation -- **THEN** the parameter dropdown options SHALL update to show search parameters - declared for Observation, and all search parameter rows SHALL be reset to a - single empty row - -#### Scenario: No search parameters available - -- **WHEN** the selected resource type has no declared search parameters -- **THEN** the parameter dropdown SHALL be empty and display placeholder text - indicating no parameters are available - -#### Scenario: FHIRPath filters reset on resource type change - -- **WHEN** the user has entered FHIRPath filter expressions and then changes the - selected resource type -- **THEN** all FHIRPath filter rows SHALL be reset to a single empty row - -### Requirement: Search parameters included in search request - -When the user submits the search form, any search parameter rows with both a -selected parameter name and a non-empty value SHALL be included in the search -request as standard FHIR query parameters alongside any FHIRPath filters. +## ADDED Requirements -#### Scenario: Search with standard parameters only +### Requirement: Reusable SearchParamsInput component -- **WHEN** the user selects resource type "Patient", adds parameter "gender" - with value "male", leaves the FHIRPath filter empty, and submits the search -- **THEN** the search request SHALL be sent as - `GET [base]/Patient?gender=male&_count=10` +The search parameter rows UI (parameter dropdown, value input, add/remove +controls, and help text) SHALL be provided as a reusable `SearchParamsInput` +component at `ui/src/components/SearchParamsInput.tsx`. The component SHALL +accept the following props: -#### Scenario: Search combining standard parameters and FHIRPath filters +- `availableParams`: array of `{ name: string; type: string }` objects to + populate the parameter dropdown. +- `rows`: array of `{ id: number; paramName: string; value: string }` objects + representing the current row state. +- `onChange`: callback invoked with the updated rows array when the user adds, + removes, or edits a row. +- `onKeyDown` (optional): keyboard event handler to attach to value inputs. -- **WHEN** the user selects resource type "Patient", adds parameter "gender" - with value "male", adds FHIRPath filter `active = true`, and submits the - search -- **THEN** the search request SHALL be sent as - `GET [base]/Patient?_query=fhirPath&filter=active = true&gender=male&_count=10` +The component SHALL be stateless, receiving all state from the parent and +reporting changes via the `onChange` callback. -#### Scenario: Empty parameter rows are excluded +#### Scenario: SearchParamsInput renders rows from props -- **WHEN** the user has a parameter row with no parameter selected or no value - entered, and submits the search -- **THEN** that row SHALL be excluded from the search request +- **WHEN** `SearchParamsInput` is rendered with `rows` containing two entries + and `availableParams` containing three parameters +- **THEN** two parameter rows SHALL be displayed, each with a dropdown + containing three options and a value text input -#### Scenario: Multiple values for the same parameter +#### Scenario: SearchParamsInput reports row addition -- **WHEN** the user adds two rows for the same parameter name with different - values -- **THEN** both values SHALL be sent as repeated query parameters (AND logic), - e.g., `date=gt2020-01-01&date=lt2025-01-01` +- **WHEN** the user clicks the "Add parameter" button within `SearchParamsInput` +- **THEN** the `onChange` callback SHALL be invoked with the current rows plus a + new empty row appended -### Requirement: SearchRequest type extended for standard parameters +#### Scenario: SearchParamsInput reports row removal -The `SearchRequest` interface SHALL be extended to include a `params` field of -type `Record` to carry standard search parameter name-value -pairs alongside the existing `filters` array. +- **WHEN** the user clicks the remove button on a row and there is more than one + row +- **THEN** the `onChange` callback SHALL be invoked with the clicked row removed -#### Scenario: SearchRequest with both filters and params +#### Scenario: SearchParamsInput disables remove on last row -- **WHEN** a search is submitted with FHIRPath filter `active = true` and - search parameter `gender=male` -- **THEN** the `SearchRequest` object SHALL contain - `filters: ["active = true"]` and `params: { gender: ["male"] }` +- **WHEN** `SearchParamsInput` is rendered with a single row +- **THEN** the remove button SHALL be disabled -### Requirement: Help text for search parameters section +#### Scenario: SearchParamsInput reports parameter name change -The search parameters section SHALL include help text explaining that search -parameters use standard FHIR search syntax and are combined with AND logic. +- **WHEN** the user selects a parameter name from the dropdown +- **THEN** the `onChange` callback SHALL be invoked with the updated row + reflecting the new parameter name -#### Scenario: Help text is displayed +#### Scenario: SearchParamsInput reports value change -- **WHEN** the search form is rendered -- **THEN** the search parameters section SHALL display help text below the - parameter rows explaining the AND combination logic and that values use - standard FHIR search syntax +- **WHEN** the user types a value in a row's text input +- **THEN** the `onChange` callback SHALL be invoked with the updated row + reflecting the new value diff --git a/ui/src/api/__tests__/bulkExport.test.ts b/ui/src/api/__tests__/bulkExport.test.ts index 9a511c06bf..554ee02283 100644 --- a/ui/src/api/__tests__/bulkExport.test.ts +++ b/ui/src/api/__tests__/bulkExport.test.ts @@ -20,6 +20,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UnauthorizedError } from "../../types/errors"; import { allPatientsExportKickOff, + buildExportParams, bulkExportDownload, bulkExportStatus, groupExportKickOff, @@ -334,6 +335,101 @@ describe("bulkExportStatus", () => { }); }); +describe("buildExportParams", () => { + it("returns empty URLSearchParams when no options provided", () => { + const params = buildExportParams({}); + expect(params.toString()).toBe(""); + }); + + it("includes _type parameter when types provided", () => { + const params = buildExportParams({ + types: ["Patient", "Observation"], + }); + expect(params.get("_type")).toBe("Patient,Observation"); + }); + + it("includes _since parameter when provided", () => { + const params = buildExportParams({ + since: "2024-01-01T00:00:00Z", + }); + expect(params.get("_since")).toBe("2024-01-01T00:00:00Z"); + }); + + it("includes _until parameter when provided", () => { + const params = buildExportParams({ + until: "2024-12-31T23:59:59Z", + }); + expect(params.get("_until")).toBe("2024-12-31T23:59:59Z"); + }); + + it("includes _elements parameter when provided", () => { + const params = buildExportParams({ elements: "id,name,birthDate" }); + expect(params.get("_elements")).toBe("id,name,birthDate"); + }); + + it("includes _outputFormat parameter when provided", () => { + const params = buildExportParams({ + outputFormat: "application/fhir+ndjson", + }); + expect(params.get("_outputFormat")).toBe("application/fhir+ndjson"); + }); + + it("includes single _typeFilter parameter", () => { + const params = buildExportParams({ + typeFilters: ["Patient?gender=female"], + }); + expect(params.getAll("_typeFilter")).toEqual(["Patient?gender=female"]); + }); + + it("includes multiple _typeFilter parameters as repeated keys", () => { + const params = buildExportParams({ + typeFilters: ["Patient?gender=female", "Observation?status=final"], + }); + expect(params.getAll("_typeFilter")).toEqual([ + "Patient?gender=female", + "Observation?status=final", + ]); + }); + + it("does not include _typeFilter when array is empty", () => { + const params = buildExportParams({ typeFilters: [] }); + expect(params.has("_typeFilter")).toBe(false); + }); + + it("includes all parameters together", () => { + const params = buildExportParams({ + types: ["Patient"], + since: "2024-01-01T00:00:00Z", + elements: "id,name", + typeFilters: ["Patient?gender=female"], + }); + expect(params.get("_type")).toBe("Patient"); + expect(params.get("_since")).toBe("2024-01-01T00:00:00Z"); + expect(params.get("_elements")).toBe("id,name"); + expect(params.getAll("_typeFilter")).toEqual(["Patient?gender=female"]); + }); +}); + +describe("systemExportKickOff with _typeFilter", () => { + it("includes _typeFilter parameters in the request URL", async () => { + const headers = new Headers(); + headers.set("Content-Location", "https://example.com/$job?id=abc"); + + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 202, headers }), + ); + + await systemExportKickOff("https://example.com/fhir", { + typeFilters: ["Patient?gender=female", "Observation?status=final"], + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + // Verify both _typeFilter values are present. + expect(calledUrl).toContain("_typeFilter=Patient"); + expect(calledUrl).toContain("_typeFilter=Observation"); + }); +}); + describe("bulkExportDownload", () => { it("makes GET request to file URL", async () => { const body = new ReadableStream(); diff --git a/ui/src/api/bulkExport.ts b/ui/src/api/bulkExport.ts index 3cd8f3c3ca..2d9d63ae90 100644 --- a/ui/src/api/bulkExport.ts +++ b/ui/src/api/bulkExport.ts @@ -30,6 +30,8 @@ export interface BulkExportBaseOptions extends AuthOptions { until?: string; elements?: string; outputFormat?: string; + /** Pre-serialised _typeFilter strings (e.g. "Patient?gender=female"). */ + typeFilters?: string[]; } export type SystemExportKickOffOptions = BulkExportBaseOptions; @@ -87,34 +89,41 @@ export type BulkExportDownloadFn = ( ) => Promise; /** - * Builds query parameters for bulk export operations. + * Builds query parameters for bulk export operations. Returns URLSearchParams + * to support repeated keys (e.g. multiple _typeFilter values). * * @param options - Export options containing optional filters. - * @returns Record of query parameter key-value pairs. + * @returns URLSearchParams instance with the export query parameters. */ -function buildExportParams( - options: SystemExportKickOffOptions | AllPatientsExportKickOffOptions, -): Record { - const params: Record = {}; +export function buildExportParams( + options: BulkExportBaseOptions, +): URLSearchParams { + const params = new URLSearchParams(); if (options.types && options.types.length > 0) { - params._type = options.types.join(","); + params.set("_type", options.types.join(",")); } if (options.since) { - params._since = options.since; + params.set("_since", options.since); } if (options.until) { - params._until = options.until; + params.set("_until", options.until); } if (options.elements) { - params._elements = options.elements; + params.set("_elements", options.elements); } if (options.outputFormat) { - params._outputFormat = options.outputFormat; + params.set("_outputFormat", options.outputFormat); + } + + if (options.typeFilters) { + for (const filter of options.typeFilters) { + params.append("_typeFilter", filter); + } } return params; @@ -131,14 +140,10 @@ function buildExportParams( async function kickOffExport( baseUrl: string, path: string, - options: SystemExportKickOffOptions, + options: BulkExportBaseOptions, ): Promise { const params = buildExportParams(options); - const url = buildUrl( - baseUrl, - path, - Object.keys(params).length > 0 ? params : undefined, - ); + const url = buildUrl(baseUrl, path, params); const headers = buildHeaders({ accessToken: options.accessToken, prefer: "respond-async", diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 04eadc6101..7cb299de93 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -27,6 +27,7 @@ export { groupExportKickOff, bulkExportStatus, bulkExportDownload, + buildExportParams, } from "./bulkExport"; // Bulk submit operations. diff --git a/ui/src/api/utils.ts b/ui/src/api/utils.ts index 47e1119e11..8e62810c9f 100644 --- a/ui/src/api/utils.ts +++ b/ui/src/api/utils.ts @@ -87,7 +87,7 @@ export function buildHeaders(options: BuildHeadersOptions = {}): HeadersInit { export function buildUrl( base: string, path: string, - params?: Record, + params?: Record | URLSearchParams, ): string { // Normalise base URL by removing trailing slash. const normalisedBase = base.endsWith("/") ? base.slice(0, -1) : base; @@ -97,9 +97,13 @@ export function buildUrl( let url = `${normalisedBase}${normalisedPath}`; - if (params && Object.keys(params).length > 0) { - const searchParams = new URLSearchParams(params); - url += `?${searchParams.toString()}`; + if (params) { + const searchParams = + params instanceof URLSearchParams ? params : new URLSearchParams(params); + const queryString = searchParams.toString(); + if (queryString) { + url += `?${queryString}`; + } } return url; diff --git a/ui/src/components/SearchParamsInput.tsx b/ui/src/components/SearchParamsInput.tsx new file mode 100644 index 0000000000..409bd4d436 --- /dev/null +++ b/ui/src/components/SearchParamsInput.tsx @@ -0,0 +1,165 @@ +/* + * Copyright © 2018-2026 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Reusable search parameter rows input component. + * + * @author John Grimes + */ + +import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { Badge, Box, Button, Flex, IconButton, Select, Text, TextField } from "@radix-ui/themes"; + +import type { SearchParamCapability } from "../hooks/useServerCapabilities"; + +/** + * Represents a single search parameter row with a unique ID, parameter name, + * and value. + */ +export interface SearchParamRowData { + /** Unique identifier for the row. */ + id: number; + /** The selected search parameter name. */ + paramName: string; + /** The search parameter value. */ + value: string; +} + +interface SearchParamsInputProps { + /** Available search parameters to show in the dropdown. */ + availableParams: SearchParamCapability[]; + /** Current row state. */ + rows: SearchParamRowData[]; + /** Callback when rows change (add, remove, or edit). */ + onChange: (rows: SearchParamRowData[]) => void; + /** Optional keyboard event handler to attach to value inputs. */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +/** Counter shared across all instances for generating unique row IDs. */ +let nextId = 1000; + +/** + * Generates a unique ID for a new search parameter row. + * + * @returns A unique numeric ID. + */ +export function generateRowId(): number { + return nextId++; +} + +/** + * Creates a new empty search parameter row. + * + * @returns A new row with an empty parameter name and value. + */ +export function createEmptyRow(): SearchParamRowData { + return { id: generateRowId(), paramName: "", value: "" }; +} + +/** + * Stateless component for rendering search parameter name-value rows with + * add/remove controls. + * + * @param props - The component props. + * @param props.availableParams - Available parameters for the dropdown. + * @param props.rows - Current row state. + * @param props.onChange - Callback when rows change. + * @param props.onKeyDown - Optional keyboard event handler for value inputs. + * @returns The search parameters input component. + */ +export function SearchParamsInput({ + availableParams, + rows, + onChange, + onKeyDown, +}: Readonly) { + const addRow = () => { + onChange([...rows, createEmptyRow()]); + }; + + const removeRow = (id: number) => { + if (rows.length > 1) { + onChange(rows.filter((row) => row.id !== id)); + } + }; + + const updateRow = (id: number, field: "paramName" | "value", val: string) => { + onChange(rows.map((row) => (row.id === id ? { ...row, [field]: val } : row))); + }; + + return ( + + + + Search parameters + + + + + {rows.map((row) => ( + + + updateRow(row.id, "paramName", val)} + > + + + {availableParams.map((param) => ( + + + {param.name} + + {param.type} + + + + ))} + + + + + updateRow(row.id, "value", e.target.value)} + onKeyDown={onKeyDown} + style={{ minWidth: "100px" }} + /> + + removeRow(row.id)} + disabled={rows.length === 1} + > + + + + ))} + + + Search parameters use standard FHIR search syntax and are combined with AND logic. + + + ); +} diff --git a/ui/src/components/__tests__/SearchParamsInput.test.tsx b/ui/src/components/__tests__/SearchParamsInput.test.tsx new file mode 100644 index 0000000000..2f14bba3d7 --- /dev/null +++ b/ui/src/components/__tests__/SearchParamsInput.test.tsx @@ -0,0 +1,275 @@ +/* + * Copyright © 2018-2026 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests for the SearchParamsInput component which provides a reusable search + * parameter rows interface with dropdown selection and value inputs. + * + * These tests verify row rendering, add/remove behaviour, parameter name and + * value changes, and the disabled state of the remove button when only one row + * remains. + * + * @author John Grimes + */ + +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { render, screen } from "../../test/testUtils"; +import { SearchParamsInput } from "../SearchParamsInput"; + +import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; +import type { SearchParamRowData } from "../SearchParamsInput"; + +describe("SearchParamsInput", () => { + const mockAvailableParams: SearchParamCapability[] = [ + { name: "gender", type: "token" }, + { name: "birthdate", type: "date" }, + { name: "name", type: "string" }, + ]; + + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("row rendering", () => { + it("renders the correct number of rows from props", () => { + const rows: SearchParamRowData[] = [ + { id: 1, paramName: "", value: "" }, + { id: 2, paramName: "", value: "" }, + ]; + + render( + , + ); + + // Each row has a value input with the placeholder. + const valueInputs = screen.getAllByPlaceholderText(/e\.g\., male/i); + expect(valueInputs).toHaveLength(2); + }); + + it("renders the heading and add parameter button", () => { + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + expect(screen.getByText("Search parameters")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add parameter/i })).toBeInTheDocument(); + }); + + it("renders help text about FHIR search syntax", () => { + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + expect(screen.getByText(/standard fhir search syntax/i)).toBeInTheDocument(); + }); + + it("renders parameter dropdown with available options", async () => { + const user = userEvent.setup(); + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + // Open the parameter dropdown. + const dropdown = screen.getByRole("combobox"); + await user.click(dropdown); + + // All three parameters should be available. + expect(screen.getByRole("option", { name: /gender/i })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /birthdate/i })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /name/i })).toBeInTheDocument(); + }); + }); + + describe("add row", () => { + it("calls onChange with a new empty row appended when add button is clicked", async () => { + const user = userEvent.setup(); + const rows: SearchParamRowData[] = [{ id: 1, paramName: "gender", value: "male" }]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /add parameter/i })); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + const newRows = mockOnChange.mock.calls[0][0] as SearchParamRowData[]; + expect(newRows).toHaveLength(2); + // First row should be unchanged. + expect(newRows[0]).toEqual({ id: 1, paramName: "gender", value: "male" }); + // Second row should be empty with a new ID. + expect(newRows[1].paramName).toBe(""); + expect(newRows[1].value).toBe(""); + }); + }); + + describe("remove row", () => { + it("calls onChange with the row removed when remove button is clicked", async () => { + const user = userEvent.setup(); + const rows: SearchParamRowData[] = [ + { id: 1, paramName: "gender", value: "male" }, + { id: 2, paramName: "birthdate", value: "2000-01-01" }, + ]; + + render( + , + ); + + // Find the icon-only buttons (remove buttons are icon buttons with no text). + const allButtons = screen.getAllByRole("button"); + const enabledRemoveButtons = allButtons.filter( + (btn) => + btn.querySelector("svg") !== null && !btn.textContent && !btn.hasAttribute("disabled"), + ); + // Click the first enabled remove button to remove the first row. + expect(enabledRemoveButtons.length).toBeGreaterThan(0); + await user.click(enabledRemoveButtons[0]); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + const newRows = mockOnChange.mock.calls[0][0] as SearchParamRowData[]; + expect(newRows).toHaveLength(1); + expect(newRows[0].id).toBe(2); + }); + + it("disables the remove button when there is only one row", () => { + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + // Find icon-only buttons (remove buttons). + const allButtons = screen.getAllByRole("button"); + const removeButtons = allButtons.filter( + (btn) => btn.querySelector("svg") !== null && !btn.textContent, + ); + + expect(removeButtons).toHaveLength(1); + expect(removeButtons[0]).toBeDisabled(); + }); + }); + + describe("parameter name change", () => { + it("calls onChange with the updated parameter name when a selection is made", async () => { + const user = userEvent.setup(); + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + const dropdown = screen.getByRole("combobox"); + await user.click(dropdown); + await user.click(screen.getByRole("option", { name: /gender/i })); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + const newRows = mockOnChange.mock.calls[0][0] as SearchParamRowData[]; + expect(newRows[0].paramName).toBe("gender"); + expect(newRows[0].value).toBe(""); + }); + }); + + describe("value change", () => { + it("calls onChange with the updated value when the user types", async () => { + const user = userEvent.setup(); + const rows: SearchParamRowData[] = [{ id: 1, paramName: "gender", value: "" }]; + + render( + , + ); + + const valueInput = screen.getByPlaceholderText(/e\.g\., male/i); + await user.type(valueInput, "m"); + + // onChange should have been called for the first character typed. + expect(mockOnChange).toHaveBeenCalled(); + const firstCall = mockOnChange.mock.calls[0][0] as SearchParamRowData[]; + expect(firstCall[0].value).toBe("m"); + }); + }); + + describe("onKeyDown", () => { + it("forwards keyboard events from value inputs to the onKeyDown handler", async () => { + const user = userEvent.setup(); + const mockOnKeyDown = vi.fn(); + const rows: SearchParamRowData[] = [{ id: 1, paramName: "", value: "" }]; + + render( + , + ); + + const valueInput = screen.getByPlaceholderText(/e\.g\., male/i); + await user.type(valueInput, "{Enter}"); + + expect(mockOnKeyDown).toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/src/components/export/ExportCard.tsx b/ui/src/components/export/ExportCard.tsx index 9e4153e862..8b166ebd1d 100644 --- a/ui/src/components/export/ExportCard.tsx +++ b/ui/src/components/export/ExportCard.tsx @@ -28,7 +28,7 @@ import { useEffect, useRef, useState } from "react"; import { OperationOutcomeDisplay } from "../../components/error/OperationOutcomeDisplay"; import { useBulkExport, useDownloadFile } from "../../hooks"; -import { getExportOutputFiles } from "../../types/export"; +import { getExportOutputFiles, serialiseTypeFilters } from "../../types/export"; import { formatDateTime } from "../../utils"; import type { BulkExportType } from "../../hooks/useBulkExport"; @@ -125,6 +125,7 @@ export function ExportCard({ request, createdAt, onError, onClose }: Readonly void; resourceTypes: string[]; + /** Search parameters per resource type from the CapabilityStatement. */ + searchParams?: Record; +} + +/** Internal state for a single type filter entry. */ +interface TypeFilterState { + id: string; + resourceType: string; + rows: SearchParamRowData[]; } const EXPORT_LEVELS: { value: ExportLevel; label: string }[] = [ @@ -43,19 +65,69 @@ const EXPORT_LEVELS: { value: ExportLevel; label: string }[] = [ { value: "group", label: "Data for patients in group" }, ]; +/** + * Serialises type filter entries into the format expected by the ExportRequest. + * Entries with no resource type selected or no non-empty rows are excluded. + * + * @param entries - The internal type filter state entries. + * @returns Array of TypeFilterEntry objects, or undefined if empty. + */ +function serialiseTypeFilters(entries: TypeFilterState[]): TypeFilterEntry[] | undefined { + const result: TypeFilterEntry[] = []; + for (const entry of entries) { + if (!entry.resourceType) continue; + const params: Record = {}; + for (const row of entry.rows) { + if (row.paramName && row.value) { + if (!params[row.paramName]) { + params[row.paramName] = []; + } + params[row.paramName].push(row.value); + } + } + result.push({ resourceType: entry.resourceType, params }); + } + return result.length > 0 ? result : undefined; +} + /** * Form for configuring and starting a bulk data export. * * @param root0 - The component props. * @param root0.onSubmit - Callback when export is submitted. * @param root0.resourceTypes - Available resource types for selection. + * @param root0.searchParams - Search parameters per resource type. * @returns The export form component. */ -export function ExportForm({ onSubmit, resourceTypes }: Readonly) { +export function ExportForm({ onSubmit, resourceTypes, searchParams }: Readonly) { const [level, setLevel] = useState("system"); const [exportOptions, setExportOptions] = useState(DEFAULT_EXPORT_OPTIONS); const [patientId, setPatientId] = useState(""); const [groupId, setGroupId] = useState(""); + const [typeFilters, setTypeFilters] = useState([]); + + const addTypeFilter = () => { + setTypeFilters((prev) => [ + ...prev, + { id: crypto.randomUUID(), resourceType: "", rows: [createEmptyRow()] }, + ]); + }; + + const removeTypeFilter = (id: string) => { + setTypeFilters((prev) => prev.filter((entry) => entry.id !== id)); + }; + + const updateTypeFilterResourceType = (id: string, resourceType: string) => { + setTypeFilters((prev) => + prev.map((entry) => + entry.id === id ? { ...entry, resourceType, rows: [createEmptyRow()] } : entry, + ), + ); + }; + + const updateTypeFilterRows = (id: string, rows: SearchParamRowData[]) => { + setTypeFilters((prev) => prev.map((entry) => (entry.id === id ? { ...entry, rows } : entry))); + }; const handleSubmit = () => { const request: ExportRequest = { @@ -67,6 +139,7 @@ export function ExportForm({ onSubmit, resourceTypes }: Readonly + + + + Type filters + + + + {typeFilters.length > 0 ? ( + + {typeFilters.map((entry) => ( + + + + + updateTypeFilterResourceType(entry.id, value)} + > + + + {resourceTypes.map((rt) => ( + + {rt} + + ))} + + + + removeTypeFilter(entry.id)} + > + + + + {entry.resourceType && ( + updateTypeFilterRows(entry.id, rows)} + /> + )} + + + ))} + + ) : ( + + No type filters configured. Type filters allow filtering exported resources using FHIR + search parameters. + + )} + + - - - {paramRows.map((row) => ( - - - updateParamRow(row.id, "paramName", val)} - > - - - {availableParams.map((param) => ( - - - {param.name} - - {param.type} - - - - ))} - - - - - updateParamRow(row.id, "value", e.target.value)} - onKeyDown={handleKeyDown} - style={{ minWidth: "100px" }} - /> - - removeParamRow(row.id)} - disabled={paramRows.length === 1} - > - - - - ))} - - - Search parameters use standard FHIR search syntax and are combined with AND logic. - - + )} diff --git a/ui/src/components/resources/__tests__/ResourceSearchForm.test.tsx b/ui/src/components/resources/__tests__/ResourceSearchForm.test.tsx index 4b5a31e5a6..ca6482bc38 100644 --- a/ui/src/components/resources/__tests__/ResourceSearchForm.test.tsx +++ b/ui/src/components/resources/__tests__/ResourceSearchForm.test.tsx @@ -30,7 +30,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "../../../test/testUtils"; +import { render, screen } from "../../../test/testUtils"; import { ResourceSearchForm } from "../ResourceSearchForm"; import type { SearchParamCapability } from "../../../hooks/useServerCapabilities"; @@ -582,17 +582,14 @@ describe("ResourceSearchForm", () => { let valueInputs = screen.getAllByPlaceholderText(/e\.g\., male/i); expect(valueInputs).toHaveLength(2); - // Find a remove button near a parameter value input and click it. - const firstParamInput = valueInputs[0]; - const paramRow = firstParamInput.closest("[style]")!.parentElement!; - const removeButton = within(paramRow) - .getAllByRole("button") - .find( - (btn) => - btn.querySelector("svg") !== null && !btn.textContent && !btn.hasAttribute("disabled"), - ); - if (removeButton) { - await user.click(removeButton); + // Find an enabled icon-only remove button and click it. + const allButtons = screen.getAllByRole("button"); + const enabledRemoveButton = allButtons.find( + (btn) => + btn.querySelector("svg") !== null && !btn.textContent && !btn.hasAttribute("disabled"), + ); + if (enabledRemoveButton) { + await user.click(enabledRemoveButton); } valueInputs = screen.getAllByPlaceholderText(/e\.g\., male/i); @@ -610,15 +607,16 @@ describe("ResourceSearchForm", () => { />, ); - // Find the remove button adjacent to the parameter value input. - const paramInput = screen.getByPlaceholderText(/e\.g\., male/i); - const paramRow = paramInput.closest("[style]")!.parentElement!; - const removeButtons = within(paramRow) - .getAllByRole("button") - .filter((btn) => btn.querySelector("svg") !== null && !btn.textContent); + // Find icon-only buttons (remove buttons) - the first disabled one is for + // the FHIRPath filter row, the second is for the search parameter row. + const allButtons = screen.getAllByRole("button"); + const disabledRemoveButtons = allButtons.filter( + (btn) => + btn.querySelector("svg") !== null && !btn.textContent && btn.hasAttribute("disabled"), + ); - expect(removeButtons).toHaveLength(1); - expect(removeButtons[0]).toBeDisabled(); + // Both the filter row and param row remove buttons should be disabled. + expect(disabledRemoveButtons.length).toBeGreaterThanOrEqual(2); }); it("populates parameter dropdown with options for selected resource type", async () => { diff --git a/ui/src/hooks/useBulkExport.ts b/ui/src/hooks/useBulkExport.ts index 7981203776..46bab5e2a0 100644 --- a/ui/src/hooks/useBulkExport.ts +++ b/ui/src/hooks/useBulkExport.ts @@ -61,6 +61,8 @@ export interface BulkExportRequest { elements?: string; /** Output format. */ outputFormat?: string; + /** Pre-serialised _typeFilter strings (e.g. "Patient?gender=female"). */ + typeFilters?: string[]; } /** @@ -76,10 +78,8 @@ export type ExportManifest = Parameters; /** * Result of useBulkExport hook. */ -export interface UseBulkExportResult extends UseAsyncJobResult< - BulkExportRequest, - ExportManifest -> { +export interface UseBulkExportResult + extends UseAsyncJobResult { /** Function to download a file from the manifest. */ download: (fileName: string) => Promise; } @@ -122,6 +122,7 @@ export const useBulkExport: UseBulkExportFn = (options) => { until: request.until, elements: request.elements, outputFormat: request.outputFormat, + typeFilters: request.typeFilters, accessToken, }; diff --git a/ui/src/pages/Export.tsx b/ui/src/pages/Export.tsx index 55410ffdb7..ed9b76dca1 100644 --- a/ui/src/pages/Export.tsx +++ b/ui/src/pages/Export.tsx @@ -33,6 +33,7 @@ import { config } from "../config"; import { useAuth } from "../contexts/AuthContext"; import { useServerCapabilities } from "../hooks"; +import type { SearchParamCapability } from "../hooks/useServerCapabilities"; import type { ExportRequest } from "../types/export"; interface ExportJob { @@ -55,6 +56,15 @@ export function Export() { const { data: capabilities, isLoading: isLoadingCapabilities } = useServerCapabilities(fhirBaseUrl); + // Build search parameters mapping from capabilities. + let searchParams: Record | undefined; + if (capabilities?.resources) { + searchParams = {}; + for (const resource of capabilities.resources) { + searchParams[resource.type] = resource.searchParams; + } + } + const handleExport = (request: ExportRequest) => { const newExport: ExportJob = { id: crypto.randomUUID(), @@ -92,7 +102,11 @@ export function Export() { <> - + diff --git a/ui/src/types/__tests__/export.test.ts b/ui/src/types/__tests__/export.test.ts index a4ffb83604..fe3e18b899 100644 --- a/ui/src/types/__tests__/export.test.ts +++ b/ui/src/types/__tests__/export.test.ts @@ -26,7 +26,11 @@ import { describe, expect, it } from "vitest"; -import { getExportOutputFiles } from "../export"; +import { + getExportOutputFiles, + serialiseTypeFilterEntry, + serialiseTypeFilters, +} from "../export"; import type { Parameters } from "fhir/r4"; @@ -246,3 +250,58 @@ describe("getExportOutputFiles", () => { expect(outputs.map((o) => o.count)).toEqual([1000, 1000, 50000]); }); }); + +describe("serialiseTypeFilterEntry", () => { + it("serialises entry with a single parameter", () => { + const result = serialiseTypeFilterEntry({ + resourceType: "Patient", + params: { gender: ["female"] }, + }); + expect(result).toBe("Patient?gender=female"); + }); + + it("serialises entry with multiple parameters", () => { + const result = serialiseTypeFilterEntry({ + resourceType: "Patient", + params: { gender: ["female"], active: ["true"] }, + }); + expect(result).toBe("Patient?gender=female&active=true"); + }); + + it("serialises entry with multiple values for the same parameter", () => { + const result = serialiseTypeFilterEntry({ + resourceType: "Observation", + params: { code: ["1234-5", "6789-0"] }, + }); + expect(result).toBe("Observation?code=1234-5&code=6789-0"); + }); + + it("serialises entry with no parameters as just the resource type", () => { + const result = serialiseTypeFilterEntry({ + resourceType: "Patient", + params: {}, + }); + expect(result).toBe("Patient"); + }); +}); + +describe("serialiseTypeFilters", () => { + it("serialises multiple entries", () => { + const result = serialiseTypeFilters([ + { resourceType: "Patient", params: { gender: ["female"] } }, + { resourceType: "Observation", params: { status: ["final"] } }, + ]); + expect(result).toEqual([ + "Patient?gender=female", + "Observation?status=final", + ]); + }); + + it("returns undefined for empty array", () => { + expect(serialiseTypeFilters([])).toBeUndefined(); + }); + + it("returns undefined for undefined input", () => { + expect(serialiseTypeFilters(undefined)).toBeUndefined(); + }); +}); diff --git a/ui/src/types/export.ts b/ui/src/types/export.ts index d1d472099d..cbcb0e03c0 100644 --- a/ui/src/types/export.ts +++ b/ui/src/types/export.ts @@ -25,6 +25,12 @@ import type { Parameters } from "fhir/r4"; export type ExportLevel = "system" | "all-patients" | "patient" | "group"; +/** A single type filter entry mapping a resource type to search parameters. */ +export interface TypeFilterEntry { + resourceType: string; + params: Record; +} + export interface ExportRequest { level: ExportLevel; resourceTypes?: string[]; @@ -34,6 +40,7 @@ export interface ExportRequest { patientId?: string; groupId?: string; outputFormat?: string; + typeFilters?: TypeFilterEntry[]; } /** @@ -50,6 +57,38 @@ export interface ExportManifestOutput { */ export type ExportManifest = Parameters; +/** + * Serialises a single TypeFilterEntry into the `_typeFilter` query string + * format: `ResourceType?param1=value1¶m2=value2`. + * + * @param entry - The type filter entry to serialise. + * @returns The serialised _typeFilter string. + */ +export function serialiseTypeFilterEntry(entry: TypeFilterEntry): string { + const parts: string[] = []; + for (const [name, values] of Object.entries(entry.params)) { + for (const value of values) { + parts.push(`${name}=${value}`); + } + } + return parts.length > 0 + ? `${entry.resourceType}?${parts.join("&")}` + : entry.resourceType; +} + +/** + * Serialises an array of TypeFilterEntry objects into `_typeFilter` strings. + * + * @param entries - The type filter entries to serialise. + * @returns Array of serialised _typeFilter strings, or undefined if empty. + */ +export function serialiseTypeFilters( + entries: TypeFilterEntry[] | undefined, +): string[] | undefined { + if (!entries || entries.length === 0) return undefined; + return entries.map(serialiseTypeFilterEntry); +} + /** * Extracts output file entries from an export manifest Parameters resource. * diff --git a/ui/src/types/exportOptions.ts b/ui/src/types/exportOptions.ts index 03f6a2e9c0..6ebf2b00af 100644 --- a/ui/src/types/exportOptions.ts +++ b/ui/src/types/exportOptions.ts @@ -35,10 +35,6 @@ export interface ExportOptionsValues { elements: string; /** Output format MIME type for the export. */ outputFormat: string; - /** Comma-separated FHIR search queries to filter resources. */ - typeFilters: string; - /** Comma-separated list of pre-defined associated data sets to include. */ - includeAssociatedData: string; } /** @@ -50,8 +46,6 @@ export const DEFAULT_EXPORT_OPTIONS: ExportOptionsValues = { until: "", elements: "", outputFormat: "", - typeFilters: "", - includeAssociatedData: "", }; /** From 3ce7713374be10756bbbf84e0abdefb98a606202 Mon Sep 17 00:00:00 2001 From: John Grimes Date: Fri, 20 Feb 2026 08:19:45 +1000 Subject: [PATCH 03/18] refactor: Move type filter UI into ExportOptions component Consolidates type filter controls (add/remove entries, resource type selection, search parameter rows) from ExportForm into ExportOptions, making them available to all consumers. Extends ExportOptionsValues with a typeFilters field and adds searchParams prop to ExportOptions. Updates ImportPnpForm to pass searchParams through and serialise type filters into the import request, enabling type filter support for ping-and-pull imports. --- .../.openspec.yaml | 2 + .../design.md | 67 ++++++++ .../proposal.md | 52 +++++++ .../specs/export-type-filter-ui/spec.md | 145 ++++++++++++++++++ .../tasks.md | 30 ++++ openspec/specs/export-type-filter-ui/spec.md | 71 +++++++-- ui/src/components/export/ExportForm.tsx | 142 +---------------- ui/src/components/export/ExportOptions.tsx | 110 ++++++++++++- .../export/__tests__/ExportOptions.test.tsx | 28 +++- ui/src/components/import/ImportPnpForm.tsx | 14 +- .../import/__tests__/ImportPnpForm.test.tsx | 72 +++++++++ ui/src/pages/Import.tsx | 11 ++ ui/src/types/exportOptions.ts | 43 ++++++ 13 files changed, 628 insertions(+), 159 deletions(-) create mode 100644 openspec/changes/archive/2026-02-20-type-filters-in-export-options/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-20-type-filters-in-export-options/design.md create mode 100644 openspec/changes/archive/2026-02-20-type-filters-in-export-options/proposal.md create mode 100644 openspec/changes/archive/2026-02-20-type-filters-in-export-options/specs/export-type-filter-ui/spec.md create mode 100644 openspec/changes/archive/2026-02-20-type-filters-in-export-options/tasks.md diff --git a/openspec/changes/archive/2026-02-20-type-filters-in-export-options/.openspec.yaml b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/.openspec.yaml new file mode 100644 index 0000000000..d299748398 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-19 diff --git a/openspec/changes/archive/2026-02-20-type-filters-in-export-options/design.md b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/design.md new file mode 100644 index 0000000000..dcc0c43427 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/design.md @@ -0,0 +1,67 @@ +## Context + +The `ExportOptions` component currently renders resource type selection, date +range filters, elements, and output format. Type filter UI (add/remove filter +entries, resource type dropdown per entry, search parameter rows) lives inline +in `ExportForm` with its own state management. This creates a split where some +export configuration lives in `ExportOptions` and some in `ExportForm`. + +## Goals / Non-Goals + +**Goals:** + +- Consolidate all export configuration fields (including type filters) inside + `ExportOptions`. +- Keep the existing type filter UX unchanged. +- Make type filters available to any consumer of `ExportOptions` without + duplicating state management. + +**Non-Goals:** + +- Changing the type filter serialisation logic or API request format. +- Modifying `SearchParamsInput` behaviour. + +## Decisions + +### Extend ExportOptionsValues with typeFilters field + +Add a `typeFilters: TypeFilterState[]` field to `ExportOptionsValues` and move +`TypeFilterState` into `exportOptions.ts`. This keeps all export config in a +single state object that flows through `values`/`onChange`. + +Alternative considered: passing type filters as a separate prop/callback pair on +`ExportOptions`. Rejected because it breaks the single-values-object pattern +already established. + +### Pass searchParams through ExportOptions + +Add an optional `searchParams` prop to `ExportOptions` to provide per-resource-type +search parameter metadata. This is needed for the search parameter dropdowns +within type filter entries. + +### Move serialiseTypeFilters to exportOptions.ts + +Since `TypeFilterState` moves to `exportOptions.ts`, the serialisation function +that converts internal state to `TypeFilterEntry[]` should move there too, +keeping type and logic co-located. `ExportForm.handleSubmit` will import and +call it. + +### Keep ExportForm as the state owner + +`ExportForm` continues to own the `ExportOptionsValues` state via `useState`. +`ExportOptions` remains a controlled component receiving `values` and `onChange`. + +### Wire up ImportPnpForm + +`ImportPnpForm` already renders `ExportOptions` and its hook already supports +`typeFilters?: string[]`. The form just needs to: accept `searchParams`, pass it +through to `ExportOptions`, and serialise `exportOptions.typeFilters` into the +`ImportPnpRequest` on submit. No API or hook changes required. + +## Risks / Trade-offs + +- Slightly larger `ExportOptions` component, but the total code is the same - + just relocated. Mitigated by the component already being a form-fields + aggregator by design. +- `ExportOptions` gains a dependency on `SearchParamsInput`. This is acceptable + since both are export-specific components in the same directory. diff --git a/openspec/changes/archive/2026-02-20-type-filters-in-export-options/proposal.md b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/proposal.md new file mode 100644 index 0000000000..a6aaed622b --- /dev/null +++ b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/proposal.md @@ -0,0 +1,52 @@ +## Why + +The type filter UI (resource type selector, search parameter rows, add/remove +controls) is currently implemented inline within `ExportForm`. Moving it into +`ExportOptions` consolidates all export configuration fields in one reusable +component, reducing duplication if type filters are needed in other export +contexts (e.g. export-by-query) and simplifying `ExportForm`. + +## What Changes + +- Add type filter state and UI to `ExportOptions`, including the + `TypeFilterState` type, add/remove/update handlers, and the rendered card list + with `SearchParamsInput`. +- Extend `ExportOptionsValues` with a `typeFilters` field so type filter state + flows through the same `values`/`onChange` interface as other export options. +- Add `searchParams` prop to `ExportOptions` so it can populate search parameter + dropdowns per resource type. +- Remove all type filter state, handlers, and JSX from `ExportForm`, delegating + to `ExportOptions` instead. +- Update `ExportForm.handleSubmit` to read type filters from + `exportOptions.typeFilters` rather than separate state. +- Update `ImportPnpForm` to pass `searchParams` to `ExportOptions` and + serialise type filters from `exportOptions.typeFilters` into the + `ImportPnpRequest`. The hook and API layer already support `typeFilters`. +- Update tests to reflect the new component boundaries. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `export-type-filter-ui`: Type filter UI moves from `ExportForm` into + `ExportOptions`; all existing requirements remain unchanged but the component + boundary shifts. `ImportPnpForm` gains type filter support via + `ExportOptions`. + +## Impact + +- `ui/src/components/export/ExportOptions.tsx` - gains type filter UI and + `searchParams` prop. +- `ui/src/types/exportOptions.ts` - `ExportOptionsValues` gains `typeFilters` + field; `DEFAULT_EXPORT_OPTIONS` updated. +- `ui/src/components/export/ExportForm.tsx` - type filter state and UI removed; + reads from `ExportOptions` values. +- `ui/src/components/import/ImportPnpForm.tsx` - passes `searchParams` to + `ExportOptions`; `handleSubmit` serialises and includes type filters in + request. +- Existing tests in `ExportFormTypeFilters.test.tsx` and any ExportOptions tests + will need updating. diff --git a/openspec/changes/archive/2026-02-20-type-filters-in-export-options/specs/export-type-filter-ui/spec.md b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/specs/export-type-filter-ui/spec.md new file mode 100644 index 0000000000..7efce02f53 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/specs/export-type-filter-ui/spec.md @@ -0,0 +1,145 @@ +## MODIFIED Requirements + +### Requirement: Type filters section in export form + +The `ExportOptions` component SHALL include a "Type filters" section positioned +after the output format field. The section SHALL display a heading, help text +explaining that type filters restrict exported resources using FHIR search +parameters, and an "Add type filter" button. The `ExportOptions` component SHALL +accept an optional `searchParams` prop providing per-resource-type search +parameter metadata. + +#### Scenario: Initial export options has no type filter entries + +- **WHEN** the `ExportOptions` component is rendered with default values +- **THEN** the type filters section SHALL be displayed with a heading, help + text, and an "Add type filter" button, but no type filter entries + +#### Scenario: User adds a type filter entry + +- **WHEN** the user clicks the "Add type filter" button within `ExportOptions` +- **THEN** a new type filter entry SHALL be appended containing a resource type + dropdown (defaulting to no selection), a search parameters input with one + empty row, and a remove button +- **AND** the `onChange` callback SHALL be invoked with the updated + `ExportOptionsValues` including the new entry in `typeFilters` + +### Requirement: Type filter entry resource type selection + +Each type filter entry SHALL include a resource type dropdown populated with the +available resource types from the server capabilities. When the user selects a +resource type, the search parameters input for that entry SHALL update to show +the search parameters available for the selected type. + +#### Scenario: Resource type selection populates search parameters + +- **WHEN** the user selects "Observation" in a type filter entry's resource type + dropdown +- **THEN** the search parameters dropdown for that entry SHALL display the + search parameters declared for Observation in the CapabilityStatement + +#### Scenario: Changing resource type resets search parameter rows + +- **WHEN** the user has entered search parameter values for a type filter entry + and then changes the resource type +- **THEN** all search parameter rows for that entry SHALL be reset to a single + empty row + +### Requirement: Type filter entry search parameters + +Each type filter entry SHALL include a search parameters input that allows the +user to add parameter name-value rows. Each row SHALL have a parameter name +dropdown and a value text input. An "Add parameter" button SHALL allow adding +rows, and each row SHALL have a remove button (disabled when only one row +remains). + +#### Scenario: Add search parameter row within a type filter entry + +- **WHEN** the user clicks the "Add parameter" button within a type filter entry +- **THEN** a new parameter row SHALL be appended to that entry's search + parameters input + +#### Scenario: Remove search parameter row within a type filter entry + +- **WHEN** the user clicks the remove button on a parameter row within a type + filter entry and there is more than one row +- **THEN** that row SHALL be removed from the entry + +### Requirement: Remove type filter entry + +The user SHALL be able to remove any type filter entry by clicking its remove +button. + +#### Scenario: Remove a type filter entry + +- **WHEN** the user clicks the remove button on a type filter entry +- **THEN** that entry SHALL be removed from the type filters section +- **AND** the `onChange` callback SHALL be invoked with the updated + `ExportOptionsValues` excluding the removed entry + +### Requirement: Type filters included in export request + +When the user submits the export form, type filter entries with a selected +resource type and at least one non-empty search parameter row SHALL be serialised +into `_typeFilter` query parameters in the format +`ResourceType?param1=value1¶m2=value2` and included in the export API +request. Entries with no resource type selected or with all empty parameter rows +SHALL be excluded. + +#### Scenario: Export with a single type filter + +- **WHEN** the user adds a type filter entry with resource type "Patient" and + parameter "active" with value "true", and submits the export +- **THEN** the export API request SHALL include the query parameter + `_typeFilter=Patient?active=true` + +#### Scenario: Incomplete type filter entries are excluded + +- **WHEN** the user adds a type filter entry with no resource type selected and + submits the export +- **THEN** that entry SHALL be excluded from the export API request + +### Requirement: ExportOptionsValues extended with typeFilters + +The `ExportOptionsValues` interface SHALL include a `typeFilters` field of type +`TypeFilterState[]`. The `TypeFilterState` type SHALL be defined in +`exportOptions.ts` with fields `id` (string), `resourceType` (string), and +`rows` (SearchParamRowData[]). The `DEFAULT_EXPORT_OPTIONS` constant SHALL +include `typeFilters: []`. + +#### Scenario: Default export options include empty typeFilters + +- **WHEN** `DEFAULT_EXPORT_OPTIONS` is used as the initial value +- **THEN** the `typeFilters` field SHALL be an empty array + +### Requirement: ImportPnpForm passes type filters to import request + +The `ImportPnpForm` SHALL pass `searchParams` through to `ExportOptions` so that +type filter search parameter dropdowns are populated. When the form is submitted, +type filter entries from `exportOptions.typeFilters` SHALL be serialised into +`string[]` format and included in the `ImportPnpRequest.typeFilters` field. + +#### Scenario: Import PnP form submits with type filters + +- **WHEN** the user configures a type filter for "Patient" with parameter + "active" = "true" in the import PnP form and submits +- **THEN** the `ImportPnpRequest` SHALL include + `typeFilters: ["Patient?active=true"]` + +#### Scenario: Import PnP form without type filters + +- **WHEN** the user submits the import PnP form with no type filters configured +- **THEN** the `ImportPnpRequest` SHALL have `typeFilters` as `undefined` + +### Requirement: Remove raw typeFilters and showExtendedOptions from ExportOptions + +The `ExportOptions` component SHALL NOT render the raw type filters text field +or the include associated data text field. The `showExtendedOptions` prop SHALL +be removed. The `typeFilters` and `includeAssociatedData` fields SHALL be +removed from the `ExportOptionsValues` interface and `DEFAULT_EXPORT_OPTIONS`. + +#### Scenario: ExportOptions does not render type filters text field + +- **WHEN** the `ExportOptions` component is rendered +- **THEN** there SHALL be no text field for type filters or include associated + data diff --git a/openspec/changes/archive/2026-02-20-type-filters-in-export-options/tasks.md b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/tasks.md new file mode 100644 index 0000000000..7a91a5a74e --- /dev/null +++ b/openspec/changes/archive/2026-02-20-type-filters-in-export-options/tasks.md @@ -0,0 +1,30 @@ +## 1. Update types and defaults + +- [x] 1.1 Move `TypeFilterState` into `exportOptions.ts` and add `typeFilters: TypeFilterState[]` to `ExportOptionsValues` +- [x] 1.2 Update `DEFAULT_EXPORT_OPTIONS` to include `typeFilters: []` +- [x] 1.3 Move `serialiseTypeFilters` from `ExportForm.tsx` to `exportOptions.ts` + +## 2. Update ExportOptions component + +- [x] 2.1 Add `searchParams` optional prop to `ExportOptions` +- [x] 2.2 Add type filter add/remove/update handlers inside `ExportOptions` +- [x] 2.3 Render type filter section (heading, help text, add button, filter entry cards with `SearchParamsInput`) + +## 3. Simplify ExportForm + +- [x] 3.1 Remove `TypeFilterState` type, `typeFilters` state, and all type filter handlers from `ExportForm` +- [x] 3.2 Remove type filter JSX from `ExportForm` +- [x] 3.3 Pass `searchParams` prop through to `ExportOptions` +- [x] 3.4 Update `handleSubmit` to read type filters from `exportOptions.typeFilters` via imported `serialiseTypeFilters` + +## 4. Wire up ImportPnpForm + +- [x] 4.1 Add `searchParams` prop to `ImportPnpForm` and pass through to `ExportOptions` +- [x] 4.2 Update `ImportPnpForm.handleSubmit` to serialise `exportOptions.typeFilters` and include in `ImportPnpRequest` +- [x] 4.3 Update the parent that renders `ImportPnpForm` to pass `searchParams` + +## 5. Update tests + +- [x] 5.1 Update type filter tests to interact via `ExportOptions` rather than `ExportForm` internals +- [x] 5.2 Add test for ImportPnpForm submitting type filters +- [x] 5.3 Verify existing export form tests still pass diff --git a/openspec/specs/export-type-filter-ui/spec.md b/openspec/specs/export-type-filter-ui/spec.md index 2ece5f7fa3..1b4e9f5687 100644 --- a/openspec/specs/export-type-filter-ui/spec.md +++ b/openspec/specs/export-type-filter-ui/spec.md @@ -2,23 +2,27 @@ ### Requirement: Type filters section in export form -The export form SHALL include a "Type filters" section positioned between the -export options and the submit button. The section SHALL display a heading, help -text explaining that type filters restrict exported resources using FHIR search -parameters, and an "Add type filter" button. +The `ExportOptions` component SHALL include a "Type filters" section positioned +after the output format field. The section SHALL display a heading, help text +explaining that type filters restrict exported resources using FHIR search +parameters, and an "Add type filter" button. The `ExportOptions` component SHALL +accept an optional `searchParams` prop providing per-resource-type search +parameter metadata. -#### Scenario: Initial export form has no type filter entries +#### Scenario: Initial export options has no type filter entries -- **WHEN** the export form is first rendered -- **THEN** the type filters section SHALL be displayed with a heading, help text, - and an "Add type filter" button, but no type filter entries +- **WHEN** the `ExportOptions` component is rendered with default values +- **THEN** the type filters section SHALL be displayed with a heading, help + text, and an "Add type filter" button, but no type filter entries #### Scenario: User adds a type filter entry -- **WHEN** the user clicks the "Add type filter" button +- **WHEN** the user clicks the "Add type filter" button within `ExportOptions` - **THEN** a new type filter entry SHALL be appended containing a resource type - dropdown (defaulting to no selection), a search parameters input with one empty - row, and a remove button + dropdown (defaulting to no selection), a search parameters input with one + empty row, and a remove button +- **AND** the `onChange` callback SHALL be invoked with the updated + `ExportOptionsValues` including the new entry in `typeFilters` ### Requirement: Type filter entry resource type selection @@ -31,8 +35,8 @@ the search parameters available for the selected type. - **WHEN** the user selects "Observation" in a type filter entry's resource type dropdown -- **THEN** the search parameters dropdown for that entry SHALL display the search - parameters declared for Observation in the CapabilityStatement +- **THEN** the search parameters dropdown for that entry SHALL display the + search parameters declared for Observation in the CapabilityStatement #### Scenario: Changing resource type resets search parameter rows @@ -44,8 +48,7 @@ the search parameters available for the selected type. ### Requirement: Type filter entry search parameters Each type filter entry SHALL include a search parameters input that allows the -user to add parameter name-value rows, identical in behaviour to the search -parameters section on the Resources page. Each row SHALL have a parameter name +user to add parameter name-value rows. Each row SHALL have a parameter name dropdown and a value text input. An "Add parameter" button SHALL allow adding rows, and each row SHALL have a remove button (disabled when only one row remains). @@ -71,6 +74,8 @@ button. - **WHEN** the user clicks the remove button on a type filter entry - **THEN** that entry SHALL be removed from the type filters section +- **AND** the `onChange` callback SHALL be invoked with the updated + `ExportOptionsValues` excluding the removed entry ### Requirement: Type filters included in export request @@ -137,6 +142,38 @@ structured type filter data from the form to the export card. - **THEN** the `ExportRequest` object SHALL contain `typeFilters: [{ resourceType: "Patient", params: { active: ["true"] } }]` +### Requirement: ExportOptionsValues extended with typeFilters + +The `ExportOptionsValues` interface SHALL include a `typeFilters` field of type +`TypeFilterState[]`. The `TypeFilterState` type SHALL be defined in +`exportOptions.ts` with fields `id` (string), `resourceType` (string), and +`rows` (SearchParamRowData[]). The `DEFAULT_EXPORT_OPTIONS` constant SHALL +include `typeFilters: []`. + +#### Scenario: Default export options include empty typeFilters + +- **WHEN** `DEFAULT_EXPORT_OPTIONS` is used as the initial value +- **THEN** the `typeFilters` field SHALL be an empty array + +### Requirement: ImportPnpForm passes type filters to import request + +The `ImportPnpForm` SHALL pass `searchParams` through to `ExportOptions` so that +type filter search parameter dropdowns are populated. When the form is submitted, +type filter entries from `exportOptions.typeFilters` SHALL be serialised into +`string[]` format and included in the `ImportPnpRequest.typeFilters` field. + +#### Scenario: Import PnP form submits with type filters + +- **WHEN** the user configures a type filter for "Patient" with parameter + "active" = "true" in the import PnP form and submits +- **THEN** the `ImportPnpRequest` SHALL include + `typeFilters: ["Patient?active=true"]` + +#### Scenario: Import PnP form without type filters + +- **WHEN** the user submits the import PnP form with no type filters configured +- **THEN** the `ImportPnpRequest` SHALL have `typeFilters` as `undefined` + ### Requirement: BulkExportRequest and API support for typeFilters The `BulkExportRequest` interface SHALL include a `typeFilters` field of type @@ -156,8 +193,8 @@ in the query parameters sent to the server, supporting multiple values. The `ExportOptions` component SHALL NOT render the raw type filters text field or the include associated data text field. The `showExtendedOptions` prop SHALL -be removed. The `typeFilters` and `includeAssociatedData` fields SHALL be removed -from the `ExportOptionsValues` interface and `DEFAULT_EXPORT_OPTIONS`. +be removed. The `typeFilters` and `includeAssociatedData` fields SHALL be +removed from the `ExportOptionsValues` interface and `DEFAULT_EXPORT_OPTIONS`. #### Scenario: ExportOptions does not render type filters text field diff --git a/ui/src/components/export/ExportForm.tsx b/ui/src/components/export/ExportForm.tsx index f0b64e564f..02a3dc9bbc 100644 --- a/ui/src/components/export/ExportForm.tsx +++ b/ui/src/components/export/ExportForm.tsx @@ -21,28 +21,16 @@ * @author John Grimes */ -import { Cross2Icon, PlayIcon, PlusIcon } from "@radix-ui/react-icons"; -import { - Box, - Button, - Card, - Flex, - Heading, - IconButton, - Select, - Text, - TextField, -} from "@radix-ui/themes"; +import { PlayIcon } from "@radix-ui/react-icons"; +import { Box, Button, Card, Flex, Heading, Select, Text, TextField } from "@radix-ui/themes"; import { useState } from "react"; import { ExportOptions } from "./ExportOptions"; -import { DEFAULT_EXPORT_OPTIONS } from "../../types/exportOptions"; -import { SearchParamsInput, createEmptyRow } from "../SearchParamsInput"; +import { DEFAULT_EXPORT_OPTIONS, serialiseTypeFilterState } from "../../types/exportOptions"; import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; -import type { ExportLevel, ExportRequest, TypeFilterEntry } from "../../types/export"; +import type { ExportLevel, ExportRequest } from "../../types/export"; import type { ExportOptionsValues } from "../../types/exportOptions"; -import type { SearchParamRowData } from "../SearchParamsInput"; interface ExportFormProps { onSubmit: (request: ExportRequest) => void; @@ -51,13 +39,6 @@ interface ExportFormProps { searchParams?: Record; } -/** Internal state for a single type filter entry. */ -interface TypeFilterState { - id: string; - resourceType: string; - rows: SearchParamRowData[]; -} - const EXPORT_LEVELS: { value: ExportLevel; label: string }[] = [ { value: "system", label: "All data in system" }, { value: "all-patients", label: "All patient data" }, @@ -65,31 +46,6 @@ const EXPORT_LEVELS: { value: ExportLevel; label: string }[] = [ { value: "group", label: "Data for patients in group" }, ]; -/** - * Serialises type filter entries into the format expected by the ExportRequest. - * Entries with no resource type selected or no non-empty rows are excluded. - * - * @param entries - The internal type filter state entries. - * @returns Array of TypeFilterEntry objects, or undefined if empty. - */ -function serialiseTypeFilters(entries: TypeFilterState[]): TypeFilterEntry[] | undefined { - const result: TypeFilterEntry[] = []; - for (const entry of entries) { - if (!entry.resourceType) continue; - const params: Record = {}; - for (const row of entry.rows) { - if (row.paramName && row.value) { - if (!params[row.paramName]) { - params[row.paramName] = []; - } - params[row.paramName].push(row.value); - } - } - result.push({ resourceType: entry.resourceType, params }); - } - return result.length > 0 ? result : undefined; -} - /** * Form for configuring and starting a bulk data export. * @@ -104,30 +60,6 @@ export function ExportForm({ onSubmit, resourceTypes, searchParams }: Readonly(DEFAULT_EXPORT_OPTIONS); const [patientId, setPatientId] = useState(""); const [groupId, setGroupId] = useState(""); - const [typeFilters, setTypeFilters] = useState([]); - - const addTypeFilter = () => { - setTypeFilters((prev) => [ - ...prev, - { id: crypto.randomUUID(), resourceType: "", rows: [createEmptyRow()] }, - ]); - }; - - const removeTypeFilter = (id: string) => { - setTypeFilters((prev) => prev.filter((entry) => entry.id !== id)); - }; - - const updateTypeFilterResourceType = (id: string, resourceType: string) => { - setTypeFilters((prev) => - prev.map((entry) => - entry.id === id ? { ...entry, resourceType, rows: [createEmptyRow()] } : entry, - ), - ); - }; - - const updateTypeFilterRows = (id: string, rows: SearchParamRowData[]) => { - setTypeFilters((prev) => prev.map((entry) => (entry.id === id ? { ...entry, rows } : entry))); - }; const handleSubmit = () => { const request: ExportRequest = { @@ -139,7 +71,7 @@ export function ExportForm({ onSubmit, resourceTypes, searchParams }: Readonly - - - - Type filters - - - - {typeFilters.length > 0 ? ( - - {typeFilters.map((entry) => ( - - - - - updateTypeFilterResourceType(entry.id, value)} - > - - - {resourceTypes.map((rt) => ( - - {rt} - - ))} - - - - removeTypeFilter(entry.id)} - > - - - - {entry.resourceType && ( - updateTypeFilterRows(entry.id, rows)} - /> - )} - - - ))} - - ) : ( - - No type filters configured. Type filters allow filtering exported resources using FHIR - search parameters. - - )} - - + + {values.typeFilters.length > 0 ? ( + + {values.typeFilters.map((entry) => ( + + + + + updateTypeFilterResourceType(entry.id, value)} + > + + + {resourceTypes.map((rt) => ( + + {rt} + + ))} + + + + removeTypeFilter(entry.id)} + > + + + + {entry.resourceType && ( + updateTypeFilterRows(entry.id, rows)} + /> + )} + + + ))} + + ) : ( + + No type filters configured. Type filters allow filtering exported resources using FHIR + search parameters. + + )} + ); } diff --git a/ui/src/components/export/__tests__/ExportOptions.test.tsx b/ui/src/components/export/__tests__/ExportOptions.test.tsx index 52aba97884..5e9cb8ad4d 100644 --- a/ui/src/components/export/__tests__/ExportOptions.test.tsx +++ b/ui/src/components/export/__tests__/ExportOptions.test.tsx @@ -112,7 +112,32 @@ describe("ExportOptions", () => { expect(screen.queryByText(/output format/i)).not.toBeInTheDocument(); }); - it("does not render type filters or include associated data fields", () => { + it("renders the type filters section with add button", () => { + render( + , + ); + + expect(screen.getByText("Type filters")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add type filter/i })).toBeInTheDocument(); + }); + + it("shows empty state text when no type filters are configured", () => { + render( + , + ); + + expect(screen.getByText(/no type filters configured/i)).toBeInTheDocument(); + }); + + it("does not render include associated data field", () => { render( { />, ); - expect(screen.queryByText(/type filters/i)).not.toBeInTheDocument(); expect(screen.queryByText(/include associated data/i)).not.toBeInTheDocument(); }); }); diff --git a/ui/src/components/import/ImportPnpForm.tsx b/ui/src/components/import/ImportPnpForm.tsx index 5659e3a2c5..79280d7d90 100644 --- a/ui/src/components/import/ImportPnpForm.tsx +++ b/ui/src/components/import/ImportPnpForm.tsx @@ -27,10 +27,12 @@ import { Box, Button, Card, Flex, Heading, Select, Text, TextField } from "@radi import { useState } from "react"; import { SaveModeField } from "./SaveModeField"; -import { DEFAULT_EXPORT_OPTIONS } from "../../types/exportOptions"; +import { serialiseTypeFilters } from "../../types/export"; +import { DEFAULT_EXPORT_OPTIONS, serialiseTypeFilterState } from "../../types/exportOptions"; import { IMPORT_FORMATS } from "../../types/import"; import { ExportOptions } from "../export/ExportOptions"; +import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; import type { ExportOptionsValues } from "../../types/exportOptions"; import type { ImportFormat, SaveMode } from "../../types/import"; import type { ExportType, ImportPnpRequest } from "../../types/importPnp"; @@ -40,6 +42,8 @@ interface ImportPnpFormProps { isSubmitting: boolean; disabled: boolean; resourceTypes: string[]; + /** Search parameters per resource type from the CapabilityStatement. */ + searchParams?: Record; } /** @@ -50,6 +54,7 @@ interface ImportPnpFormProps { * @param root0.isSubmitting - Whether an import is in progress. * @param root0.disabled - Whether the form is disabled. * @param root0.resourceTypes - Available resource types for selection. + * @param root0.searchParams - Search parameters per resource type. * @returns The import PnP form component. */ export function ImportPnpForm({ @@ -57,6 +62,7 @@ export function ImportPnpForm({ isSubmitting, disabled, resourceTypes, + searchParams, }: Readonly) { const [exportUrl, setExportUrl] = useState(""); const [saveMode, setSaveMode] = useState("overwrite"); @@ -66,6 +72,10 @@ export function ImportPnpForm({ const exportType: ExportType = "dynamic"; const handleSubmit = () => { + // Serialise type filter state into TypeFilterEntry[], then into strings. + const typeFilterEntries = serialiseTypeFilterState(exportOptions.typeFilters); + const typeFilterStrings = serialiseTypeFilters(typeFilterEntries); + const request: ImportPnpRequest = { exportUrl, exportType, @@ -76,6 +86,7 @@ export function ImportPnpForm({ since: exportOptions.since || undefined, until: exportOptions.until || undefined, elements: exportOptions.elements || undefined, + typeFilters: typeFilterStrings, }; onSubmit(request); }; @@ -143,6 +154,7 @@ export function ImportPnpForm({ values={exportOptions} onChange={setExportOptions} hideOutputFormat + searchParams={searchParams} /> diff --git a/ui/src/components/import/__tests__/ImportPnpForm.test.tsx b/ui/src/components/import/__tests__/ImportPnpForm.test.tsx index a95fac6513..09ae81e902 100644 --- a/ui/src/components/import/__tests__/ImportPnpForm.test.tsx +++ b/ui/src/components/import/__tests__/ImportPnpForm.test.tsx @@ -33,6 +33,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "../../../test/testUtils"; import { ImportPnpForm } from "../ImportPnpForm"; +import type { SearchParamCapability } from "../../../hooks/useServerCapabilities"; import type { ImportPnpRequest } from "../../../types/importPnp"; describe("ImportPnpForm", () => { @@ -241,6 +242,77 @@ describe("ImportPnpForm", () => { const request = mockOnSubmit.mock.calls[0][0] as ImportPnpRequest; expect(request.types).toContain("Patient"); }); + + it("includes type filters in the request when configured", async () => { + const searchParams: Record = { + Patient: [ + { name: "gender", type: "token" }, + { name: "active", type: "token" }, + ], + Observation: [{ name: "status", type: "token" }], + }; + const user = userEvent.setup(); + render( + , + ); + + // Enter export URL. + const urlInput = screen.getByPlaceholderText(/https:\/\/example.org\/fhir\/\$export/i); + await user.type(urlInput, "https://server.example.org/fhir/$export"); + + // Expand export options. + const trigger = screen.getByText(/export options/i); + await user.click(trigger); + + // Add a type filter entry and select Patient. + await user.click(screen.getByRole("button", { name: /add type filter/i })); + // The first combobox is the input format selector; the type filter resource type + // selector is the next one. + const comboboxes = screen.getAllByRole("combobox"); + const resourceTypeCombobox = comboboxes[comboboxes.length - 1]; + await user.click(resourceTypeCombobox); + await user.click(screen.getByRole("option", { name: "Patient" })); + + // Select search parameter and enter value. + const paramComboboxes = screen.getAllByRole("combobox"); + const paramNameCombobox = paramComboboxes[paramComboboxes.length - 1]; + await user.click(paramNameCombobox); + await user.click(screen.getByRole("option", { name: /active/i })); + + const valueInput = screen.getByPlaceholderText(/e\.g\., male/i); + await user.type(valueInput, "true"); + + await user.click(screen.getByRole("button", { name: /start import/i })); + + const request = mockOnSubmit.mock.calls[0][0] as ImportPnpRequest; + expect(request.typeFilters).toEqual(["Patient?active=true"]); + }); + + it("submits without type filters when none are configured", async () => { + const user = userEvent.setup(); + render( + , + ); + + // Enter export URL and submit. + const urlInput = screen.getByPlaceholderText(/https:\/\/example.org\/fhir\/\$export/i); + await user.type(urlInput, "https://server.example.org/fhir/$export"); + await user.click(screen.getByRole("button", { name: /start import/i })); + + const request = mockOnSubmit.mock.calls[0][0] as ImportPnpRequest; + expect(request.typeFilters).toBeUndefined(); + }); }); describe("button disabled states", () => { diff --git a/ui/src/pages/Import.tsx b/ui/src/pages/Import.tsx index 593f1c20b2..337ea1051c 100644 --- a/ui/src/pages/Import.tsx +++ b/ui/src/pages/Import.tsx @@ -33,6 +33,7 @@ import { config } from "../config"; import { useAuth } from "../contexts/AuthContext"; import { useServerCapabilities } from "../hooks"; +import type { SearchParamCapability } from "../hooks/useServerCapabilities"; import type { ImportJob, ImportRequest } from "../types/import"; import type { ImportPnpRequest } from "../types/importPnp"; @@ -94,6 +95,15 @@ export function Import() { setImports((prev) => prev.filter((job) => job.id !== id)); }; + // Build search parameters mapping from capabilities. + let searchParams: Record | undefined; + if (capabilities?.resources) { + searchParams = {}; + for (const resource of capabilities.resources) { + searchParams[resource.type] = resource.searchParams; + } + } + // Show loading state while checking server capabilities. if (isLoadingCapabilities) { return ( @@ -138,6 +148,7 @@ export function Import() { isSubmitting={false} disabled={false} resourceTypes={capabilities?.resourceTypes ?? []} + searchParams={searchParams} /> diff --git a/ui/src/types/exportOptions.ts b/ui/src/types/exportOptions.ts index 6ebf2b00af..c02bda3dbc 100644 --- a/ui/src/types/exportOptions.ts +++ b/ui/src/types/exportOptions.ts @@ -21,6 +21,19 @@ * @author John Grimes */ +import type { TypeFilterEntry } from "./export"; +import type { SearchParamRowData } from "../components/SearchParamsInput"; + +/** Internal state for a single type filter entry. */ +export interface TypeFilterState { + /** Unique identifier for the entry. */ + id: string; + /** The selected resource type. */ + resourceType: string; + /** Search parameter rows for this entry. */ + rows: SearchParamRowData[]; +} + /** * Values for configuring bulk export options. */ @@ -35,6 +48,8 @@ export interface ExportOptionsValues { elements: string; /** Output format MIME type for the export. */ outputFormat: string; + /** Type filter entries for restricting exported resources. */ + typeFilters: TypeFilterState[]; } /** @@ -46,8 +61,36 @@ export const DEFAULT_EXPORT_OPTIONS: ExportOptionsValues = { until: "", elements: "", outputFormat: "", + typeFilters: [], }; +/** + * Serialises type filter state entries into the format expected by export + * requests. Entries with no resource type selected are excluded. + * + * @param entries - The type filter state entries. + * @returns Array of TypeFilterEntry objects, or undefined if empty. + */ +export function serialiseTypeFilterState( + entries: TypeFilterState[], +): TypeFilterEntry[] | undefined { + const result: TypeFilterEntry[] = []; + for (const entry of entries) { + if (!entry.resourceType) continue; + const params: Record = {}; + for (const row of entry.rows) { + if (row.paramName && row.value) { + if (!params[row.paramName]) { + params[row.paramName] = []; + } + params[row.paramName].push(row.value); + } + } + result.push({ resourceType: entry.resourceType, params }); + } + return result.length > 0 ? result : undefined; +} + /** * Output format options for bulk export. */ From 337bac4bf6c528463a27ae5908035b06808f269a Mon Sep 17 00:00:00 2001 From: John Grimes Date: Fri, 20 Feb 2026 09:07:50 +1000 Subject: [PATCH 04/18] refactor: Extract FieldGuidance component for form helper text Replace duplicated pattern across 7 form components with a shared FieldGuidance component. Accepts children and an optional mt prop (defaults to "1"). --- .../.openspec.yaml | 2 + .../design.md | 35 +++++++++++ .../proposal.md | 24 ++++++++ .../specs/field-guidance/spec.md | 42 +++++++++++++ .../tasks.md | 20 +++++++ openspec/specs/field-guidance/spec.md | 40 +++++++++++++ ui/src/components/FieldGuidance.tsx | 50 ++++++++++++++++ ui/src/components/SearchParamsInput.tsx | 6 +- .../__tests__/FieldGuidance.test.tsx | 59 +++++++++++++++++++ ui/src/components/export/ExportOptions.tsx | 21 +++---- .../components/export/ResourceTypePicker.tsx | 6 +- ui/src/components/import/ImportForm.tsx | 5 +- ui/src/components/import/ImportPnpForm.tsx | 5 +- .../resources/ResourceSearchForm.tsx | 5 +- ui/src/components/sqlOnFhir/SqlOnFhirForm.tsx | 9 +-- 15 files changed, 298 insertions(+), 31 deletions(-) create mode 100644 openspec/changes/archive/2026-02-20-extract-field-guidance-component/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-20-extract-field-guidance-component/design.md create mode 100644 openspec/changes/archive/2026-02-20-extract-field-guidance-component/proposal.md create mode 100644 openspec/changes/archive/2026-02-20-extract-field-guidance-component/specs/field-guidance/spec.md create mode 100644 openspec/changes/archive/2026-02-20-extract-field-guidance-component/tasks.md create mode 100644 openspec/specs/field-guidance/spec.md create mode 100644 ui/src/components/FieldGuidance.tsx create mode 100644 ui/src/components/__tests__/FieldGuidance.test.tsx diff --git a/openspec/changes/archive/2026-02-20-extract-field-guidance-component/.openspec.yaml b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/.openspec.yaml new file mode 100644 index 0000000000..d299748398 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-19 diff --git a/openspec/changes/archive/2026-02-20-extract-field-guidance-component/design.md b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/design.md new file mode 100644 index 0000000000..606e917d0e --- /dev/null +++ b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/design.md @@ -0,0 +1,35 @@ +## Context + +The Pathling UI uses a repeated pattern of `` for field guidance text across ~10 components. There is no shared abstraction; each site duplicates the same Radix UI props. + +## Goals / Non-goals + +**Goals:** + +- Eliminate duplication of field guidance text styling across all form components. +- Provide a single component that encodes the semantic intent of "field guidance". + +**Non-goals:** + +- Changing the visual appearance of guidance text. +- Handling validation messages or error states. +- Covering non-guidance uses of `` (e.g. column sub-labels in `ImportForm`, metadata labels in cards). + +## Decisions + +### Component API + +The component accepts `children` (the guidance text) and an optional `mt` prop (defaulting to `"1"`) to handle the few cases where spacing varies (e.g. `SqlOnFhirForm` uses `mt="2"`, empty-state text in `ExportOptions` uses no margin). + +**Rationale**: A minimal API avoids over-abstraction. The `mt` prop covers the only meaningful variation across use sites. + +### File location + +Place the component at `ui/src/components/FieldGuidance.tsx`, alongside other shared form-related components. + +**Rationale**: This is a general-purpose form component, not specific to any feature. The flat `components/` directory matches existing project structure. + +## Risks / Trade-offs + +- **Risk**: Some `` instances are not field guidance (card metadata, column labels). These must not be converted. + **Mitigation**: Only convert instances that semantically represent help text below form fields. diff --git a/openspec/changes/archive/2026-02-20-extract-field-guidance-component/proposal.md b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/proposal.md new file mode 100644 index 0000000000..086095d15c --- /dev/null +++ b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/proposal.md @@ -0,0 +1,24 @@ +## Why + +Field guidance text (small grey helper text below form fields) is repeated inline across ~10 components using the same `` pattern. Extracting a shared `FieldGuidance` component eliminates this duplication and provides a single point of control for styling and semantics. + +## What changes + +- Introduce a `FieldGuidance` component that renders field helper text with consistent styling. +- Replace all inline `` guidance text instances with the new component. + +## Capabilities + +### New capabilities + +- `field-guidance`: A reusable component for rendering field-level guidance text below form inputs. + +### Modified capabilities + +(None - this is a pure refactoring with no requirement changes.) + +## Impact + +- **Components affected**: `ExportOptions`, `SearchParamsInput`, `ResourceSearchForm`, `ImportForm`, `ImportPnpForm`, `SqlOnFhirForm`, `ResourceTypePicker`. +- **No API or behavioural changes** - purely visual/structural refactoring. +- **No new dependencies** - uses existing Radix UI `Text` component internally. diff --git a/openspec/changes/archive/2026-02-20-extract-field-guidance-component/specs/field-guidance/spec.md b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/specs/field-guidance/spec.md new file mode 100644 index 0000000000..7b61ace9a8 --- /dev/null +++ b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/specs/field-guidance/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: FieldGuidance component renders guidance text + +The system SHALL provide a `FieldGuidance` component that renders children as small, grey helper text using Radix UI's `Text` component with `size="1"` and `color="gray"`. + +#### Scenario: Default rendering + +- **WHEN** `FieldGuidance` is rendered with children `"Only resources updated after this time."` +- **THEN** the component SHALL render a `Text` element with `size="1"`, `color="gray"`, and `mt="1"` containing the provided text. + +#### Scenario: Custom margin top + +- **WHEN** `FieldGuidance` is rendered with `mt="2"` +- **THEN** the component SHALL render with `mt="2"` instead of the default `"1"`. + +#### Scenario: No margin top + +- **WHEN** `FieldGuidance` is rendered with `mt="0"` +- **THEN** the component SHALL render with no top margin. + +### Requirement: All field guidance text uses FieldGuidance component + +All inline field guidance text previously rendered as `` SHALL be replaced with the `FieldGuidance` component in the following locations: + +- `ExportOptions`: "Since" field, "Until" field, "Elements" field, "Output format" field, and type filters empty state. +- `SearchParamsInput`: search parameters syntax guidance. +- `ResourceSearchForm`: FHIRPath filter guidance. +- `ImportForm`: supported URL schemes guidance. +- `ImportPnpForm`: export URL guidance. +- `SqlOnFhirForm`: view definition selection guidance and custom JSON guidance. +- `ResourceTypePicker`: "leave empty to export all" hint. + +#### Scenario: ExportOptions guidance text + +- **WHEN** the export options form is rendered +- **THEN** each field guidance string SHALL be rendered using the `FieldGuidance` component with identical text content to the current implementation. + +#### Scenario: Visual output unchanged + +- **WHEN** any form containing field guidance is rendered after the refactoring +- **THEN** the visual appearance SHALL be identical to the previous inline implementation. diff --git a/openspec/changes/archive/2026-02-20-extract-field-guidance-component/tasks.md b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/tasks.md new file mode 100644 index 0000000000..0312b401ca --- /dev/null +++ b/openspec/changes/archive/2026-02-20-extract-field-guidance-component/tasks.md @@ -0,0 +1,20 @@ +## 1. Create FieldGuidance component + +- [x] 1.1 Write unit tests for FieldGuidance (default rendering, custom mt, no margin) +- [x] 1.2 Create `FieldGuidance.tsx` in `ui/src/components/` with props: `children`, optional `mt` (default `"1"`) +- [x] 1.3 Verify tests pass + +## 2. Replace inline guidance text + +- [x] 2.1 Replace guidance text in `ExportOptions.tsx` (5 instances) +- [x] 2.2 Replace guidance text in `SearchParamsInput.tsx` (1 instance) +- [x] 2.3 Replace guidance text in `ResourceSearchForm.tsx` (1 instance) +- [x] 2.4 Replace guidance text in `ImportForm.tsx` (1 instance) +- [x] 2.5 Replace guidance text in `ImportPnpForm.tsx` (1 instance) +- [x] 2.6 Replace guidance text in `SqlOnFhirForm.tsx` (2 instances) +- [x] 2.7 Replace guidance text in `ResourceTypePicker.tsx` (1 instance) + +## 3. Verify + +- [x] 3.1 Run lint and type-check +- [x] 3.2 Run existing tests to confirm no regressions diff --git a/openspec/specs/field-guidance/spec.md b/openspec/specs/field-guidance/spec.md new file mode 100644 index 0000000000..7521b46762 --- /dev/null +++ b/openspec/specs/field-guidance/spec.md @@ -0,0 +1,40 @@ +### Requirement: FieldGuidance component renders guidance text + +The system SHALL provide a `FieldGuidance` component that renders children as small, grey helper text using Radix UI's `Text` component with `size="1"` and `color="gray"`. + +#### Scenario: Default rendering + +- **WHEN** `FieldGuidance` is rendered with children `"Only resources updated after this time."` +- **THEN** the component SHALL render a `Text` element with `size="1"`, `color="gray"`, and `mt="1"` containing the provided text. + +#### Scenario: Custom margin top + +- **WHEN** `FieldGuidance` is rendered with `mt="2"` +- **THEN** the component SHALL render with `mt="2"` instead of the default `"1"`. + +#### Scenario: No margin top + +- **WHEN** `FieldGuidance` is rendered with `mt="0"` +- **THEN** the component SHALL render with no top margin. + +### Requirement: All field guidance text uses FieldGuidance component + +All inline field guidance text previously rendered as `` SHALL be replaced with the `FieldGuidance` component in the following locations: + +- `ExportOptions`: "Since" field, "Until" field, "Elements" field, "Output format" field, and type filters empty state. +- `SearchParamsInput`: search parameters syntax guidance. +- `ResourceSearchForm`: FHIRPath filter guidance. +- `ImportForm`: supported URL schemes guidance. +- `ImportPnpForm`: export URL guidance. +- `SqlOnFhirForm`: view definition selection guidance and custom JSON guidance. +- `ResourceTypePicker`: "leave empty to export all" hint. + +#### Scenario: ExportOptions guidance text + +- **WHEN** the export options form is rendered +- **THEN** each field guidance string SHALL be rendered using the `FieldGuidance` component with identical text content to the current implementation. + +#### Scenario: Visual output unchanged + +- **WHEN** any form containing field guidance is rendered after the refactoring +- **THEN** the visual appearance SHALL be identical to the previous inline implementation. diff --git a/ui/src/components/FieldGuidance.tsx b/ui/src/components/FieldGuidance.tsx new file mode 100644 index 0000000000..8d09221d96 --- /dev/null +++ b/ui/src/components/FieldGuidance.tsx @@ -0,0 +1,50 @@ +/* + * Copyright © 2018-2026 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Reusable field guidance component for rendering helper text below form + * fields. + * + * @author John Grimes + */ + +import { Text } from "@radix-ui/themes"; + +import type { ReactNode } from "react"; + +interface FieldGuidanceProps { + /** The guidance text to display. */ + children: ReactNode; + /** Top margin spacing. Defaults to "1". */ + mt?: "0" | "1" | "2"; +} + +/** + * Renders small, grey helper text below a form field. + * + * @param props - The component props. + * @param props.children - The guidance text to display. + * @param props.mt - Top margin spacing. Defaults to "1". + * @returns The field guidance text element. + */ +export function FieldGuidance({ children, mt = "1" }: Readonly) { + return ( + + {children} + + ); +} diff --git a/ui/src/components/SearchParamsInput.tsx b/ui/src/components/SearchParamsInput.tsx index 409bd4d436..0b51c332bf 100644 --- a/ui/src/components/SearchParamsInput.tsx +++ b/ui/src/components/SearchParamsInput.tsx @@ -24,6 +24,8 @@ import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; import { Badge, Box, Button, Flex, IconButton, Select, Text, TextField } from "@radix-ui/themes"; +import { FieldGuidance } from "./FieldGuidance"; + import type { SearchParamCapability } from "../hooks/useServerCapabilities"; /** @@ -157,9 +159,9 @@ export function SearchParamsInput({ ))} - + Search parameters use standard FHIR search syntax and are combined with AND logic. - + ); } diff --git a/ui/src/components/__tests__/FieldGuidance.test.tsx b/ui/src/components/__tests__/FieldGuidance.test.tsx new file mode 100644 index 0000000000..959fac421b --- /dev/null +++ b/ui/src/components/__tests__/FieldGuidance.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright © 2018-2026 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests for the FieldGuidance component which renders small grey helper text + * below form fields. + * + * @author John Grimes + */ + +import { describe, expect, it } from "vitest"; + +import { render, screen } from "../../test/testUtils"; +import { FieldGuidance } from "../FieldGuidance"; + +describe("FieldGuidance", () => { + it("renders children text", () => { + render(Only resources updated after this time.); + + expect(screen.getByText("Only resources updated after this time.")).toBeInTheDocument(); + }); + + it("renders with default mt of 1", () => { + render(Some guidance text.); + + // Radix UI Text renders with rt-r-mt-* classes for margin top. + const textElement = screen.getByText("Some guidance text."); + expect(textElement).toHaveClass("rt-r-mt-1"); + }); + + it("renders with custom mt value", () => { + render(Some guidance text.); + + const textElement = screen.getByText("Some guidance text."); + expect(textElement).toHaveClass("rt-r-mt-2"); + }); + + it("renders with mt 0 when no margin is needed", () => { + render(Some guidance text.); + + const textElement = screen.getByText("Some guidance text."); + expect(textElement).not.toHaveClass("rt-r-mt-1"); + expect(textElement).not.toHaveClass("rt-r-mt-2"); + }); +}); diff --git a/ui/src/components/export/ExportOptions.tsx b/ui/src/components/export/ExportOptions.tsx index b10860db01..cbb769c602 100644 --- a/ui/src/components/export/ExportOptions.tsx +++ b/ui/src/components/export/ExportOptions.tsx @@ -26,6 +26,7 @@ import { Box, Button, Card, Flex, IconButton, Select, Text, TextField } from "@r import { ResourceTypePicker } from "./ResourceTypePicker"; import { OUTPUT_FORMATS } from "../../types/exportOptions"; +import { FieldGuidance } from "../FieldGuidance"; import { SearchParamsInput, createEmptyRow } from "../SearchParamsInput"; import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; @@ -128,9 +129,7 @@ export function ExportOptions({ value={values.since} onChange={(e) => updateOption("since", e.target.value)} /> - - Only resources updated after this time. - + Only resources updated after this time. @@ -141,9 +140,7 @@ export function ExportOptions({ value={values.until} onChange={(e) => updateOption("until", e.target.value)} /> - - Only resources updated before this time. - + Only resources updated before this time. @@ -156,9 +153,7 @@ export function ExportOptions({ value={values.elements} onChange={(e) => updateOption("elements", e.target.value)} /> - - Comma-separated list of element names to include. - + Comma-separated list of element names to include. {!hideOutputFormat && ( @@ -179,9 +174,7 @@ export function ExportOptions({ ))} - - Output format for the export data. - + Output format for the export data. )} @@ -241,10 +234,10 @@ export function ExportOptions({ ))} ) : ( - + No type filters configured. Type filters allow filtering exported resources using FHIR search parameters. - + )} diff --git a/ui/src/components/export/ResourceTypePicker.tsx b/ui/src/components/export/ResourceTypePicker.tsx index a924e146ac..1b995e8aa2 100644 --- a/ui/src/components/export/ResourceTypePicker.tsx +++ b/ui/src/components/export/ResourceTypePicker.tsx @@ -23,6 +23,8 @@ import { Box, CheckboxCards, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { FieldGuidance } from "../FieldGuidance"; + interface ResourceTypePickerProps { resourceTypes: string[]; selectedTypes: string[]; @@ -54,9 +56,7 @@ export function ResourceTypePicker({ Resource types - - (leave empty to export all) - + (leave empty to export all) Clear diff --git a/ui/src/components/import/ImportForm.tsx b/ui/src/components/import/ImportForm.tsx index 075de861d2..336291095e 100644 --- a/ui/src/components/import/ImportForm.tsx +++ b/ui/src/components/import/ImportForm.tsx @@ -37,6 +37,7 @@ import { useRef, useState } from "react"; import { SaveModeField } from "./SaveModeField"; import { IMPORT_FORMATS } from "../../types/import"; +import { FieldGuidance } from "../FieldGuidance"; import type { ImportFormat, ImportRequest, SaveMode } from "../../types/import"; @@ -179,9 +180,7 @@ export function ImportForm({ onSubmit, isSubmitting, disabled, resourceTypes }: ))} - - Supported URL schemes: s3a://, hdfs://, file:// - + Supported URL schemes: s3a://, hdfs://, file:// diff --git a/ui/src/components/import/ImportPnpForm.tsx b/ui/src/components/import/ImportPnpForm.tsx index 79280d7d90..2b38c09ae7 100644 --- a/ui/src/components/import/ImportPnpForm.tsx +++ b/ui/src/components/import/ImportPnpForm.tsx @@ -31,6 +31,7 @@ import { serialiseTypeFilters } from "../../types/export"; import { DEFAULT_EXPORT_OPTIONS, serialiseTypeFilterState } from "../../types/exportOptions"; import { IMPORT_FORMATS } from "../../types/import"; import { ExportOptions } from "../export/ExportOptions"; +import { FieldGuidance } from "../FieldGuidance"; import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; import type { ExportOptionsValues } from "../../types/exportOptions"; @@ -130,9 +131,7 @@ export function ImportPnpForm({ value={exportUrl} onChange={(e) => setExportUrl(e.target.value)} /> - - The bulk export endpoint URL of the remote FHIR server. - + The bulk export endpoint URL of the remote FHIR server. diff --git a/ui/src/components/resources/ResourceSearchForm.tsx b/ui/src/components/resources/ResourceSearchForm.tsx index a09898d41a..16b1330cd6 100644 --- a/ui/src/components/resources/ResourceSearchForm.tsx +++ b/ui/src/components/resources/ResourceSearchForm.tsx @@ -35,6 +35,7 @@ import { } from "@radix-ui/themes"; import { useRef, useState } from "react"; +import { FieldGuidance } from "../FieldGuidance"; import { SearchParamsInput, createEmptyRow } from "../SearchParamsInput"; import type { SearchParamCapability } from "../../hooks/useServerCapabilities"; @@ -199,9 +200,9 @@ export function ResourceSearchForm({ ))} - + Filter expressions are combined with AND logic. Leave empty to return all resources. - +