diff --git a/docs/how-tos/prepopulate-template-parameters-from-url.md b/docs/how-tos/prepopulate-template-parameters-from-url.md new file mode 100644 index 000000000..84137338a --- /dev/null +++ b/docs/how-tos/prepopulate-template-parameters-from-url.md @@ -0,0 +1,200 @@ +# Pre-populate template parameters from a URL + +Workflow template forms can be pre-populated using URL query parameters. This is useful for bookmarking common configurations, sharing pre-configured templates, integrating with external systems, and reproducing submissions without entering the same values manually. + +Users can review and edit all pre-populated values before submitting the workflow. + +## Simple parameters + +Strings, numbers, booleans, and enum values can be added directly to the URL. + +For example: + +```text +/templates/xrf-tomography?outputFolder=testing&alignmentBand=15&normalise=true +``` + +The first parameter follows `?`. Add further parameters using `&`. + +```text +?outputFolder=testing&alignmentBand=15&normalise=true +``` + +Parameter names and enum values are case-sensitive and must match the template parameter schema. For the XRF tomography template, examples include: + +```text +elementToAlign=H +transitionToAlign=Ka +alignmentSection=top +``` + +## Array and object parameters + +Array and object parameters must be supplied as valid JSON. The JSON must be URL-encoded before being added to the URL. + +### Multiple edges + +The decoded JSON value for two edges is: + +```json +[ + { + "edgeElement": "Tl", + "edgeTransition": "La" + }, + { + "edgeElement": "Ga", + "edgeTransition": "Ka" + } +] +``` + +The URL-encoded value can be used as follows: + +```text +/templates/xrf-tomography?multiEdge=%5B%7B%22edgeElement%22%3A%22Tl%22%2C%22edgeTransition%22%3A%22La%22%7D%2C%7B%22edgeElement%22%3A%22Ga%22%2C%22edgeTransition%22%3A%22Ka%22%7D%5D +``` + +### Multiple scans + +The decoded JSON value for two scan ranges is: + +```json +[ + { + "multiScan": { + "start": 436147, + "end": 436230, + "excluded": [] + } + }, + { + "multiScan": { + "start": 436300, + "end": 436350, + "excluded": [] + } + } +] +``` + +The URL-encoded value can be used as follows: + +```text +/templates/xrf-tomography?multiScan=%5B%7B%22multiScan%22%3A%7B%22start%22%3A436147%2C%22end%22%3A436230%2C%22excluded%22%3A%5B%5D%7D%7D%2C%7B%22multiScan%22%3A%7B%22start%22%3A436300%2C%22end%22%3A436350%2C%22excluded%22%3A%5B%5D%7D%7D%5D +``` + +## Single-item arrays + +A parameter whose schema type is `array` must still use a JSON array when it contains only one item. + +Correct: + +```json +[ + { + "edgeElement": "Tl", + "edgeTransition": "La" + } +] +``` + +Incorrect: + +```json +{ + "edgeElement": "Tl", + "edgeTransition": "La" +} +``` + +The first value is an array containing one object. The second value is an object and is rejected when the schema expects an array. + +## Generate a URL with Python + +The following script builds a URL and performs the required JSON and URL encoding: + +```python +import json +import urllib.parse + +BASE_URL = "https://workflows.diamond.ac.uk/templates/xrf-tomography" + + +def encode_param(value): + return urllib.parse.quote( + json.dumps(value, separators=(",", ":")) + ) + + +multi_edge = [ + { + "edgeElement": "Tl", + "edgeTransition": "La", + }, + { + "edgeElement": "Ga", + "edgeTransition": "Ka", + }, +] + +multi_scan = [ + { + "multiScan": { + "start": 436147, + "end": 436230, + "excluded": [], + }, + }, + { + "multiScan": { + "start": 436300, + "end": 436350, + "excluded": [], + }, + }, +] + +parameters = { + "outputFolder": "testing", + "elementToAlign": "H", + "transitionToAlign": "Ka", + "alignmentSection": "top", + "alignmentBand": "15", + "normalise": "true", + "stacking": "false", + "multiEdge": encode_param(multi_edge), + "multiScan": encode_param(multi_scan), +} + +query = "&".join( + f"{key}={value}" + for key, value in parameters.items() +) + +print(f"{BASE_URL}?{query}") +``` + +Run the script with: + +```bash +python build_url.py +``` + +Copy the generated URL into a browser to open the pre-populated template form. + +## Validation and error handling + +- URL parameters override values reused from an existing workflow. +- Parameters that are not present in the template schema are ignored. +- Invalid JSON values for array or object parameters are ignored. +- An object is rejected when the schema expects an array. +- An array is rejected when the schema expects an object. +- Both single-item and multi-item arrays are supported. +- Template schema validation still applies after the form is populated. +- Enum values must exactly match an allowed schema value. +- Pre-populated values remain editable before submission. + +## Security consideration + +Query parameters can appear in browser history, bookmarks, server logs, and shared links. Do not include passwords, access tokens, secrets, or other sensitive values in a template URL. diff --git a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx index 93c3c4b3e..a41ab4d7a 100644 --- a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx +++ b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx @@ -52,21 +52,24 @@ const SubmissionForm = ({ workflowId?: string; }) => { const data = useFragment(SubmissionFormFragment, template); + const repositoryUrl = data.repository ?? templateSourceToLink(data.templateSource); const reusedParameterData = useFragment( SubmissionFormParametersFragment, prepopulatedParameters, ); + const [searchParams] = useSearchParams(); + const parametersSchema = data.arguments as JsonSchema; + const autofilledParameters = mergeParameters( reusedParameterData, searchParams, + parametersSchema, ); - const parametersSchema = data.arguments as JsonSchema; - const overriddenKeys = Array.from(new Set(searchParams.keys())).filter( (key) => { return parametersSchema.properties && key in parametersSchema.properties; diff --git a/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts index 95226850a..8534b7451 100644 --- a/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts +++ b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts @@ -10,6 +10,7 @@ import { } from "../graphql/__generated__/WorkflowTasksFragment.graphql"; import { JSONObject } from "workflows-lib"; import { SubmissionFormParametersFragment$data } from "../components/__generated__/SubmissionFormParametersFragment.graphql"; +import { JsonSchema } from "@jsonforms/core"; export function updateSearchParamsWithTaskIds( updatedTaskIds: string[], @@ -95,8 +96,56 @@ export function useFetchedTasks( export function mergeParameters( reusedParameterData: SubmissionFormParametersFragment$data | null | undefined, searchParams: URLSearchParams, + parametersSchema?: JsonSchema, ) { - const searchParameterData = Object.fromEntries(searchParams.entries()); + const searchParameterData: JSONObject = {}; + if (!parametersSchema) { + return { + ...reusedParameterData?.parameters, + ...Object.fromEntries(searchParams.entries()), + } as JSONObject; + } + + for (const [key, value] of searchParams.entries()) { + const propertySchema = parametersSchema.properties?.[key]; + + // Ignore URL parameters that do not exist in the template schema. + if (!propertySchema || typeof propertySchema === "boolean") { + continue; + } + + if (propertySchema.type === "array" || propertySchema.type === "object") { + try { + const parsedValue: unknown = JSON.parse(value); + + const hasExpectedType = + (propertySchema.type === "array" && Array.isArray(parsedValue)) || + (propertySchema.type === "object" && + parsedValue !== null && + typeof parsedValue === "object" && + !Array.isArray(parsedValue)); + + if (!hasExpectedType) { + console.warn( + `Ignoring URL parameter "${key}": expected ${propertySchema.type}.`, + ); + continue; + } + + // Argo workflow parameters are passed as strings. + searchParameterData[key] = parsedValue as JSONObject[keyof JSONObject]; + } catch { + console.warn( + `Ignoring URL parameter "${key}": value is not valid JSON.`, + ); + } + + continue; + } + // Preserve existing behaviour for flat parameters. + searchParameterData[key] = value; + } + return { ...reusedParameterData?.parameters, ...searchParameterData, diff --git a/frontend/relay-workflows-lib/tests/utils/workflowRelayUtils.test.tsx b/frontend/relay-workflows-lib/tests/utils/workflowRelayUtils.test.tsx index fd0ba8521..c0820a640 100644 --- a/frontend/relay-workflows-lib/tests/utils/workflowRelayUtils.test.tsx +++ b/frontend/relay-workflows-lib/tests/utils/workflowRelayUtils.test.tsx @@ -23,6 +23,8 @@ import { Suspense } from "react"; import { SubmissionFormParametersFragment$data } from "../../lib/components/__generated__/SubmissionFormParametersFragment.graphql"; import e02Mib2xRetriggerResponse from "../mocks/responses/templates/e02Mib2xRetriggerResponse.json"; +import { JsonSchema } from "@jsonforms/core"; + beforeAll(() => { server.listen(); }); @@ -144,3 +146,101 @@ test("mergeParameters", () => { }), ); }); + +test("mergeParameters supports a single-item array", () => { + const schema: JsonSchema = { + type: "object", + properties: { + multiEdge: { + type: "array", + }, + }, + }; + + const multiEdge = [ + { + edgeElement: "Tl", + edgeTransition: "La", + }, + ]; + + const searchParams = new URLSearchParams({ + multiEdge: JSON.stringify(multiEdge), + }); + + expect(mergeParameters(undefined, searchParams, schema)).toEqual({ + multiEdge, + }); +}); + +test("mergeParameters supports a multi-item array", () => { + const schema: JsonSchema = { + type: "object", + properties: { + multiEdge: { + type: "array", + }, + }, + }; + + const multiEdge = [ + { + edgeElement: "Tl", + edgeTransition: "La", + }, + { + edgeElement: "Ga", + edgeTransition: "Ka", + }, + ]; + + const searchParams = new URLSearchParams({ + multiEdge: JSON.stringify(multiEdge), + }); + + expect(mergeParameters(undefined, searchParams, schema)).toEqual({ + multiEdge, + }); +}); + +test("mergeParameters ignores unknown schema parameters", () => { + const schema: JsonSchema = { + type: "object", + properties: { + outputFolder: { + type: "string", + }, + }, + }; + + const searchParams = new URLSearchParams({ + unknownParameter: "test", + }); + + expect(mergeParameters(undefined, searchParams, schema)).toEqual({}); +}); + +test("mergeParameters allows URL values to override reused values", () => { + const schema: JsonSchema = { + type: "object", + properties: { + outputFolder: { + type: "string", + }, + }, + }; + + const reusedData = { + parameters: { + outputFolder: "old-value", + }, + } as SubmissionFormParametersFragment$data; + + const searchParams = new URLSearchParams({ + outputFolder: "new-value", + }); + + expect(mergeParameters(reusedData, searchParams, schema)).toEqual({ + outputFolder: "new-value", + }); +}); diff --git a/frontend/workflows-lib/lib/components/template/SubmissionForm.tsx b/frontend/workflows-lib/lib/components/template/SubmissionForm.tsx index f1157ecb4..1f29d50bd 100644 --- a/frontend/workflows-lib/lib/components/template/SubmissionForm.tsx +++ b/frontend/workflows-lib/lib/components/template/SubmissionForm.tsx @@ -2,6 +2,7 @@ import { materialCells } from "@jsonforms/material-renderers"; import { JsonSchema, UISchemaElement, createAjv } from "@jsonforms/core"; import { JsonForms } from "@jsonforms/react"; import React, { useState } from "react"; + import { Box, Divider, @@ -44,7 +45,9 @@ const TemplateSubmissionForm: React.FC = ({ const theme = useTheme(); const validator = createAjv({ useDefaults: true, coerceTypes: true }); - const [parameters, setParameters] = useState(prepopulatedParameters ?? {}); + const [parameters, setParameters] = useState( + prepopulatedParameters ?? {}, + ); const [errors, setErrors] = useState([]); const [submitted, setSubmitted] = useState(false);