Compare commits

...
36 changed files with 4704 additions and 129 deletions
@@ -115,6 +115,7 @@ public class ExternalApiCallController {
@RequestParam(value = "headers", required = false) String headers,
@RequestParam(value = "includeContext", defaultValue = "false") boolean includeContext,
@RequestParam(value = "includeFile", defaultValue = "true") boolean includeFile,
@RequestParam(value = "maxRequestBytes", defaultValue = "0") long maxRequestBytes,
@RequestHeader(value = InternalApiClient.POLICY_NAME_HEADER, required = false)
String policyName,
@RequestHeader(value = AutomationRunContext.RUN_ID_HEADER, required = false)
@@ -138,6 +139,17 @@ public class ExternalApiCallController {
: fileInput.getContentType();
byte[] content = fileInput.getBytes();
// Some destinations cap uploads (Discord's varies with Nitro tier), so the operator
// sets the limit; we fail clearly here rather than on an opaque vendor rejection.
if (maxRequestBytes > 0 && content.length > maxRequestBytes) {
throw new IllegalArgumentException(
"The document is "
+ megabytes(content.length)
+ " MB, over the "
+ megabytes(maxRequestBytes)
+ " MB limit set for this step.");
}
ObjectNode context =
DocumentContext.build(fileInput, content, policyName, runId, objectMapper);
@@ -545,6 +557,11 @@ public class ExternalApiCallController {
: oneLine.substring(0, MAX_REPORT_BODY_CHARS) + "";
}
/** Bytes as MB to one decimal, for a size message an operator reads in the units they set. */
private static String megabytes(long bytes) {
return String.format(Locale.ROOT, "%.1f", bytes / (1024.0 * 1024.0));
}
private static String safeFileName(String originalFilename) {
String name = Filenames.toSimpleFileName(originalFilename);
return (name == null || name.isBlank()) ? "document" : name;
@@ -21,6 +21,8 @@ import tools.jackson.databind.node.StringNode;
*/
final class Placeholders {
private static final int MAX_TREE_DEPTH = 64;
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*}}");
/** How a resolved value is escaped for the position it lands in. */
@@ -68,15 +70,24 @@ final class Placeholders {
* documents[0].data} as readily as a flat field - without a connector per vendor.
*/
static JsonNode resolveTree(JsonNode node, JsonNode context) {
return resolveTree(node, context, 0);
}
private static JsonNode resolveTree(JsonNode node, JsonNode context, int depth) {
// Deeply nested JSON is left untouched rather than recursed into, so a
// pathological template cannot overflow the stack.
if (depth > MAX_TREE_DEPTH) {
return node;
}
if (node instanceof ObjectNode object) {
for (String name : new java.util.ArrayList<>(object.propertyNames())) {
object.set(name, resolveTree(object.get(name), context));
object.set(name, resolveTree(object.get(name), context, depth + 1));
}
return object;
}
if (node instanceof ArrayNode array) {
for (int i = 0; i < array.size(); i++) {
array.set(i, resolveTree(array.get(i), context));
array.set(i, resolveTree(array.get(i), context, depth + 1));
}
return array;
}
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.service.AiToolResponseHeaders;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Runs an ordered chain of tool steps, feeding each step's output files into the next.
@@ -49,6 +51,10 @@ public class PolicyExecutor {
private static final String FILTER_OPERATION_PREFIX = "/api/v1/filter/filter-";
// Nested list parameters are walked recursively; cap the depth so a pathological
// pipeline cannot overflow the stack.
private static final int MAX_PARAMETER_DEPTH = 32;
private final InternalApiClient internalApiClient;
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
@@ -89,6 +95,10 @@ public class PolicyExecutor {
// Last non-null report wins: the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
// Every step's report, keyed by 1-based position, so a later step can reference an earlier
// one's response via {{steps.N...}} - e.g. post the share link an upload step returned.
ObjectNode runContext = objectMapper.createObjectNode();
ObjectNode stepReports = runContext.putObject("steps");
for (int i = 0; i < steps.size(); i++) {
PipelineStep step = steps.get(i);
@@ -98,13 +108,17 @@ public class PolicyExecutor {
"Pipeline step " + (i + 1) + " has no operation");
}
listener.onStepStart(i + 1, steps.size(), operation);
// Fill in references to earlier steps' outputs before dispatch; document- and run-scope
// placeholders are left for the tool to resolve per document.
PipelineStep resolved = resolveStepReferences(step, runContext);
StepOutput stepResult =
executeStep(step, currentFiles, currentOrigins, supportingFiles);
executeStep(resolved, currentFiles, currentOrigins, supportingFiles);
currentFiles = stepResult.files();
currentOrigins = stepResult.origins();
if (stepResult.report() != null) {
lastReport = stepResult.report();
lastReportTool = operation;
stepReports.set(String.valueOf(i + 1), stepResult.report());
}
listener.onStepComplete(i + 1, steps.size(), operation);
}
@@ -163,6 +177,92 @@ public class PolicyExecutor {
return new StepOutput(files, origins, report);
}
/**
* Resolve {@code {{steps.N...}}} references in a step's string parameters against the reports
* earlier steps produced. Returns the step unchanged when it references nothing, so a pipeline
* that uses no cross-step values pays nothing and behaves exactly as before.
*/
private PipelineStep resolveStepReferences(PipelineStep step, JsonNode runContext) {
boolean any = step.parameters().values().stream().anyMatch(this::referencesStep);
if (!any) {
return step;
}
Map<String, Object> resolved = new LinkedHashMap<>();
step.parameters()
.forEach((key, value) -> resolved.put(key, resolveValue(value, runContext)));
return new PipelineStep(step.operation(), resolved, step.fileParameters());
}
private boolean referencesStep(Object value) {
return referencesStep(value, 0);
}
private boolean referencesStep(Object value, int depth) {
if (depth > MAX_PARAMETER_DEPTH) {
return false;
}
if (value instanceof String s) {
return StepOutputPlaceholders.references(s);
}
if (value instanceof List<?> list) {
return list.stream().anyMatch(item -> referencesStep(item, depth + 1));
}
return false;
}
private Object resolveValue(Object value, JsonNode runContext) {
return resolveValue(value, runContext, 0);
}
private Object resolveValue(Object value, JsonNode runContext, int depth) {
if (depth > MAX_PARAMETER_DEPTH) {
return value;
}
if (value instanceof String s) {
return resolveString(s, runContext);
}
if (value instanceof List<?> list) {
List<Object> out = new ArrayList<>(list.size());
for (Object item : list) {
out.add(resolveValue(item, runContext, depth + 1));
}
return out;
}
return value;
}
/**
* A JSON-shaped parameter (bodyTemplate, fields, headers) is resolved inside its parsed tree,
* so an earlier step's response can only ever become a value in it. String-level substitution
* would let a response like {@code x", "admin": true, "y": "} inject fields into the JSON the
* operator wrote; a plain-text parameter keeps the plain substitution.
*/
private String resolveString(String value, JsonNode runContext) {
if (!StepOutputPlaceholders.references(value)) {
return value;
}
JsonNode tree = parseJsonContainer(value);
if (tree == null) {
return StepOutputPlaceholders.resolve(value, runContext);
}
return objectMapper.writeValueAsString(
StepOutputPlaceholders.resolveTree(tree, runContext));
}
/** The value parsed as a JSON object or array, or null when it is anything else. */
private JsonNode parseJsonContainer(String value) {
String trimmed = value.trim();
if (trimmed.isEmpty() || (trimmed.charAt(0) != '{' && trimmed.charAt(0) != '[')) {
return null;
}
try {
JsonNode node = objectMapper.readTree(value);
return (node.isObject() || node.isArray()) ? node : null;
} catch (JacksonException e) {
return null;
}
}
/**
* Call an endpoint, returning result files and optional report. Response handling: JSON body is
* the report with no file; a file body returns the file plus any {@link
@@ -0,0 +1,111 @@
package stirling.software.proprietary.policy.engine;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.node.StringNode;
/**
* Substitutes {@code {{steps.N...}}} references in a step's parameters against the reports the
* earlier steps of the same run produced.
*
* <p>This is pipeline-scope resolution, and it lives here rather than in the tool because only the
* executor can see the steps before the current one. Document- and run-scope placeholders ({@code
* {{document.*}}}, {@code {{run.*}}}) are deliberately left untouched for the tool to resolve per
* document; only {@code steps.*} is touched here, so the two passes never collide and neither has
* to know the other's namespace.
*
* <p>Deliberately not a template language, matching the document-scope resolver: dotted lookup and
* nothing else. A reference that names a step or field with no value fails the run rather than
* sending an empty value, so a typo or a forward reference surfaces as an error.
*/
final class StepOutputPlaceholders {
private static final int MAX_TREE_DEPTH = 64;
// Only steps.* is matched; a document/run reference is left verbatim for the downstream tool.
private static final Pattern STEP_REF = Pattern.compile("\\{\\{\\s*(steps\\.[\\w.]+?)\\s*}}");
private StepOutputPlaceholders() {}
/** Whether the text references an earlier step at all, so callers can skip resolving. */
static boolean references(String text) {
return text != null && STEP_REF.matcher(text).find();
}
/**
* @param template text that may contain {@code {{steps...}}} references; null passes through
* @param context the run context whose {@code steps} object is keyed by 1-based step number
* @throws IllegalArgumentException if a reference names a step or field the context does not
* hold, or names a non-scalar, so it cannot be inlined into a string parameter
*/
static String resolve(String template, JsonNode context) {
if (template == null || template.isEmpty()) {
return template;
}
Matcher matcher = STEP_REF.matcher(template);
StringBuilder out = new StringBuilder();
while (matcher.find()) {
String path = matcher.group(1);
JsonNode value = lookup(context, path);
if (value == null || value.isMissingNode() || !value.isValueNode()) {
throw new IllegalArgumentException(
"step reference '{{"
+ path
+ "}}' resolved to nothing; an earlier step must have produced that"
+ " value (a later step cannot be referenced, and only scalar"
+ " values can be inlined)");
}
matcher.appendReplacement(out, Matcher.quoteReplacement(value.asString()));
}
matcher.appendTail(out);
return out.toString();
}
/**
* Resolve every string inside a parsed JSON tree, leaving structure and non-strings alone.
* Mirrors the document-scope resolver: a substituted value lands in a text node and is escaped
* on serialise, so a response can never inject fields into the JSON the operator wrote.
*/
static JsonNode resolveTree(JsonNode node, JsonNode context) {
return resolveTree(node, context, 0);
}
private static JsonNode resolveTree(JsonNode node, JsonNode context, int depth) {
// Deeply nested JSON is left untouched rather than recursed into, so a
// pathological template cannot overflow the stack.
if (depth > MAX_TREE_DEPTH) {
return node;
}
if (node instanceof ObjectNode object) {
for (String name : new java.util.ArrayList<>(object.propertyNames())) {
object.set(name, resolveTree(object.get(name), context, depth + 1));
}
return object;
}
if (node instanceof ArrayNode array) {
for (int i = 0; i < array.size(); i++) {
array.set(i, resolveTree(array.get(i), context, depth + 1));
}
return array;
}
if (node != null && node.isString()) {
return StringNode.valueOf(resolve(node.asString(), context));
}
return node;
}
private static JsonNode lookup(JsonNode context, String path) {
JsonNode node = context;
for (String segment : path.split("\\.")) {
if (node == null || !node.isObject()) {
return null;
}
node = node.get(segment);
}
return node;
}
}
@@ -298,6 +298,7 @@ class ExternalApiCallControllerLiveTest {
private String headers;
private boolean includeContext;
private boolean includeFile = true;
private long maxRequestBytes = 0;
private String policyName;
private String runId;
@@ -356,6 +357,11 @@ class ExternalApiCallControllerLiveTest {
return this;
}
Step maxRequestBytes(long v) {
maxRequestBytes = v;
return this;
}
Step run(String policy, String id) {
policyName = policy;
runId = id;
@@ -380,6 +386,7 @@ class ExternalApiCallControllerLiveTest {
headers,
includeContext,
includeFile,
maxRequestBytes,
policyName,
runId);
}
@@ -389,6 +396,15 @@ class ExternalApiCallControllerLiveTest {
return new Step();
}
@Test
void aDocumentOverTheSizeLimitFailsBeforeTheCall() {
connection(Map.of());
// A 1-byte cap is under the test PDF, so the step stops before any request goes out.
assertThatThrownBy(() -> step().path("/v1/scan").maxRequestBytes(1L).go())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("limit set for this step");
}
@Test
void sendsTheDocumentAndWhatWeKnowAboutItToTheReceiver() throws IOException {
connection(Map.of());
@@ -34,6 +34,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
@@ -48,6 +50,7 @@ import stirling.software.proprietary.policy.model.PipelineDefinition;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -73,6 +76,7 @@ class PolicyExecutorTest {
private TempFileManager tempFileManager;
private PolicyExecutor executor;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
@@ -80,7 +84,7 @@ class PolicyExecutorTest {
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("policy-test-");
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
ObjectMapper objectMapper = JsonMapper.builder().build();
objectMapper = JsonMapper.builder().build();
executor =
new PolicyExecutor(
internalApiClient, toolMetadataService, tempFileManager, objectMapper);
@@ -356,6 +360,83 @@ class PolicyExecutorTest {
PolicyProgressListener.NOOP));
}
@Test
void crossStepPlaceholderIsResolvedFromAnEarlierStepReport() throws IOException {
String share = "/api/v1/integration/share";
String notify = "/api/v1/integration/notify";
// Step 1 returns the document plus a report carrying a share url, as report-mode does.
stubEndpointWithReport(
share, pdf("doc", "doc.pdf"), "{\"body\":{\"url\":\"https://share/abc\"}}");
stubEndpoint(notify, pdf("doc", "doc.pdf"));
Map<String, Object> notifyParams = new LinkedHashMap<>();
notifyParams.put("message", "see {{steps.1.body.url}}");
// A document-scope placeholder must be left verbatim for the tool to resolve per document.
notifyParams.put("keep", "{{document.filename}}");
executor.execute(
definition(
new PipelineStep(share, Map.of()), new PipelineStep(notify, notifyParams)),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP);
@SuppressWarnings("unchecked")
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
ArgumentCaptor.forClass(MultiValueMap.class);
verify(internalApiClient).post(eq(notify), bodyCaptor.capture());
MultiValueMap<String, Object> body = bodyCaptor.getValue();
assertEquals("see https://share/abc", body.getFirst("message"));
assertEquals("{{document.filename}}", body.getFirst("keep"));
}
@Test
void hostileStepOutputCannotInjectFieldsIntoAJsonParameter() throws IOException {
String share = "/api/v1/integration/share";
String notify = "/api/v1/integration/notify";
// Step 1's report carries a value crafted to break out of a JSON string.
stubEndpointWithReport(
share,
pdf("doc", "doc.pdf"),
"{\"body\":{\"url\":\"x\\\", \\\"admin\\\": true, \\\"y\\\": \\\"\"}}");
stubEndpoint(notify, pdf("doc", "doc.pdf"));
Map<String, Object> params = new LinkedHashMap<>();
params.put("bodyTemplate", "{\"msg\": \"{{steps.1.body.url}}\"}");
executor.execute(
definition(new PipelineStep(share, Map.of()), new PipelineStep(notify, params)),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP);
@SuppressWarnings("unchecked")
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
ArgumentCaptor.forClass(MultiValueMap.class);
verify(internalApiClient).post(eq(notify), bodyCaptor.capture());
// The sent template is still one field; the hostile value stayed a value.
var sent = objectMapper.readTree((String) bodyCaptor.getValue().getFirst("bodyTemplate"));
assertEquals(1, sent.size());
assertEquals("x\", \"admin\": true, \"y\": \"", sent.get("msg").asString());
assertNull(sent.get("admin"));
}
@Test
void referencingAStepThatProducedNothingFailsTheRun() {
String notify = "/api/v1/integration/notify";
// The first step references an output no earlier step produced, so the run stops.
assertThrows(
IllegalArgumentException.class,
() ->
executor.execute(
definition(
new PipelineStep(
notify, Map.of("message", "{{steps.1.body.url}}"))),
PolicyInputs.of(List.of(pdf("in", "in.pdf"))),
PolicyProgressListener.NOOP));
// It fails before any dispatch, since the reference cannot be filled in.
verify(internalApiClient, never()).post(anyString(), any());
}
@Test
void emptyPipelineIsRejected() {
assertThrows(
@@ -377,6 +458,16 @@ class PolicyExecutorTest {
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
}
/**
* A file response carrying a {@link AiToolResponseHeaders#TOOL_REPORT} report, as report-mode.
*/
private void stubEndpointWithReport(String endpoint, Resource body, String reportJson) {
HttpHeaders headers = new HttpHeaders();
headers.add(AiToolResponseHeaders.TOOL_REPORT, reportJson);
when(internalApiClient.post(eq(endpoint), any()))
.thenReturn(new ResponseEntity<>(body, headers, HttpStatus.OK));
}
private static ByteArrayResource pdf(String content, String filename) {
return new ByteArrayResource(content.getBytes()) {
@Override
@@ -0,0 +1,128 @@
package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Unit tests for {@link StepOutputPlaceholders}: it resolves only {@code steps.*} references and
* leaves every other placeholder for the downstream tool, so the pipeline-scope and document-scope
* passes never collide.
*/
class StepOutputPlaceholdersTest {
private final ObjectMapper mapper = JsonMapper.builder().build();
/** A run context whose step 1 reported a share url, matching the executor's shape. */
private ObjectNode contextWithStep1Url(String url) {
ObjectNode root = mapper.createObjectNode();
ObjectNode body = root.putObject("steps").putObject("1").putObject("body");
body.put("url", url);
return root;
}
@Test
void resolvesAStepReference() {
ObjectNode ctx = contextWithStep1Url("https://share/abc");
assertEquals(
"see https://share/abc",
StepOutputPlaceholders.resolve("see {{steps.1.body.url}}", ctx));
}
@Test
void leavesDocumentAndRunPlaceholdersUntouched() {
ObjectNode ctx = contextWithStep1Url("u");
String template = "{{document.filename}} by {{run.policyName}}";
assertEquals(template, StepOutputPlaceholders.resolve(template, ctx));
}
@Test
void resolvesStepsAndLeavesTheRestInOneString() {
ObjectNode ctx = contextWithStep1Url("https://s/1");
assertEquals(
"https://s/1 for {{document.filename}}",
StepOutputPlaceholders.resolve(
"{{steps.1.body.url}} for {{document.filename}}", ctx));
}
@Test
void throwsWhenStepReferenceIsMissing() {
ObjectNode ctx = contextWithStep1Url("u");
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> StepOutputPlaceholders.resolve("{{steps.2.body.url}}", ctx));
assertTrue(ex.getMessage().contains("steps.2.body.url"));
}
@Test
void throwsWhenStepReferenceIsNotScalar() {
ObjectNode ctx = contextWithStep1Url("u");
// steps.1.body is an object; it cannot be inlined into a string parameter.
assertThrows(
IllegalArgumentException.class,
() -> StepOutputPlaceholders.resolve("{{steps.1.body}}", ctx));
}
@Test
void resolveTreeKeepsAnInjectionAttemptAsAValue() {
// The classic JSON break-out: a response value that tries to close the string and add a
// field. Tree-level resolution keeps it a value; serialising escapes the quotes.
ObjectNode ctx = contextWithStep1Url("x\", \"admin\": true, \"y\": \"");
var template = mapper.readTree("{\"msg\": \"{{steps.1.body.url}}\"}");
var resolved = StepOutputPlaceholders.resolveTree(template, ctx);
assertEquals("x\", \"admin\": true, \"y\": \"", resolved.get("msg").asString());
assertNull(resolved.get("admin"));
var reparsed = mapper.readTree(mapper.writeValueAsString(resolved));
assertEquals(1, reparsed.size());
assertEquals("x\", \"admin\": true, \"y\": \"", reparsed.get("msg").asString());
}
@Test
void resolveTreeResolvesNestedStringsAndLeavesTheRestAlone() {
ObjectNode ctx = contextWithStep1Url("https://s/1");
var template =
mapper.readTree(
"{\"a\": [{\"link\": \"{{steps.1.body.url}}\"}], \"n\": 7,"
+ " \"doc\": \"{{document.filename}}\"}");
var resolved = StepOutputPlaceholders.resolveTree(template, ctx);
assertEquals("https://s/1", resolved.get("a").get(0).get("link").asString());
assertEquals(7, resolved.get("n").asInt());
// Document scope stays for the downstream tool, same as the string resolver.
assertEquals("{{document.filename}}", resolved.get("doc").asString());
}
@Test
void resolveTreeFailsClosedOnAMissingReference() {
ObjectNode ctx = contextWithStep1Url("u");
var template = mapper.readTree("{\"msg\": \"{{steps.2.body.url}}\"}");
assertThrows(
IllegalArgumentException.class,
() -> StepOutputPlaceholders.resolveTree(template, ctx));
}
@Test
void referencesDetectsStepsOnly() {
assertTrue(StepOutputPlaceholders.references("x {{steps.1.a}}"));
assertFalse(StepOutputPlaceholders.references("{{document.filename}}"));
assertFalse(StepOutputPlaceholders.references("no refs"));
assertFalse(StepOutputPlaceholders.references(null));
}
@Test
void nullAndEmptyPassThrough() {
ObjectNode ctx = contextWithStep1Url("u");
assertNull(StepOutputPlaceholders.resolve(null, ctx));
assertEquals("", StepOutputPlaceholders.resolve("", ctx));
}
}
@@ -6727,6 +6727,8 @@ heading = "What do you want to connect?"
noResultsBody = "We do not support it yet - tell us about it and we will look at adding it."
noResultsTitle = "Nothing matches \"{{query}}\""
searchPlaceholder = "Search integrations - try \"sign\", \"ocr\" or a product name"
tasksInfo = "Show the tasks {{name}} adds"
tasksTitle = "Tasks you can add"
[portal.connections.types.api]
description = "Call any HTTP API from a policy. You choose the URL, authentication and payload."
@@ -7746,6 +7748,7 @@ chooseOperation = "Choose what this step does"
chooseSource = "Choose a source"
discard = "Discard changes"
enabled = "Enabled"
fixStepFields = "Fix the highlighted fields"
inputs = "Input"
inputSource = "Input source"
inputTrigger = "Trigger"
@@ -8052,6 +8055,11 @@ note = "Submits the document. Retrieving the signed copy is not yet supported, b
description = "Author the call yourself: path, method, body and response handling."
label = "Call a custom API"
[portal.policies.operations.discordAttach]
description = "Uploads the processed document to a channel, with an optional message."
label = "Attach the document to Discord"
note = "Discord caps uploads at 25 MB by default; a channel's Nitro tier can raise it. Set the limit your channel allows."
[portal.policies.operations.discordNotify]
description = "Tells a channel that the policy handled a document."
label = "Post a message to Discord"
@@ -8060,12 +8068,16 @@ label = "Post a message to Discord"
description = "Records what the policy did as a searchable document."
label = "Index an audit event in Elasticsearch"
[portal.policies.operations.errors]
invalidNumber = "Enter a number greater than zero, or leave it blank for no limit."
unknownVariable = "'{{path}}' is not a variable this step can use, so every run would fail."
[portal.policies.operations.fields.bodyMode]
helperText = "Multipart suits upload APIs; JSON suits APIs that want the file inside a payload; binary sends the raw bytes."
label = "How to send the document"
[portal.policies.operations.fields.bodyTemplate]
helperText = "A JSON body sent as-is. {{document.base64}} carries the file; {{document.*}} and {{run.*}} are filled in per document."
helperText = "A JSON body sent as-is. Add variables with the button below, or by typing @ or / - use File contents to carry the document itself."
label = "JSON body"
[portal.policies.operations.fields.connection]
@@ -8096,8 +8108,12 @@ placeholder = "stirling-audit"
label = "Issue key"
placeholder = "OPS-42"
[portal.policies.operations.fields.maxFileMb]
helperText = "Skip the upload if the document is larger, in MB. Discord allows 25 by default, more on higher Nitro tiers. Leave blank for no limit."
label = "Maximum file size (MB)"
[portal.policies.operations.fields.message]
helperText = "Sent as the message body. {{document.*}} and {{run.*}} are filled in per document."
helperText = "Sent as the message body. Add variables with the button below, or by typing @ or /."
label = "Message"
[portal.policies.operations.fields.method]
@@ -8113,30 +8129,39 @@ label = "Path"
placeholder = "/v1/scan"
[portal.policies.operations.fields.remotePath]
helperText = "Where in the drive to write it. {{document.filename}} is filled in per document."
helperText = "Where in the drive to write it. Add variables with the + button, or by typing @ or /."
label = "Destination path"
placeholder = "Processed/{{document.filename}}"
placeholder = "e.g. Processed/invoice.pdf"
[portal.policies.operations.fields.responseMode]
helperText = "Report leaves the document untouched. Replace swaps it for whatever comes back."
label = "What to do with the reply"
[portal.policies.operations.fields.shareRemotePath]
helperText = "The file to share, as it is stored in Nextcloud. Match the path an earlier Upload step wrote to."
label = "File to share"
placeholder = "e.g. Processed/invoice.pdf"
[portal.policies.operations.fields.signerEmail]
label = "Signer's email"
placeholder = "signer@acme.com"
[portal.policies.operations.fields.subject]
label = "Subject"
placeholder = "Processed: {{document.filename}}"
placeholder = "e.g. Processed: invoice.pdf"
[portal.policies.operations.fields.text]
helperText = "Presidio analyses this text. Use {{document.title}} or paste the text you want checked."
helperText = "Presidio analyses this text. Add the Title variable, or paste the text you want checked."
label = "Text to check"
[portal.policies.operations.fields.to]
label = "To"
placeholder = "records@acme.com"
[portal.policies.operations.fields.transitionId]
label = "Transition ID"
placeholder = "31"
[portal.policies.operations.fields.username]
label = "Nextcloud username"
placeholder = "svc-stirling"
@@ -8149,10 +8174,24 @@ label = "Post a message to Google Chat"
description = "Files the processed document onto an issue."
label = "Attach to a Jira issue"
[portal.policies.operations.jiraComment]
description = "Adds a comment to an issue - useful with a link from an earlier step."
label = "Comment on a Jira issue"
[portal.policies.operations.jiraTransition]
description = "Moves an issue to another status, e.g. to Done once processed."
label = "Transition a Jira issue"
note = "Give the transition's id, not the status name. List them with GET /rest/api/3/issue/{key}/transitions."
[portal.policies.operations.mailgunEmail]
description = "Sends the processed document as an attachment."
label = "Email the document (Mailgun)"
[portal.policies.operations.nextcloudShareLink]
description = "Creates a public link to a file already in Nextcloud, to post or email."
label = "Create a Nextcloud share link"
note = "Share a file that is already in Nextcloud - run an Upload step first. The link comes back in this step's response, for a later step to use."
[portal.policies.operations.nextcloudUpload]
description = "Writes the processed document into a folder."
label = "Upload to Nextcloud"
@@ -8226,6 +8265,113 @@ enforces = "Enforces"
policy = "Policy"
status = "Status"
[portal.policies.variables]
add = "Add variable"
addTitle = "Add variable"
added = "Added {{label}}"
changeTitle = "Change variable"
changed = "Changed to {{label}}"
editAsBoxes = "Back to boxes"
editAsText = "Edit as text"
enterToAdd = "Enter to add"
enterToChange = "Enter to change"
exampleTag = "Example"
fromStep = "{{label}} from step {{n}}"
intro = "A variable is filled in per document when the step runs, so one step works for every file that passes through. Add one with the button, or by typing @ or / in any text field."
introSteps = "A step can also use an earlier step's answer. That reply is parsed once, so a dotted path reaches any field inside it."
menuLabel = "Variables"
noMatches = "Nothing matches that"
rawLabel = "Value as text"
redo = "Redo"
redone = "Redone"
remove = "Remove"
removeToken = "Remove {{label}}"
removed = "Removed {{label}}"
search = "Search"
title = "Variables you can use"
tokenLabel = "Variable: {{label}}. Press Enter to change it."
triggerHint = "or type @ or /"
undo = "Undo"
undone = "Undone"
unnamedTitle = "No name for this path. Saved as {{path}}"
[portal.policies.variables.defs]
classification = "The classifier's whole verdict, as JSON"
classification_label = "The label the classifier chose"
document_author = "Author from the PDF metadata"
document_base64 = "The file itself, base64-encoded - for JSON bodies that carry the document"
document_contentType = "MIME type, e.g. application/pdf"
document_created = "Created date from the PDF metadata (ISO 8601)"
document_creator = "Creating application from the PDF metadata"
document_encrypted = "Whether the PDF is encrypted (true/false)"
document_extension = "File extension, lowercased, e.g. pdf"
document_filename = "The file's name, e.g. invoice.pdf"
document_keywords = "Keywords from the PDF metadata"
document_modified = "Modified date from the PDF metadata (ISO 8601)"
document_pageCount = "Number of pages"
document_producer = "Producer from the PDF metadata"
document_sha256 = "SHA-256 of the file - what audit systems key on"
document_sizeBytes = "File size in bytes"
document_subject = "Subject from the PDF metadata"
document_title = "Title from the PDF metadata"
run_policyName = "The policy or pipeline this run belongs to"
run_runId = "Unique id of this run"
run_timestamp = "When the step ran (ISO 8601)"
sensitivityLabel_labelId = "The label's id in Microsoft Purview"
sensitivityLabel_name = "The sensitivity label's name, e.g. Confidential"
sensitivityLabel_protected = "Whether the label applies protection (true/false)"
steps_body = "That step's whole JSON answer - add a dotted path to reach into it"
steps_status = "The HTTP status that step's call returned"
[portal.policies.variables.groups.classification]
description = "The classifier's verdict, when a classification step has run."
label = "Classification"
[portal.policies.variables.groups.document]
description = "Facts about the document flowing through the run."
label = "Document"
[portal.policies.variables.groups.run]
description = "Facts about this run of the policy or pipeline."
label = "Run"
[portal.policies.variables.groups.sensitivityLabel]
description = "The Microsoft Purview label already on the document, if any."
label = "Sensitivity label"
[portal.policies.variables.groups.steps]
description = "Earlier steps' answers. Replace 1 with the step's position in the chain; add a dotted path to reach into its JSON reply."
example = "Reaches the share link inside a Nextcloud share-link step's reply - create the link in step 1, post it in step 2's message."
label = "Earlier steps"
[portal.policies.variables.labels]
classification = "Classification verdict"
classification_label = "Classification"
document_author = "Author"
document_base64 = "File contents"
document_contentType = "File type"
document_created = "Created date"
document_creator = "Created with"
document_encrypted = "Encrypted"
document_extension = "File extension"
document_filename = "File name"
document_keywords = "Keywords"
document_modified = "Modified date"
document_pageCount = "Page count"
document_producer = "Produced by"
document_sha256 = "Fingerprint"
document_sizeBytes = "File size"
document_subject = "Subject"
document_title = "Title"
run_policyName = "Policy name"
run_runId = "Run ID"
run_timestamp = "Time run"
sensitivityLabel_labelId = "Sensitivity label ID"
sensitivityLabel_name = "Sensitivity label"
sensitivityLabel_protected = "Protected"
steps_body = "Full response"
steps_status = "Status code"
[portal.policies.wizard.actions]
back = "Back"
cancel = "Cancel"
@@ -108,6 +108,9 @@ export const I18N_PROJECTS: TranslationProject[] = [
// "portal.policies.operations" - the shape heuristic treats that interpolation as one
// segment, so this whole catalogue-driven family is matched here instead.
/^portal\.policies\.operations\./,
// The variables catalogue (variables.ts) mirrors it: every def/group key is assembled
// from the const "portal.policies.variables" prefix and the variable's own path.
/^portal\.policies\.variables\./,
// Policy field labels + option display copy are looked up with keys
// derived from catalogue data (t(`policies.field.${key}`),
// t(`policyOption.${id}`)) in the PolicyFieldRows and setup wizards —
@@ -12,6 +12,13 @@ vi.mock("react-i18next", () => ({
}),
}));
// The integration step's variable layer imports the policies API, whose real module drags the
// editor tool-hook chain (and its i18n side effects) into this otherwise-isolated test.
vi.mock("@portal/api/policies", () => ({
fetchPoliciesList: () => Promise.resolve([]),
fetchPolicyRuns: () => Promise.resolve([]),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
@@ -13,6 +13,8 @@ import type { ExternalApiStepParams } from "@portal/components/policies/stepOper
interface PipelineStepSettingsProps {
step: WorkingToolStep;
/** The step's 1-based place in the chain, so cross-step variables offer only earlier steps. */
stepPosition?: number;
registry: Partial<ToolRegistry>;
onChange: (params: ErasedToolParams) => void;
}
@@ -24,6 +26,7 @@ interface PipelineStepSettingsProps {
*/
export function PipelineStepSettings({
step,
stepPosition,
registry,
onChange,
}: PipelineStepSettingsProps) {
@@ -37,6 +40,7 @@ export function PipelineStepSettings({
return (
<PolicyExternalApiConfig
parameters={step.params as unknown as ExternalApiStepParams}
stepPosition={stepPosition}
onChange={(params) => onChange(params as never)}
/>
);
@@ -67,6 +67,47 @@ describe("integration steps in a pipeline", () => {
expect(integrationStepConfigured(step)).toBe(true);
});
it("is not configured while an answer references something the run cannot fill in", () => {
const op = operationById("discordNotify")!;
const step = newIntegrationStep(op);
step.params = buildStepParameters(op, "4", {
message: "did {{document.flename}}",
}) as never;
expect(integrationStepConfigured(step)).toBe(false);
step.params = buildStepParameters(op, "4", {
message: "did {{document.filename}}",
}) as never;
expect(integrationStepConfigured(step)).toBe(true);
});
it("rejects a steps reference at or past the step's own position", () => {
const op = operationById("discordNotify")!;
const step = newIntegrationStep(op);
step.params = buildStepParameters(op, "4", {
message: "see {{steps.1.body.ocs.data.url}}",
}) as never;
// Fine as step 2 (step 1 ran before it); a self-reference as step 1 fails every run.
expect(integrationStepConfigured(step, 2)).toBe(true);
expect(integrationStepConfigured(step, 1)).toBe(false);
});
it("is not configured while the size cap is unparseable", () => {
const op = operationById("discordAttach")!;
const step = newIntegrationStep(op);
step.params = buildStepParameters(op, "4", {
message: "",
maxFileMb: "abc",
}) as never;
expect(integrationStepConfigured(step)).toBe(false);
step.params = buildStepParameters(op, "4", {
message: "",
maxFileMb: "25",
}) as never;
expect(integrationStepConfigured(step)).toBe(true);
});
it("leaves ordinary tool steps alone", () => {
const toolStep = {
toolId: "compress",
@@ -17,8 +17,11 @@ import type { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationType
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import {
buildStepParameters,
customCallUnknownReference,
emptyOperationValues,
operationById,
operationFormValid,
type ExternalApiStepParams,
type StepOperation,
} from "@portal/components/policies/stepOperations";
@@ -54,9 +57,39 @@ export function stepOperation(
return typeof id === "string" && id ? operationById(id) : undefined;
}
/** True once the step can actually run: an operation chosen and an account selected. */
export function integrationStepConfigured(step: WorkingToolStep): boolean {
/**
* True once the step can actually run: an operation chosen, an account selected, and every
* operator answer one the backend would accept - a bad size cap or a `{{reference}}` the run
* cannot fill in fails every run, so it must not be saveable. `stepPosition` (1-based) lets the
* check reject steps.N references at or past this step.
*/
export function integrationStepConfigured(
step: WorkingToolStep,
stepPosition?: number,
): boolean {
if (!isIntegrationStep(step)) return true;
const params = step.params as Record<string, unknown>;
return Boolean(params.operationId) && Boolean(params.connectionId);
if (!params.operationId || !params.connectionId) return false;
const op = operationById(String(params.operationId));
// A step authored through the API with an unknown operationId round-trips untouched.
if (!op) return true;
const values = decodeOperationValues(params.operationValues);
if (!operationFormValid(op, values, undefined, stepPosition)) return false;
if (op.custom) {
const custom = step.params as unknown as ExternalApiStepParams;
return customCallUnknownReference(custom, undefined, stepPosition) === null;
}
return true;
}
function decodeOperationValues(raw: unknown): Record<string, string> {
if (typeof raw !== "string" || raw === "") return {};
try {
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === "object"
? (parsed as Record<string, string>)
: {};
} catch {
return {};
}
}
@@ -33,6 +33,7 @@ const EMPTY_PARAMS: ExternalApiParams = {
bodyTemplate: "",
includeContext: "",
includeFile: "",
maxRequestBytes: "",
operationId: "",
operationValues: "",
};
@@ -6,7 +6,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig";
import {
@@ -15,8 +15,10 @@ import {
type ExternalApiStepParams,
} from "@portal/components/policies/stepOperations";
// The component reaches for the shared query layer (variable availability), so
// wrap in the query client + Mantine the portal provides.
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
baseRender(ui, { wrapper: PortalTestProviders });
vi.mock("react-i18next", () => ({
useTranslation: () => ({
@@ -32,6 +34,14 @@ vi.mock("@portal/api/integrations", () => ({
updateIntegration: vi.fn(),
}));
// Only what the query layer pulls from the module; importing the real one would
// drag in the full i18n chain the test has already mocked away.
vi.mock("@portal/api/policies", () => ({
fetchPoliciesList: () => Promise.resolve([]),
fetchPolicyRuns: () => Promise.resolve([]),
assemblePolicies: () => ({ summary: {}, catalogue: [] }),
}));
vi.mock("@portal/api/http", () => ({
errorMessage: (e: unknown) => String(e),
}));
@@ -39,12 +49,19 @@ vi.mock("@portal/api/http", () => ({
// A stateful host so the controlled component behaves as it does in the builder, and the test can
// read the parameters after each change.
let latest: ExternalApiStepParams;
function Harness({ initial }: { initial: ExternalApiStepParams }) {
function Harness({
initial,
stepPosition,
}: {
initial: ExternalApiStepParams;
stepPosition?: number;
}) {
const [params, setParams] = useState(initial);
latest = params;
return (
<PolicyExternalApiConfig
parameters={params}
stepPosition={stepPosition}
onChange={(p) => {
latest = p;
setParams(p);
@@ -79,3 +96,64 @@ describe("switching an operation's vendor", () => {
expect(latest.connectionId).toBe("");
});
});
describe("field validation surfaced at the field", () => {
it("flags an unparseable size cap instead of silently dropping it", () => {
const op = operationById("discordAttach")!;
render(
<Harness
initial={buildStepParameters(op, "5", {
message: "",
maxFileMb: "abc",
})}
/>,
);
expect(
screen.getByText("portal.policies.operations.errors.invalidNumber"),
).toBeInTheDocument();
});
it("accepts a plain positive cap without complaint", () => {
const op = operationById("discordAttach")!;
render(
<Harness
initial={buildStepParameters(op, "5", {
message: "",
maxFileMb: "25",
})}
/>,
);
expect(
screen.queryByText("portal.policies.operations.errors.invalidNumber"),
).not.toBeInTheDocument();
});
it("flags a reference the run cannot fill in", () => {
const op = operationById("discordNotify")!;
render(
<Harness
initial={buildStepParameters(op, "5", {
message: "did {{document.flename}}",
})}
/>,
);
expect(
screen.getByText("portal.policies.operations.errors.unknownVariable"),
).toBeInTheDocument();
});
it("flags a self-referencing steps variable in step 1", () => {
const op = operationById("discordNotify")!;
render(
<Harness
initial={buildStepParameters(op, "5", {
message: "see {{steps.1.body.url}}",
})}
stepPosition={1}
/>,
);
expect(
screen.getByText("portal.policies.operations.errors.unknownVariable"),
).toBeInTheDocument();
});
});
@@ -15,11 +15,22 @@ import {
buildStepParameters,
emptyOperationValues,
operationById,
operationFieldIssue,
operationsByCategory,
searchOperations,
type ExternalApiStepParams,
type OperationFieldIssue,
type StepOperation,
} from "@portal/components/policies/stepOperations";
import {
VariableField,
VariablesReference,
} from "@portal/components/policies/VariableField";
import { useVariableGroups } from "@portal/components/policies/useVariableGroups";
import {
unknownReferences,
type VariableGroup,
} from "@portal/components/policies/variables";
/**
* Configures a "send the document to another system" step.
@@ -54,15 +65,20 @@ function decodeValues(raw: string | undefined): Record<string, string> {
interface PolicyExternalApiConfigProps {
parameters: ExternalApiParams;
/** The step's 1-based place in the chain, so cross-step variables offer only earlier steps. */
stepPosition?: number;
onChange: (parameters: ExternalApiParams) => void;
}
export function PolicyExternalApiConfig({
parameters,
stepPosition,
onChange,
}: PolicyExternalApiConfigProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
// Which variable scopes this team can use, offered to every field and the reference panel.
const variableGroups = useVariableGroups(stepPosition);
// Whether to OFFER the escape hatch. The server refuses it regardless of what the client
// believes, so this is presentation only - the same contract the connections tab uses.
const [allowCustom, setAllowCustom] = useState(true);
@@ -199,48 +215,91 @@ export function PolicyExternalApiConfig({
/>
</FormField>
{(selected.fields ?? []).map((field) => (
<FormField
key={field.key}
label={t(field.labelKey)}
required={field.required}
helperText={field.helperTextKey ? t(field.helperTextKey) : undefined}
>
{field.control === "textarea" ? (
<textarea
className="portal-sources__connection-textarea"
rows={3}
value={values[field.key] ?? ""}
onChange={(e) => setValue(field.key, e.target.value)}
/>
) : field.control === "select" ? (
<Select
value={values[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(v) => v && setValue(field.key, v)}
/>
) : (
<Input
value={values[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) => setValue(field.key, e.target.value)}
/>
)}
</FormField>
))}
{(selected.fields ?? []).map((field) => {
// Surfaced at the field, and the same check gates saving the pipeline: a bad number or
// an unknown reference would otherwise save cleanly and fail every run.
const issue = operationFieldIssue(
field,
values[field.key] ?? "",
variableGroups,
stepPosition,
);
return (
<FormField
key={field.key}
label={t(field.labelKey)}
required={field.required}
error={issueText(t, issue)}
helperText={
field.helperTextKey ? t(field.helperTextKey) : undefined
}
>
{field.control === "select" ? (
<Select
value={values[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(v) => v && setValue(field.key, v)}
/>
) : field.control === "number" ? (
// A plain numeric input: variables can never resolve to a size cap.
<Input
type="number"
inputMode="decimal"
min={1}
step="any"
invalid={issue !== null}
value={values[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) => setValue(field.key, e.target.value)}
/>
) : (
// Text answers flow into the call, so every one may reference {{variables}}.
<VariableField
multiline={field.control === "textarea"}
value={values[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(v) => setValue(field.key, v)}
groups={variableGroups}
/>
)}
</FormField>
);
})}
{selected.custom && (
<CustomCallFields parameters={parameters} onChange={onChange} />
<CustomCallFields
parameters={parameters}
onChange={onChange}
groups={variableGroups}
stepPosition={stepPosition}
/>
)}
<VariablesReference groups={variableGroups} />
</div>
);
}
/** An issue rendered as the field's error copy. */
function issueText(
t: (key: string, options?: Record<string, unknown>) => string,
issue: OperationFieldIssue | null,
): string | undefined {
if (!issue) return undefined;
return issue.kind === "number"
? t("portal.policies.operations.errors.invalidNumber")
: t("portal.policies.operations.errors.unknownVariable", {
path: issue.path,
});
}
function OperationGrid({
operations,
onPick,
@@ -289,26 +348,39 @@ function OperationGrid({
function CustomCallFields({
parameters,
onChange,
groups,
stepPosition,
}: {
parameters: ExternalApiParams;
onChange: (p: ExternalApiParams) => void;
groups: VariableGroup[];
stepPosition?: number;
}) {
const { t } = useTranslation();
const set = (key: keyof ExternalApiParams, value: string) =>
onChange({ ...parameters, [key]: value });
const str = (key: keyof ExternalApiParams) => parameters[key] ?? "";
// Same save-time reference check the preset fields get; these are typed directly.
const refError = (key: keyof ExternalApiParams): string | undefined => {
const unknown = unknownReferences(str(key), groups, stepPosition);
return unknown.length > 0
? issueText(t, { kind: "reference", path: unknown[0] })
: undefined;
};
return (
<>
<FormField
label={t("portal.policies.operations.fields.path.label")}
helperText={t("portal.policies.operations.fields.path.helperText")}
error={refError("path")}
required
>
<Input
<VariableField
value={str("path")}
placeholder="/v1/scan"
onChange={(e) => set("path", e.target.value)}
onChange={(v) => set("path", v)}
groups={groups}
/>
</FormField>
@@ -386,13 +458,15 @@ function CustomCallFields({
<FormField
label={t("portal.policies.operations.fields.headers.label")}
helperText={t("portal.policies.operations.fields.headers.helperText")}
error={refError("headers")}
>
<textarea
className="portal-sources__connection-textarea"
<VariableField
multiline
rows={2}
value={str("headers")}
placeholder='{"X-Api-Version": "2"}'
onChange={(e) => set("headers", e.target.value)}
onChange={(v) => set("headers", v)}
groups={groups}
/>
</FormField>
@@ -402,13 +476,15 @@ function CustomCallFields({
helperText={t(
"portal.policies.operations.fields.bodyTemplate.helperText",
)}
error={refError("bodyTemplate")}
>
<textarea
className="portal-sources__connection-textarea"
<VariableField
multiline
rows={4}
value={str("bodyTemplate")}
placeholder='{"file": "{{document.base64}}"}'
onChange={(e) => set("bodyTemplate", e.target.value)}
onChange={(v) => set("bodyTemplate", v)}
groups={groups}
/>
</FormField>
)}
@@ -0,0 +1,491 @@
/* VariableField: a contenteditable whose {{references}} are drawn as labelled boxes. The braces
only ever appear in the stored value, or in the "Edit as text" textarea. */
.portal-varfield {
position: relative;
}
/* ── the editor ───────────────────────────────────────────────────────────── */
.portal-varfield__editor {
font-size: 0.8125rem;
line-height: 2.1;
padding: 0.4375rem 0.625rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem);
background: var(--c-input-bg, var(--c-surface));
color: var(--c-text);
outline: none;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.portal-varfield__editor:focus {
outline: 2px solid var(--c-conn-accent);
outline-offset: -1px;
}
.portal-varfield__editor:empty::before {
content: attr(data-placeholder);
color: var(--c-text-subtle);
}
/* Single line: the field keeps one row and the add button rides inside it. */
.portal-varfield--single .portal-varfield__editor {
line-height: 1.7;
padding-right: 2.125rem;
white-space: pre;
overflow-x: auto;
}
/* Multiline: the toolbar is welded to the bottom of the field. */
.portal-varfield:not(.portal-varfield--single) .portal-varfield__editor {
border-radius: var(--radius-sm, 0.375rem) var(--radius-sm, 0.375rem) 0 0;
}
.portal-varfield__raw {
display: block;
width: 100%;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.7;
padding: 0.4375rem 0.625rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm, 0.375rem) var(--radius-sm, 0.375rem) 0 0;
background: var(--c-input-bg, var(--c-surface));
color: var(--c-text);
resize: vertical;
}
.portal-varfield__raw:focus {
outline: 2px solid var(--c-conn-accent);
outline-offset: -1px;
}
/* ── a variable, as a box ─────────────────────────────────────────────────── */
.portal-varfield__token {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.3125rem;
vertical-align: baseline;
margin: 0 0.0625rem;
padding: 0.0625rem 0.3125rem;
border: 1px solid color-mix(in srgb, var(--c-conn-accent) 40%, transparent);
border-radius: 0.3125rem;
background: color-mix(in srgb, var(--c-conn-accent) 14%, transparent);
color: var(--c-text);
font-size: 0.75rem;
font-weight: 550;
line-height: 1.5;
white-space: nowrap;
user-select: none;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}
.portal-varfield__token:hover {
background: color-mix(in srgb, var(--c-conn-accent) 22%, transparent);
border-color: var(--c-conn-accent);
}
.portal-varfield__token:focus {
outline: none;
}
/* Selected and keyboard-focused read the same, because they mean the same thing. */
.portal-varfield__token.is-selected,
.portal-varfield__token:focus-visible {
border-color: var(--c-conn-accent);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--c-conn-accent) 32%, transparent);
}
/* The step this variable came from. That dependency is the one part of the path worth showing. */
.portal-varfield__token-source {
flex: none;
display: grid;
place-items: center;
min-width: 0.375rem;
height: 0.375rem;
border-radius: 999px;
background: var(--c-conn-accent);
font-size: 0.5625rem;
font-weight: 700;
line-height: 1;
color: transparent;
}
/* With a number in it, the dot grows into a badge. */
.portal-varfield__token-source:not(:empty) {
min-width: 0.875rem;
height: 0.875rem;
padding: 0 0.1875rem;
border-radius: 0.1875rem;
color: var(--c-text-on-primary, #fff);
font-family: var(--font-mono, ui-monospace, monospace);
}
/* The x floats over the box's top-right corner rather than sitting in the row: reserving a slot
for it padded every box with a gap that read as a gap. Out of flow it costs nothing, and
nothing reflows when it appears. */
.portal-varfield__token-remove {
position: absolute;
top: -0.3125rem;
right: -0.3125rem;
width: 0.9375rem;
height: 0.9375rem;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-surface);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6875rem;
line-height: 1;
color: var(--c-text-muted);
opacity: 0;
/* invisible must also mean unclickable: it overlaps whatever sits beside the box */
pointer-events: none;
transition:
opacity 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
.portal-varfield__token:hover .portal-varfield__token-remove,
.portal-varfield__token.is-selected .portal-varfield__token-remove,
.portal-varfield__token:focus-visible .portal-varfield__token-remove {
opacity: 1;
pointer-events: auto;
}
.portal-varfield__token-remove:hover {
color: var(--c-danger);
border-color: var(--c-danger);
}
/* A path we ship no name for: still valid and still editable, visibly a raw path. */
.portal-varfield__token--unnamed {
border-color: color-mix(in srgb, var(--c-warning) 45%, transparent);
background: color-mix(in srgb, var(--c-warning) 12%, transparent);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6875rem;
}
.portal-varfield__token--unnamed .portal-varfield__token-source {
background: var(--c-warning);
}
.portal-varfield__token--unnamed:hover {
background: color-mix(in srgb, var(--c-warning) 20%, transparent);
border-color: var(--c-warning);
}
/* ── the toolbar ──────────────────────────────────────────────────────────── */
.portal-varfield__tools {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
padding: 0.25rem 0.3125rem;
border: 1px solid var(--c-border);
border-top: 0;
border-radius: 0 0 var(--radius-sm, 0.375rem) var(--radius-sm, 0.375rem);
background: var(--c-surface-sunken);
}
.portal-varfield__tools-spacer {
flex: 1 1 auto;
}
.portal-varfield__hint {
font-size: 0.6875rem;
color: var(--c-text-subtle);
padding-right: 0.1875rem;
}
/* One line has no room for a toolbar, so the button sits in the field's trailing gutter. */
.portal-varfield__inline-add {
position: absolute;
top: 0.3125rem;
right: 0.3125rem;
width: 1.5rem;
height: 1.5rem;
display: grid;
place-items: center;
padding: 0;
border: 1px solid var(--c-border);
border-radius: 0.3125rem;
background: var(--c-surface);
color: var(--c-conn-accent);
font-size: 0.9375rem;
cursor: pointer;
}
.portal-varfield__inline-add:hover {
background: color-mix(in srgb, var(--c-conn-accent) 12%, transparent);
border-color: color-mix(in srgb, var(--c-conn-accent) 40%, transparent);
}
/* ── the list ─────────────────────────────────────────────────────────────── */
.portal-varfield__picker {
position: absolute;
z-index: 20;
width: 21rem;
max-width: calc(100% - 0.5rem);
display: flex;
flex-direction: column;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-sm, 0.375rem);
background: var(--c-bg-raised);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.portal-varfield__picker-head {
padding: 0.375rem 0.5rem 0.25rem;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-surface-sunken);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--c-text-muted);
}
.portal-varfield__picker-search {
width: 100%;
font: inherit;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
border: 0;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-input-bg, var(--c-surface));
color: var(--c-text);
outline: none;
}
.portal-varfield__picker-list {
margin: 0;
padding: 0.25rem;
list-style: none;
max-height: 13rem;
overflow-y: auto;
}
.portal-varfield__picker-section + .portal-varfield__picker-section {
margin-top: 0.1875rem;
padding-top: 0.1875rem;
border-top: 1px solid var(--c-border-subtle);
}
.portal-varfield__picker-group {
display: block;
padding: 0.375rem 0.4375rem 0.125rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--c-text-subtle);
}
.portal-varfield__picker-rows {
margin: 0;
padding: 0;
list-style: none;
}
.portal-varfield__picker-option {
display: flex;
align-items: baseline;
gap: 0.4375rem;
padding: 0.3125rem 0.4375rem;
border-radius: 0.25rem;
cursor: pointer;
}
.portal-varfield__picker-option.is-active {
background: var(--c-hover);
}
.portal-varfield__picker-tick {
flex: none;
width: 0.75rem;
font-size: 0.75rem;
line-height: 1;
color: var(--c-conn-accent);
}
.portal-varfield__picker-name {
font-size: 0.8125rem;
color: var(--c-text);
}
/* The path is kept but demoted: it answers "which one is that really?" without leading. */
.portal-varfield__picker-path {
margin-left: auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.6875rem;
color: var(--c-text-subtle);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-varfield__picker-empty {
padding: 0.625rem 0.4375rem;
font-size: 0.8125rem;
color: var(--c-text-subtle);
}
.portal-varfield__picker-foot {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.3125rem 0.5rem;
border-top: 1px solid var(--c-border-subtle);
background: var(--c-surface-sunken);
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
.portal-varfield__picker-remove {
display: inline-flex;
align-items: center;
gap: 0.1875rem;
margin-left: auto;
padding: 0.125rem 0.25rem;
border: 0;
border-radius: 0.25rem;
background: transparent;
color: var(--c-danger);
font: inherit;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.portal-varfield__picker-remove:hover {
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
/* ── VariablesReference: the quiet collapsible explainer under the fields ──── */
.portal-varref {
margin-top: 0.25rem;
}
/* Rides the shared quiet Button; only the tone is adjusted so it reads as a footnote. */
.portal-varref__toggle {
color: var(--c-text-muted);
}
.portal-varref__chevron {
transition: transform 120ms ease;
}
.portal-varref__chevron.is-open {
transform: rotate(180deg);
}
.portal-varref__body {
margin-top: 0.375rem;
padding: 0.75rem 0.875rem;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-sm, 0.375rem);
background: var(--c-surface-sunken);
}
.portal-varref__intro {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--c-text);
}
.portal-varref__group {
margin-top: 0.75rem;
}
.portal-varref__group-title {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--c-text-muted);
}
.portal-varref__group-desc {
margin: 0.125rem 0 0.375rem;
font-size: 0.75rem;
color: var(--c-text-muted);
}
.portal-varref__list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.portal-varref__row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.625rem;
row-gap: 0.0625rem;
}
/* The name leads here too, matching the boxes and the list. */
.portal-varref__name {
font-size: 0.75rem;
font-weight: 600;
color: var(--c-text);
}
.portal-varref__code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.71875rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.25rem;
background: color-mix(in srgb, var(--c-conn-accent) 12%, transparent);
color: var(--c-text);
}
.portal-varref__row-desc {
font-size: 0.75rem;
color: var(--c-text-muted);
}
/* A worked example, visibly not a variable row: tagged and set off by a rail. */
.portal-varref__example {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.625rem;
row-gap: 0.125rem;
margin-top: 0.4375rem;
padding: 0.375rem 0.625rem;
border-left: 2px solid var(--c-conn-accent);
border-radius: 0 var(--radius-sm, 0.375rem) var(--radius-sm, 0.375rem) 0;
background: color-mix(in srgb, var(--c-conn-accent) 6%, transparent);
}
.portal-varref__example-tag {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--c-conn-accent);
}
@@ -0,0 +1,102 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { VariableField } from "@portal/components/policies/VariableField";
import { variableGroupsFor } from "@portal/components/policies/variables";
const meta: Meta<typeof VariableField> = {
title: "Portal/Policies/VariableField",
component: VariableField,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof VariableField>;
/** The field is controlled; local state lets the stories be typed into. */
function Controlled({
initial,
multiline = true,
...rest
}: {
initial: string;
multiline?: boolean;
} & Partial<React.ComponentProps<typeof VariableField>>) {
const [value, setValue] = useState(initial);
return (
<div style={{ maxWidth: "34rem", display: "grid", gap: "0.75rem" }}>
<VariableField
{...rest}
value={value}
onChange={setValue}
multiline={multiline}
aria-label="Message"
/>
<code
style={{
fontSize: "0.71875rem",
color: "var(--c-text-muted)",
wordBreak: "break-all",
}}
>
{value || "(empty)"}
</code>
</div>
);
}
/** The everyday case: a message with three variables already in it. */
export const Message: Story = {
render: () => (
<Controlled initial="Filed {{document.filename}} under {{run.policyName}}. Link: {{steps.1.body}}" />
),
};
export const Empty: Story = {
render: () => <Controlled initial="" placeholder="Write your message" />,
};
/** A short field: no toolbar, the add button rides inside the input. */
export const SingleLine: Story = {
render: () => (
<Controlled
initial="#finance-{{classification.label}}"
multiline={false}
placeholder="#channel"
/>
),
};
/**
* Step 3 of a chain, so the list offers step 1 and step 2 - and each box says which step it
* depends on.
*/
export const CrossStep: Story = {
render: () => (
<Controlled
initial="{{steps.1.body}} then {{steps.2.status}}"
groups={variableGroupsFor(undefined, 3)}
/>
),
};
/**
* A vendor path the catalogue has no name for. It stays valid and swappable, but is drawn as the
* raw path so nobody mistakes it for a variable we can describe.
*/
export const UnnamedPath: Story = {
render: () => (
<Controlled initial="Share link: {{steps.1.body.ocs.data.url}}" />
),
};
/** Step 1 has no earlier steps, and a team without Purview loses that scope entirely. */
export const NarrowedScopes: Story = {
render: () => (
<Controlled
initial="{{document.filename}}"
groups={variableGroupsFor(
{ classification: true, sensitivityLabel: false },
1,
)}
/>
),
};
@@ -0,0 +1,316 @@
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormField } from "@app/ui/FormField";
import {
VariableField,
VariablesReference,
} from "@portal/components/policies/VariableField";
// Keys come back verbatim, except the few the field interpolates - those are the strings the
// assertions below read, so they have to render like the real catalogue does.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Deliberately a fresh t each call, as react-i18next often gives: the field must not treat
// that as a language change and rebuild itself under the caret.
i18n: { language: "en-US" },
t: (key: string, options?: Record<string, unknown>) => {
if (key === "portal.policies.variables.fromStep") {
return `${options?.label} from step ${options?.n}`;
}
if (key.startsWith("portal.policies.variables.labels.")) {
return (
LABELS[key.slice("portal.policies.variables.labels.".length)] ?? key
);
}
if (options?.label) return `${key}:${options.label}`;
return key;
},
}),
}));
const LABELS: Record<string, string> = {
document_filename: "File name",
document_sha256: "Fingerprint",
document_pageCount: "Page count",
run_policyName: "Policy name",
run_runId: "Run ID",
steps_body: "Full response",
steps_status: "Status code",
};
/** The field is controlled; give it real state so edits round-trip. */
function Harness({
initial = "",
multiline = true,
}: {
initial?: string;
multiline?: boolean;
}) {
const [value, setValue] = useState(initial);
return (
<MantineProvider>
<VariableField
value={value}
onChange={setValue}
multiline={multiline}
aria-label="field"
/>
<output data-testid="stored">{value}</output>
</MantineProvider>
);
}
const editor = () => screen.getByLabelText("field");
const stored = () => screen.getByTestId("stored").textContent;
const boxes = () =>
Array.from(document.querySelectorAll(".portal-varfield__token"));
const boxNames = () =>
boxes().map(
(box) => box.querySelector(".portal-varfield__token-label")?.textContent,
);
/** Put the caret at the end of the editor's last text node and fire an input. */
function typeInto(text: string) {
const el = editor();
let node = el.lastChild;
if (!node || node.nodeType !== Node.TEXT_NODE) {
node = document.createTextNode("");
el.appendChild(node);
}
node.nodeValue = (node.nodeValue ?? "") + text;
const range = document.createRange();
range.setStart(node, node.nodeValue!.length);
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
fireEvent.input(el);
}
describe("VariableField", () => {
it("draws a saved reference as a named box, with no braces on screen", () => {
render(<Harness initial="Filed {{document.filename}} today" />);
expect(boxNames()).toEqual(["File name"]);
expect(editor().textContent).not.toContain("{{");
// What is stored is unchanged: the boxes are only how it is drawn.
expect(stored()).toBe("Filed {{document.filename}} today");
});
it("names an earlier step's output by its step number", () => {
render(<Harness initial="Link: {{steps.1.body}}" />);
expect(boxNames()).toEqual(["Full response from step 1"]);
expect(
document.querySelector(".portal-varfield__token-source")?.textContent,
).toBe("1");
});
it.each(["@", "/", "{{"])(
"opens the list on %s and inserts on Enter",
(trigger) => {
render(<Harness />);
typeInto(`see ${trigger}`);
expect(screen.getByRole("listbox")).toBeInTheDocument();
fireEvent.keyDown(editor(), { key: "Enter" });
expect(stored()).toMatch(/^see \{\{[\w.]+\}\} $/);
// The typed trigger never survives as text.
expect(editor().textContent).not.toContain(trigger);
},
);
it("filters on the name, not just the path", () => {
render(<Harness />);
typeInto("@fingerprint");
const options = within(screen.getByRole("listbox")).getAllByRole("option");
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent("Fingerprint");
});
it("leaves @ and / alone in the middle of a word", () => {
render(<Harness />);
typeInto("mail bob@acme");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
typeInto(" and/or");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("closes the list once nothing matches, keeping the typed text", () => {
render(<Harness />);
typeInto("x @zzzz");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(stored()).toBe("x @zzzz");
});
it("clicking a box opens the list with that variable ticked, and swaps in place", () => {
render(<Harness initial="a {{document.filename}} b" />);
fireEvent.mouseDown(boxes()[0]);
const list = screen.getByRole("listbox");
const ticked = within(list)
.getAllByRole("option")
.filter((option) => option.getAttribute("aria-selected") === "true");
expect(ticked[0]).toHaveTextContent("File name");
fireEvent.mouseDown(
within(list).getByRole("option", { name: /Page count/ }),
);
expect(stored()).toBe("a {{document.pageCount}} b");
expect(boxes()).toHaveLength(1);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("removes a whole box from its x, and Ctrl+Z puts it back", () => {
render(<Harness initial="a {{document.filename}} b" />);
fireEvent.mouseDown(
document.querySelector(".portal-varfield__token-remove")!,
);
expect(stored()).toBe("a b");
expect(boxes()).toHaveLength(0);
fireEvent.keyDown(editor(), { key: "z", ctrlKey: true });
expect(stored()).toBe("a {{document.filename}} b");
expect(boxNames()).toEqual(["File name"]);
});
it("removes a focused box on Delete without double-snapshotting the undo", () => {
// Regression: the box and the editor both listen for Delete. When the box let the event
// bubble, one edit was snapshotted twice and the first undo restored an identical state.
render(<Harness initial="a {{document.filename}} b" />);
const box = boxes()[0] as HTMLElement;
box.focus();
fireEvent.keyDown(box, { key: "Delete" });
expect(stored()).toBe("a b");
fireEvent.keyDown(editor(), { key: "z", ctrlKey: true });
expect(stored()).toBe("a {{document.filename}} b");
});
it("redoes with Ctrl+Shift+Z", () => {
render(<Harness initial="{{run.runId}}" />);
fireEvent.mouseDown(
document.querySelector(".portal-varfield__token-remove")!,
);
fireEvent.keyDown(editor(), { key: "z", ctrlKey: true });
expect(stored()).toBe("{{run.runId}}");
fireEvent.keyDown(editor(), { key: "Z", ctrlKey: true, shiftKey: true });
expect(stored()).toBe("");
});
it("opens the list from the Add variable button and inserts at the caret", async () => {
render(<Harness />);
await userEvent.click(
screen.getByRole("button", { name: /variables\.add/ }),
);
const list = screen.getByRole("listbox");
fireEvent.mouseDown(
within(list).getByRole("option", { name: /Policy name/ }),
);
expect(stored()).toBe("{{run.policyName}} ");
});
it("converts pasted brace text into boxes", () => {
render(<Harness />);
fireEvent.paste(editor(), {
clipboardData: {
getData: () => "Filed {{document.filename}} under {{run.policyName}}.",
},
});
expect(boxNames()).toEqual(["File name", "Policy name"]);
expect(stored()).toBe(
"Filed {{document.filename}} under {{run.policyName}}.",
);
});
it("shows a path it has no name for as an unnamed box, still swappable", () => {
render(<Harness initial="{{steps.1.body.ocs.data.url}}" />);
const box = boxes()[0];
expect(box).toHaveClass("portal-varfield__token--unnamed");
expect(boxNames()).toEqual(["steps.1.body.ocs.data.url"]);
fireEvent.mouseDown(box);
const options = within(screen.getByRole("listbox")).getAllByRole("option");
// Nothing is ticked: the saved path is not one of the named variables.
expect(
options.filter((o) => o.getAttribute("aria-selected") === "true"),
).toHaveLength(1); // the highlight still has to land somewhere
expect(options.find((o) => o.querySelector("svg"))).toBeUndefined();
});
it("keeps a single-line field to one line", () => {
render(<Harness multiline={false} />);
typeInto("one");
const prevented = !fireEvent.keyDown(editor(), { key: "Enter" });
expect(prevented).toBe(true);
});
it("shows the raw value behind Edit as text and comes back with boxes", async () => {
render(<Harness initial="Filed {{document.filename}}" />);
await userEvent.click(
screen.getByRole("button", { name: /variables\.editAsText/ }),
);
const raw = screen.getByLabelText(
"portal.policies.variables.rawLabel",
) as HTMLTextAreaElement;
expect(raw.value).toBe("Filed {{document.filename}}");
await userEvent.click(
screen.getByRole("button", { name: /variables\.editAsBoxes/ }),
);
expect(boxNames()).toEqual(["File name"]);
});
it("takes its name from a FormField label, and focuses when that label is clicked", async () => {
// A <label for> binds to labelable elements only, so a contenteditable gets neither the name
// nor the click-to-focus unless the field wires them up itself.
function Labelled() {
const [value, setValue] = useState("");
return (
<MantineProvider>
<FormField label="Message body">
<VariableField value={value} onChange={setValue} multiline />
</FormField>
</MantineProvider>
);
}
render(<Labelled />);
const field = screen.getByRole("textbox", { name: "Message body" });
expect(field).toBeInTheDocument();
await userEvent.click(screen.getByText("Message body"));
expect(document.activeElement).toBe(field);
});
});
describe("VariablesReference", () => {
it("expands to the catalogue and collapses again", async () => {
render(
<MantineProvider>
<VariablesReference />
</MantineProvider>,
);
const toggle = screen.getByRole("button", {
name: /portal\.policies\.variables\.title/,
});
expect(screen.queryByText("document.sha256")).not.toBeInTheDocument();
await userEvent.click(toggle);
// Both halves are listed: the name leads, the path backs it up.
expect(screen.getByText("Fingerprint")).toBeInTheDocument();
expect(screen.getByText("document.sha256")).toBeInTheDocument();
expect(screen.getByText("steps.1.body")).toBeInTheDocument();
expect(screen.getByText("steps.1.body.ocs.data.url")).toBeInTheDocument();
expect(
screen.getByText("portal.policies.variables.exampleTag"),
).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-expanded", "true");
await userEvent.click(toggle);
expect(screen.queryByText("document.sha256")).not.toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest";
import {
STEP_OPERATIONS,
buildStepParameters,
customCallUnknownReference,
emptyOperationValues,
operationById,
operationFieldIssue,
operationFormValid,
operationsForConnectionType,
searchOperations,
} from "@portal/components/policies/stepOperations";
import { CREATABLE_CONNECTION_TYPES } from "@portal/components/sources/connectionTypes";
@@ -94,6 +97,7 @@ describe("buildStepParameters", () => {
"bodyTemplate",
"includeContext",
"includeFile",
"maxRequestBytes",
"operationId",
"operationValues",
]) {
@@ -128,6 +132,42 @@ describe("operation form", () => {
const cloudmersive = operationById("cloudmersiveScan")!;
expect(operationFormValid(cloudmersive, {})).toBe(true);
});
it("refuses a reference the run cannot fill in", () => {
// {{document.flename}} saves cleanly without this check and then fails every run.
const discord = operationById("discordNotify")!;
expect(
operationFormValid(discord, { message: "{{document.flename}}" }),
).toBe(false);
expect(
operationFormValid(discord, { message: "{{document.filename}}" }),
).toBe(true);
});
it("refuses a forward or self step reference when the position is known", () => {
const discord = operationById("discordNotify")!;
const values = { message: "see {{steps.2.body.url}}" };
expect(operationFormValid(discord, values, undefined, 3)).toBe(true);
expect(operationFormValid(discord, values, undefined, 2)).toBe(false);
expect(operationFormValid(discord, values, undefined, 1)).toBe(false);
});
it("checks the custom call's own path, headers and body template", () => {
expect(
customCallUnknownReference({
path: "/v1/{{document.filename}}",
headers: '{"X-Doc": "{{document.sha256}}"}',
bodyTemplate: "",
}),
).toBeNull();
expect(
customCallUnknownReference({
path: "",
headers: "",
bodyTemplate: '{"file": "{{document.base46}}"}',
}),
).toBe("document.base46");
});
});
describe("searchOperations", () => {
@@ -248,4 +288,142 @@ describe("substituting an answer into the URL path", () => {
});
expect(params.path).toBe("/v3/mg.acme.com/messages");
});
it("keeps a {{document.*}} reference inside an answer for the backend pass", () => {
// Encoding the braces would send the reference literally, never resolved - the backend's
// URL_PATH pass resolves it per document and percent-encodes the value itself.
const op = operationById("nextcloudUpload")!;
const params = buildStepParameters(op, "9", {
username: "svc",
remotePath: "Processed/{{document.filename}}",
});
expect(params.path).toBe(
"/remote.php/dav/files/svc/Processed/{{document.filename}}",
);
});
it("keeps a path-valued answer's slashes as separators, encoding each segment", () => {
const op = operationById("nextcloudUpload")!;
const params = buildStepParameters(op, "9", {
username: "svc",
remotePath: "My Reports/2026/x.pdf",
});
expect(params.path).toBe(
"/remote.php/dav/files/svc/My%20Reports/2026/x.pdf",
);
});
});
describe("operationsForConnectionType", () => {
it("lists every task a connection type unlocks, so the (i) can show them", () => {
const jira = operationsForConnectionType("jira").map((o) => o.id);
expect(jira).toEqual(
expect.arrayContaining(["jiraAttach", "jiraComment", "jiraTransition"]),
);
const discord = operationsForConnectionType("discord").map((o) => o.id);
expect(discord).toEqual(
expect.arrayContaining(["discordNotify", "discordAttach"]),
);
const nextcloud = operationsForConnectionType("nextcloud").map((o) => o.id);
expect(nextcloud).toEqual(
expect.arrayContaining(["nextcloudUpload", "nextcloudShareLink"]),
);
});
it("returns nothing for a connection type with no policy steps", () => {
// S3 is a source/destination, not a step operation - so it gets no (i) list.
expect(operationsForConnectionType("s3")).toEqual([]);
expect(operationsForConnectionType("does-not-exist")).toEqual([]);
});
});
describe("per-step size limit", () => {
it("turns the operator's MB limit into a byte cap for Discord attach", () => {
const op = operationById("discordAttach")!;
const params = buildStepParameters(op, "5", {
...emptyOperationValues(op),
maxFileMb: "25",
});
expect(params.maxRequestBytes).toBe(String(25 * 1024 * 1024));
});
it("caps nothing when the operation declares no size field", () => {
const params = buildStepParameters(operationById("slackNotify")!, "5", {
message: "hi",
});
expect(params.maxRequestBytes).toBe("0");
});
it("treats a blank limit as no cap - the helper text promises exactly that", () => {
const op = operationById("discordAttach")!;
const values = { ...emptyOperationValues(op), maxFileMb: "" };
expect(buildStepParameters(op, "5", values).maxRequestBytes).toBe("0");
expect(operationFormValid(op, values)).toBe(true);
});
it("refuses to save a limit it would otherwise silently drop", () => {
// "abc" or "-5" coerced to "no cap" is the unsafe direction: the operator set a safeguard
// and it silently stopped existing. The form must block the save instead.
const op = operationById("discordAttach")!;
const field = op.fields!.find((f) => f.key === "maxFileMb")!;
expect(field.control).toBe("number");
for (const bad of ["abc", "-5", "0", "25 MB"]) {
expect(
operationFormValid(op, { ...emptyOperationValues(op), maxFileMb: bad }),
bad,
).toBe(false);
expect(operationFieldIssue(field, bad)).toEqual({ kind: "number" });
}
expect(
operationFormValid(op, { ...emptyOperationValues(op), maxFileMb: "25" }),
).toBe(true);
});
});
describe("newly added tasks build the right call", () => {
it("nextcloudShareLink asks for JSON, sends no file, uses the OCS header", () => {
const op = operationById("nextcloudShareLink")!;
const params = buildStepParameters(op, "9", {
...emptyOperationValues(op),
remotePath: "Processed/x.pdf",
});
expect(params.includeFile).toBe("false");
expect(params.path).toContain("format=json");
expect(params.headers).toContain("OCS-APIRequest");
// The operator's path lands in the OCS 'path' field alongside the public shareType.
expect(JSON.parse(params.fields)).toMatchObject({
path: "Processed/x.pdf",
shareType: "3",
});
});
it("discordAttach uploads the file under files[0] with a caption", () => {
const op = operationById("discordAttach")!;
const params = buildStepParameters(op, "3", {
...emptyOperationValues(op),
message: "done",
});
expect(params.bodyMode).toBe("multipart");
expect(params.fileFieldName).toBe("files[0]");
expect(params.includeFile).toBe("true");
// The caption rides in payload_json, Discord's multipart companion field.
const fields: Record<string, string> = JSON.parse(params.fields);
expect(JSON.parse(fields.payload_json)).toEqual({ content: "done" });
});
it("jiraComment posts an ADF body to the issue's comment endpoint", () => {
const op = operationById("jiraComment")!;
const params = buildStepParameters(op, "2", {
...emptyOperationValues(op),
issueKey: "OPS-9",
message: "processed",
});
expect(params.path).toBe("/rest/api/3/issue/OPS-9/comment");
expect(params.includeFile).toBe("false");
expect(params.bodyTemplate).toContain("processed");
// The Atlassian Document Format wrapper survives to the wire.
expect(params.bodyTemplate).toContain("paragraph");
});
});
@@ -18,6 +18,11 @@
import type { IntegrationType } from "@portal/api/integrations";
import type { ConnectionCategory } from "@portal/components/sources/connectionTypes";
import {
VARIABLE_GROUPS,
unknownReferences,
type VariableGroup,
} from "@portal/components/policies/variables";
/**
* The complete parameter set of the `external-api-call` step. Every key is present and every
@@ -39,6 +44,7 @@ export interface ExternalApiStepParams {
bodyTemplate: string;
includeContext: string;
includeFile: string;
maxRequestBytes: string;
operationId: string;
operationValues: string;
}
@@ -49,12 +55,18 @@ const PREFIX = "portal.policies.operations";
export interface OperationFieldDef {
key: string;
labelKey: string;
control: "text" | "textarea" | "select";
/** "number" is a plain numeric input: no variables, validated as a positive number. */
control: "text" | "textarea" | "select" | "number";
required?: boolean;
placeholderKey?: string;
helperTextKey?: string;
defaultValue?: string;
options?: { value: string; labelKey: string }[];
/**
* True when the value is a path whose slashes are separators (Nextcloud's remotePath), so
* substitutePath encodes each segment rather than the whole value.
*/
pathValue?: boolean;
}
/**
@@ -83,6 +95,12 @@ export interface OperationCall {
bodyTemplate?: string;
/** False for notify-style calls that send facts rather than the document. */
includeFile?: boolean;
/**
* Names the operator field (in MB) that caps the document size for this call, for destinations
* with an upload limit the operator alone knows (a Discord channel's, which its Nitro tier sets).
* The step fails before dispatch when the document is over it, rather than the vendor rejecting it.
*/
maxBytesFromField?: string;
}
export interface StepOperation {
@@ -268,6 +286,65 @@ export const STEP_OPERATIONS: StepOperation[] = [
},
fields: [f("issueKey")],
},
{
id: "jiraComment",
connectionTypeId: "jira",
integrationType: "API",
category: "storage",
labelKey: `${PREFIX}.jiraComment.label`,
descriptionKey: `${PREFIX}.jiraComment.description`,
searchTerms: ["jira", "comment", "note", "atlassian", "issue"],
// Jira Cloud v3 wants the comment body as Atlassian Document Format, not plain text. No file:
// this records a note on the issue, so the document flows on untouched.
call: {
path: "/rest/api/3/issue/{{issueKey}}/comment",
bodyMode: "json",
includeFile: false,
responseMode: "report",
bodyTemplate: JSON.stringify({
body: {
type: "doc",
version: 1,
content: [
{
type: "paragraph",
content: [{ type: "text", text: "{{message}}" }],
},
],
},
}),
},
fields: [
f("issueKey"),
{
key: "message",
labelKey: `${PREFIX}.fields.message.label`,
control: "textarea",
required: true,
helperTextKey: `${PREFIX}.fields.message.helperText`,
defaultValue: "{{run.policyName}} processed {{document.filename}}",
},
],
},
{
id: "jiraTransition",
connectionTypeId: "jira",
integrationType: "API",
category: "storage",
labelKey: `${PREFIX}.jiraTransition.label`,
descriptionKey: `${PREFIX}.jiraTransition.description`,
searchTerms: ["jira", "transition", "status", "workflow", "move", "done"],
noteKey: `${PREFIX}.jiraTransition.note`,
// A transition is named by its id, not the target status; GET .../transitions lists them.
call: {
path: "/rest/api/3/issue/{{issueKey}}/transitions",
bodyMode: "json",
includeFile: false,
responseMode: "report",
bodyTemplate: JSON.stringify({ transition: { id: "{{transitionId}}" } }),
},
fields: [f("issueKey"), f("transitionId")],
},
{
id: "confluenceAttach",
connectionTypeId: "confluence",
@@ -312,6 +389,38 @@ export const STEP_OPERATIONS: StepOperation[] = [
required: true,
helperTextKey: `${PREFIX}.fields.remotePath.helperText`,
defaultValue: "Processed/{{document.filename}}",
pathValue: true,
},
],
},
{
id: "nextcloudShareLink",
connectionTypeId: "nextcloud",
integrationType: "API",
category: "storage",
labelKey: `${PREFIX}.nextcloudShareLink.label`,
descriptionKey: `${PREFIX}.nextcloudShareLink.description`,
searchTerms: ["nextcloud", "share", "link", "public", "url", "owncloud"],
noteKey: `${PREFIX}.nextcloudShareLink.note`,
// Shares a file already in Nextcloud (e.g. one an upload step wrote); no document is sent.
// The OCS link comes back at ocs.data.url, so a later step reads {{steps.N.body.ocs.data.url}}.
call: {
path: "/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json",
bodyMode: "multipart",
includeFile: false,
responseMode: "report",
headers: { "OCS-APIRequest": "true" },
fields: { path: "{{remotePath}}", shareType: "3" },
},
fields: [
{
key: "remotePath",
labelKey: `${PREFIX}.fields.shareRemotePath.label`,
control: "text",
required: true,
placeholderKey: `${PREFIX}.fields.shareRemotePath.placeholder`,
helperTextKey: `${PREFIX}.fields.shareRemotePath.helperText`,
defaultValue: "Processed/{{document.filename}}",
},
],
},
@@ -501,6 +610,46 @@ export const STEP_OPERATIONS: StepOperation[] = [
"trigger",
]),
{
id: "discordAttach",
connectionTypeId: "discord",
integrationType: "API",
category: "notify",
labelKey: `${PREFIX}.discordAttach.label`,
descriptionKey: `${PREFIX}.discordAttach.description`,
searchTerms: ["discord", "attach", "file", "upload", "document", "chat"],
noteKey: `${PREFIX}.discordAttach.note`,
// Discord webhooks take a multipart upload: the file under files[0], the caption in payload_json.
// The size cap is a field, not baked in, because a channel's limit rises with its Nitro tier.
call: {
path: "",
bodyMode: "multipart",
fileFieldName: "files[0]",
responseMode: "report",
includeFile: true,
maxBytesFromField: "maxFileMb",
fields: { payload_json: JSON.stringify({ content: "{{message}}" }) },
},
fields: [
{
key: "message",
labelKey: `${PREFIX}.fields.message.label`,
control: "textarea",
required: false,
helperTextKey: `${PREFIX}.fields.message.helperText`,
defaultValue: "{{run.policyName}} processed {{document.filename}}",
},
{
key: "maxFileMb",
labelKey: `${PREFIX}.fields.maxFileMb.label`,
control: "number",
required: false,
helperTextKey: `${PREFIX}.fields.maxFileMb.helperText`,
defaultValue: "25",
},
],
},
{
id: "webhookPost",
connectionTypeId: "webhook",
@@ -607,12 +756,49 @@ export function emptyOperationValues(
return values;
}
/** Why a field's current value cannot be saved, or null when it can. */
export type OperationFieldIssue =
| { kind: "number" }
| { kind: "reference"; path: string };
/**
* Save-time validation for one operator field. A number field must be blank (no cap) or a
* positive number - silently coercing "abc" to "no cap" would fail open on a safeguard. A text
* field's `{{references}}` must all be ones the run can fill in, because the backend hard-fails
* an unknown path on every run.
*/
export function operationFieldIssue(
field: OperationFieldDef,
value: string,
groups: VariableGroup[] = VARIABLE_GROUPS,
stepPosition?: number,
): OperationFieldIssue | null {
if (field.control === "number") {
const trimmed = value.trim();
if (trimmed === "") return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? null : { kind: "number" };
}
if (field.control === "select") return null;
const unknown = unknownReferences(value, groups, stepPosition);
return unknown.length > 0 ? { kind: "reference", path: unknown[0] } : null;
}
export function operationFormValid(
op: StepOperation,
values: Record<string, string>,
groups: VariableGroup[] = VARIABLE_GROUPS,
stepPosition?: number,
): boolean {
return (op.fields ?? []).every(
(field) => !field.required || (values[field.key] ?? "").trim() !== "",
(field) =>
(!field.required || (values[field.key] ?? "").trim() !== "") &&
operationFieldIssue(
field,
values[field.key] ?? "",
groups,
stepPosition,
) === null,
);
}
@@ -629,12 +815,35 @@ export function buildStepParameters(
connectionId: string,
values: Record<string, string>,
): ExternalApiStepParams {
const fieldsByKey = new Map(
(op.fields ?? []).map((field) => [field.key, field]),
);
// A {{document.*}}-style reference inside the answer must stay for the backend's own URL_PATH
// pass, which resolves and percent-encodes its value at run time; encoding the braces here
// would send the reference literally, never resolved.
const encodePathAnswer = (text: string, segmented: boolean): string =>
text
.split(/(\{\{[\w.]+\}\})/g)
.map((part) =>
/^\{\{[\w.]+\}\}$/.test(part)
? part
: segmented
? part.split("/").map(encodeURIComponent).join("/")
: encodeURIComponent(part),
)
.join("");
// Substituted into the URL path: the answer is percent-encoded, so a space or slash in a key
// (a Jira "OPS 1", a path-shaped id) is a value, not a change to the target. Matches the
// backend's URL_PATH escaping for its own {{document.*}} pass.
// backend's URL_PATH escaping for its own {{document.*}} pass. A pathValue field keeps its
// slashes as separators and encodes per segment instead.
const substitutePath = (text: string): string =>
text.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (whole, key: string) =>
key in values ? encodeURIComponent(values[key]) : whole,
key in values
? encodePathAnswer(
values[key],
fieldsByKey.get(key)?.pathValue === true,
)
: whole,
);
// Substituted into an already-serialised JSON string: a quote or backslash in an answer would
// otherwise break the body, and the backend rejects it as invalid JSON.
@@ -662,11 +871,53 @@ export function buildStepParameters(
bodyTemplate: call.bodyTemplate ? substituteJson(call.bodyTemplate) : "",
includeContext: "false",
includeFile: String(call.includeFile ?? true),
maxRequestBytes: maxRequestBytes(call, values),
operationId: op.id,
operationValues: JSON.stringify(values),
};
}
/**
* The size cap in bytes for this call, from the operator's MB field, or "0" for no cap. Blank
* deliberately means no cap; anything else non-positive or unparseable also yields "0" here, but
* operationFieldIssue refuses to save it - coercing "abc" into "no cap" would fail open.
*/
function maxRequestBytes(
call: OperationCall,
values: Record<string, string>,
): string {
if (!call.maxBytesFromField) return "0";
const mb = Number.parseFloat(values[call.maxBytesFromField] ?? "");
if (!Number.isFinite(mb) || mb <= 0) return "0";
return String(Math.round(mb * 1024 * 1024));
}
/**
* The first unresolvable reference across a custom call's operator-authored parameters, or null.
* The custom operation has no fields; its path, headers and body template are typed directly, so
* they get the same save-time reference check the field values do.
*/
export function customCallUnknownReference(
params: Pick<ExternalApiStepParams, "path" | "headers" | "bodyTemplate">,
groups: VariableGroup[] = VARIABLE_GROUPS,
stepPosition?: number,
): string | null {
for (const text of [params.path, params.headers, params.bodyTemplate]) {
const unknown = unknownReferences(text ?? "", groups, stepPosition);
if (unknown.length > 0) return unknown[0];
}
return null;
}
/** The operations a given connection type unlocks - what an integration lets you actually do. */
export function operationsForConnectionType(
connectionTypeId: string,
): StepOperation[] {
return STEP_OPERATIONS.filter(
(op) => op.connectionTypeId === connectionTypeId,
);
}
export function operationById(id: string): StepOperation | undefined {
return STEP_OPERATIONS.find((op) => op.id === id);
}
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { fetchIntegrations } from "@portal/api/integrations";
import { usePoliciesList } from "@portal/queries/policies";
import { fromWirePolicy } from "@app/policies/codec";
import {
variableGroupsFor,
type VariableGroup,
} from "@portal/components/policies/variables";
/**
* The variable groups this team can actually use.
*
* Classification variables only resolve where a classification policy is enabled, and
* sensitivity-label ones only where Purview is connected - so those groups are offered only when
* the team's data says they exist. Fail-open: until (or unless) the answers arrive, everything is
* offered, because hiding a variable from a team that uses it is the worse mistake.
*
* `stepPosition` is the configured step's 1-based place in its chain; with it known, the steps
* group offers only the steps that actually ran before this one (see variableGroupsFor).
*/
export function useVariableGroups(stepPosition?: number): VariableGroup[] {
const integrations = useQuery({
queryKey: qk.integrations(),
queryFn: fetchIntegrations,
});
const policies = usePoliciesList();
return useMemo(() => {
const availability =
!integrations.data || !policies.data
? undefined
: {
sensitivityLabel: integrations.data.some(
(connection) => connection.integrationType === "PURVIEW",
),
classification: policies.data.some((wire) => {
try {
return (
wire.enabled &&
fromWirePolicy(wire).categoryId === "classification"
);
} catch {
// One malformed stored policy must not decide the menu.
return false;
}
}),
};
return variableGroupsFor(availability, stepPosition);
}, [integrations.data, policies.data, stepPosition]);
}
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import {
ALL_VARIABLES,
VARIABLE_GROUPS,
defForPath,
openReferenceAt,
unknownReferences,
variableGroupsFor,
variableLabel,
variableSuggestions,
} from "@portal/components/policies/variables";
/** Stands in for i18next: labels come back readable, fromStep interpolates. */
const t = (key: string, options?: Record<string, unknown>) => {
if (key.endsWith(".fromStep"))
return `${options?.label} from step ${options?.n}`;
return key.split(".").pop() ?? key;
};
describe("openReferenceAt", () => {
it.each([
["@", "ping @doc", 5, "doc"],
["/", "note /run", 5, "run"],
["{{", "see {{doc", 4, "doc"],
])("opens on %s at a word start", (_trigger, text, start, partial) => {
expect(openReferenceAt(text, text.length)).toEqual({ start, partial });
});
it("opens at the very start of the field", () => {
expect(openReferenceAt("@doc", 4)).toEqual({ start: 0, partial: "doc" });
});
it.each([
["an email address", "mail bob@acme"],
["a path", "and/or"],
])("leaves a trigger inside %s alone", (_what, text) => {
expect(openReferenceAt(text, text.length)).toBeNull();
});
it("is closed the moment the braces are", () => {
const text = "see {{document.filename}} now";
expect(openReferenceAt(text, text.length)).toBeNull();
});
it("ignores a trigger followed by prose - typing text is not typing a reference", () => {
const text = "a {{ b c";
expect(openReferenceAt(text, text.length)).toBeNull();
});
it("opens fresh after a completed reference", () => {
const text = "{{run.runId}} and {{ste";
expect(openReferenceAt(text, text.length)).toEqual({
start: 18,
partial: "ste",
});
});
it("returns null with no trigger at all", () => {
expect(openReferenceAt("plain text", 5)).toBeNull();
});
});
describe("variableLabel", () => {
it("names a plain variable", () => {
expect(variableLabel(defForPath("document.filename")!, t)).toBe(
"document_filename",
);
});
it("carries the step number, because that is the dependency", () => {
const def = variableGroupsFor(undefined, 3)
.find((group) => group.id === "steps")!
.variables.find((v) => v.path === "steps.2.body")!;
expect(variableLabel(def, t)).toBe("steps_body from step 2");
});
});
describe("defForPath", () => {
it("finds a catalogued variable", () => {
expect(defForPath("run.runId")?.path).toBe("run.runId");
});
it("has nothing for a path we ship no name for", () => {
expect(defForPath("steps.1.body.ocs.data.url")).toBeUndefined();
});
});
describe("variableSuggestions", () => {
it("matches anywhere in the path", () => {
const hits = variableSuggestions("filename").map((d) => d.path);
expect(hits).toContain("document.filename");
});
it("narrows by dotted prefix", () => {
const hits = variableSuggestions("document.s").map((d) => d.path);
expect(hits).toContain("document.sha256");
expect(hits).toContain("document.sizeBytes");
expect(hits).not.toContain("document.filename");
});
it("offers the cross-step patterns under steps", () => {
const hits = variableSuggestions("steps").map((d) => d.path);
expect(hits).toContain("steps.1.body");
expect(hits).toContain("steps.1.status");
// The vendor-specific path is a worked example, not a variable that always exists.
expect(hits).not.toContain("steps.1.body.ocs.data.url");
});
it("offers everything for an empty partial", () => {
expect(variableSuggestions("")).toHaveLength(ALL_VARIABLES.length);
});
it("suggests only from the groups it is given", () => {
const withoutConditionals = variableGroupsFor({
classification: false,
sensitivityLabel: false,
});
const hits = variableSuggestions("", withoutConditionals).map(
(d) => d.path,
);
expect(hits).toContain("document.filename");
expect(hits.some((p) => p.startsWith("classification"))).toBe(false);
expect(hits.some((p) => p.startsWith("sensitivityLabel"))).toBe(false);
});
});
describe("variableGroupsFor", () => {
it("offers everything while availability is unknown", () => {
expect(variableGroupsFor(undefined)).toHaveLength(VARIABLE_GROUPS.length);
});
it("drops only the scopes the team does not have", () => {
const ids = variableGroupsFor({
classification: true,
sensitivityLabel: false,
}).map((g) => g.id);
expect(ids).toContain("classification");
expect(ids).not.toContain("sensitivityLabel");
expect(ids).toEqual(expect.arrayContaining(["document", "run", "steps"]));
});
it("offers no steps group to step 1 - its only completion would be a self-reference", () => {
const ids = variableGroupsFor(undefined, 1).map((g) => g.id);
expect(ids).not.toContain("steps");
});
it("offers one concrete pair per earlier step, nothing at or past this one", () => {
const steps = variableGroupsFor(undefined, 3).find(
(g) => g.id === "steps",
)!;
expect(steps.variables.map((d) => d.path)).toEqual([
"steps.1.body",
"steps.1.status",
"steps.2.body",
"steps.2.status",
]);
// Concrete paths: nothing left for the operator to edit before it resolves.
expect(steps.variables.every((d) => !d.template)).toBe(true);
});
it("keeps the generic steps template when the position is unknown", () => {
const steps = variableGroupsFor(undefined).find((g) => g.id === "steps")!;
expect(steps.variables.map((d) => d.path)).toEqual([
"steps.1.body",
"steps.1.status",
]);
});
});
describe("unknownReferences", () => {
it("passes catalogue paths and flags typos", () => {
expect(
unknownReferences("{{document.filename}} at {{run.timestamp}}"),
).toEqual([]);
expect(unknownReferences("{{document.flename}}")).toEqual([
"document.flename",
]);
});
it("tolerates spaces inside the braces, like the backend resolver", () => {
expect(unknownReferences("{{ document.filename }}")).toEqual([]);
});
it("allows dotted paths into deep variables only", () => {
expect(unknownReferences("{{classification.confidence}}")).toEqual([]);
// document.filename is a scalar; reaching inside it fails at run time.
expect(unknownReferences("{{document.filename.x}}")).toEqual([
"document.filename.x",
]);
});
it("accepts steps references only for steps that ran earlier", () => {
expect(
unknownReferences("{{steps.1.body.ocs.data.url}}", undefined, 2),
).toEqual([]);
expect(unknownReferences("{{steps.1.status}}", undefined, 2)).toEqual([]);
// The self- and forward references that fail every run.
expect(unknownReferences("{{steps.2.body}}", undefined, 2)).toEqual([
"steps.2.body",
]);
expect(unknownReferences("{{steps.1.body}}", undefined, 1)).toEqual([
"steps.1.body",
]);
});
it("accepts any step number when the position is unknown", () => {
expect(unknownReferences("{{steps.7.body.url}}")).toEqual([]);
});
it("rejects steps shapes the executor cannot resolve", () => {
expect(unknownReferences("{{steps.1.status.x}}", undefined, 2)).toEqual([
"steps.1.status.x",
]);
expect(unknownReferences("{{steps.1}}", undefined, 2)).toEqual(["steps.1"]);
});
it("ignores prose braces that are not references", () => {
expect(unknownReferences("a {{ b c }} d")).toEqual([]);
});
it("honours the groups it is given", () => {
const withoutConditionals = variableGroupsFor({
classification: false,
sensitivityLabel: false,
});
expect(
unknownReferences("{{classification.label}}", withoutConditionals),
).toEqual(["classification.label"]);
});
});
@@ -0,0 +1,281 @@
/**
* The variables an operator can reference in an integration step, as data.
*
* One catalogue drives both the reference panel and the `{{` autocomplete, so the list the
* operator reads and the list the editor offers can never drift apart. The shape mirrors what the
* backend actually resolves - `DocumentContext` for document/run scope, the executor's step
* reports for `steps.N` - and nothing else, because offering a variable the server would reject
* teaches the operator the system lies.
*/
const PREFIX = "portal.policies.variables";
export interface VariableDef {
/** The dotted path as typed inside braces, e.g. "document.filename". */
path: string;
/** The name an operator reads, e.g. "File name". Use variableLabel to resolve it. */
labelKey: string;
descKey: string;
/** True when the path contains a part the operator must edit (the N in steps.N). */
template?: boolean;
/** True when the value is JSON a dotted path may reach inside (classification, steps.N.body). */
deep?: boolean;
}
export interface VariableGroup {
id: "document" | "run" | "classification" | "sensitivityLabel" | "steps";
labelKey: string;
descKey: string;
variables: VariableDef[];
/**
* A worked example shown as such, distinct from the variable rows. This is where a
* vendor-specific path (Nextcloud's ocs.data.url) is taught without being listed as a variable
* that always exists - listed, it would fail every run where step N is a different vendor.
*/
example?: { path: string; descKey: string };
}
const v = (path: string, template = false): VariableDef => ({
path,
labelKey: `${PREFIX}.labels.${path.replaceAll(".", "_")}`,
descKey: `${PREFIX}.defs.${path.replaceAll(".", "_")}`,
template,
});
/** A steps.N def: the name and description are shared across every N, so both are set here. */
const stepVar = (n: number | string, kind: "body" | "status"): VariableDef => ({
path: `steps.${n}.${kind}`,
labelKey: `${PREFIX}.labels.steps_${kind}`,
descKey: `${PREFIX}.defs.steps_${kind}`,
template: typeof n === "string",
});
/** Grouped for the reference panel; flattened for the autocomplete. */
export const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "document",
labelKey: `${PREFIX}.groups.document.label`,
descKey: `${PREFIX}.groups.document.description`,
variables: [
v("document.filename"),
v("document.extension"),
v("document.contentType"),
v("document.sizeBytes"),
v("document.sha256"),
v("document.base64"),
v("document.pageCount"),
v("document.encrypted"),
v("document.title"),
v("document.author"),
v("document.subject"),
v("document.keywords"),
v("document.creator"),
v("document.producer"),
v("document.created"),
v("document.modified"),
],
},
{
id: "run",
labelKey: `${PREFIX}.groups.run.label`,
descKey: `${PREFIX}.groups.run.description`,
variables: [v("run.policyName"), v("run.runId"), v("run.timestamp")],
},
{
id: "classification",
labelKey: `${PREFIX}.groups.classification.label`,
descKey: `${PREFIX}.groups.classification.description`,
// classification is the verdict's whole JSON, so dotted paths may reach inside it.
variables: [
v("classification.label"),
{ ...v("classification"), deep: true },
],
},
{
id: "sensitivityLabel",
labelKey: `${PREFIX}.groups.sensitivityLabel.label`,
descKey: `${PREFIX}.groups.sensitivityLabel.description`,
variables: [
v("sensitivityLabel.name"),
v("sensitivityLabel.labelId"),
v("sensitivityLabel.protected"),
],
},
{
id: "steps",
labelKey: `${PREFIX}.groups.steps.label`,
descKey: `${PREFIX}.groups.steps.description`,
// Only the two shapes every step report actually has; the worked example below teaches
// reaching deeper. N is the 1-based position of the earlier step whose answer is wanted.
// This generic form is offered only when the step's own position is unknown - when it is
// known, variableGroupsFor swaps in one concrete pair per earlier step.
variables: [stepVar("1", "body"), stepVar("1", "status")],
example: {
path: "steps.1.body.ocs.data.url",
descKey: `${PREFIX}.groups.steps.example`,
},
},
];
export const ALL_VARIABLES: VariableDef[] = VARIABLE_GROUPS.flatMap(
(group) => group.variables,
);
/**
* Which conditional scopes this team can actually use. Document, run and steps are always real;
* classification only resolves where a classification policy runs, and sensitivityLabel only
* where Purview is connected - offering either to a team without them is teaching a variable
* that will fail their runs.
*/
export interface VariableAvailability {
classification: boolean;
sensitivityLabel: boolean;
}
/**
* The groups to offer; undefined availability (still loading, or unknowable) offers everything.
*
* `stepPosition` is the configured step's own 1-based place in the chain. With it known, the
* steps group offers one concrete pair per *earlier* step - and nothing at all for step 1 -
* because the only alternative is a steps.1 template that, accepted verbatim in step 1, is a
* self-reference the backend rightly fails every run on.
*/
export function variableGroupsFor(
availability: VariableAvailability | undefined,
stepPosition?: number,
): VariableGroup[] {
const groups = VARIABLE_GROUPS.filter((group) =>
group.id === "classification"
? (availability?.classification ?? true)
: group.id === "sensitivityLabel"
? (availability?.sensitivityLabel ?? true)
: true,
);
if (stepPosition === undefined) return groups;
return groups.flatMap((group) => {
if (group.id !== "steps") return [group];
if (stepPosition <= 1) return [];
const variables: VariableDef[] = [];
for (let n = 1; n < stepPosition; n++) {
variables.push(stepVar(n, "body"), stepVar(n, "status"));
}
return [{ ...group, variables }];
});
}
/**
* The `{{references}}` in `text` that name nothing the run can substitute, deduplicated.
*
* The backend hard-fails an unknown path at run time, so a typo saved today fails every run
* tomorrow; this is the save-time check that catches it while the fix is one keystroke away.
* Matches the backend's tolerance for spaces inside the braces. steps.N references are valid
* for any earlier step (N below `stepPosition` when the position is known); `deep` variables
* (classification, steps.N.body) accept dotted paths reaching inside their JSON.
*/
export function unknownReferences(
text: string,
groups: VariableGroup[] = VARIABLE_GROUPS,
stepPosition?: number,
): string[] {
const out = new Set<string>();
for (const match of text.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
const path = match[1];
if (!referenceValid(path, groups, stepPosition)) out.add(path);
}
return [...out];
}
function referenceValid(
path: string,
groups: VariableGroup[],
stepPosition?: number,
): boolean {
const step = /^steps\.(\d+)\.(?:body(?:\.\w+)*|status)$/.exec(path);
if (step) {
if (!groups.some((group) => group.id === "steps")) return false;
const n = Number(step[1]);
return n >= 1 && (stepPosition === undefined || n < stepPosition);
}
return groups.some((group) =>
group.variables.some(
(def) =>
def.path === path || (def.deep && path.startsWith(def.path + ".")),
),
);
}
/** Translate function shape, narrowed to what this module needs. */
type Translate = (key: string, options?: Record<string, unknown>) => string;
/**
* The name to show for a variable, e.g. "File name" or "Full response from step 2".
*
* The field draws this instead of the path, because a dotted path is a location and an operator
* is choosing a thing. A steps.N variable carries its step number, since that is the part of the
* path that actually carries meaning - it is the dependency on an earlier step.
*/
export function variableLabel(def: VariableDef, t: Translate): string {
const base = t(def.labelKey);
const step = /^steps\.(\d+)\./.exec(def.path);
return step ? t(`${PREFIX}.fromStep`, { label: base, n: step[1] }) : base;
}
/** The catalogued definition for a saved path, or undefined for one we ship no name for. */
export function defForPath(
path: string,
groups: VariableGroup[] = VARIABLE_GROUPS,
): VariableDef | undefined {
return groups
.flatMap((group) => group.variables)
.find((def) => def.path === path);
}
/**
* The variables matching what the operator has typed after a trigger.
*
* Matches the name as well as the path, because the name is now what the list shows: typing
* "link" has to find the share link even though its path says "ocs.data.url". Dots count as
* normal characters, so "document.s" still narrows to sha256/sizeBytes/subject.
*/
export function variableSuggestions(
partial: string,
groups: VariableGroup[] = VARIABLE_GROUPS,
t?: Translate,
): VariableDef[] {
const all = groups.flatMap((group) => group.variables);
const q = partial.trim().toLowerCase();
if (q === "") return all;
return all.filter(
(def) =>
def.path.toLowerCase().includes(q) ||
(t !== undefined && variableLabel(def, t).toLowerCase().includes(q)),
);
}
/**
* The characters that open the variable list. `@` and `/` are the ones we teach - one unshifted
* key, borrowed from every chat app - and `{{` is kept so anyone who learned the old syntax keeps
* their habit and still never ends up with raw braces on screen.
*/
const TRIGGER = /(?:^|[\s\n])(@|\/|\{\{)([\w.]*)$/;
/**
* Where an open reference starts in `text` before `cursor`, or null when the cursor is not in one.
*
* A trigger only counts at the start of a word - the beginning of the field, or straight after a
* space or newline - so `bob@acme.com` and `and/or` are ordinary text, which matters because these
* are exactly the fields people type addresses and paths into. The partial may only be path
* characters, so prose after a trigger closes the list rather than hijacking the sentence.
*/
export function openReferenceAt(
text: string,
cursor: number,
): { start: number; partial: string } | null {
const before = text.slice(0, cursor);
const match = TRIGGER.exec(before);
if (!match) return null;
return {
start: before.length - (match[1].length + match[2].length),
partial: match[2],
};
}
@@ -0,0 +1,73 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ConnectionTypePicker } from "@portal/components/sources/ConnectionTypePicker";
import { CREATABLE_CONNECTION_TYPES } from "@portal/components/sources/connectionTypes";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const wrap = (ui: React.ReactNode) => <MantineProvider>{ui}</MantineProvider>;
const typesFor = (...ids: string[]) =>
CREATABLE_CONNECTION_TYPES.filter((type) => ids.includes(type.id));
const INFO = "portal.connections.picker2.tasksInfo";
describe("ConnectionTypePicker task info", () => {
it("offers an (i) only for integrations that add tasks", () => {
render(
wrap(
<ConnectionTypePicker
types={typesFor("jira", "s3")}
onPick={vi.fn()}
/>,
),
);
// Jira adds tasks, so its card gets one info button; S3 (a bucket) adds none.
expect(screen.getAllByRole("button", { name: INFO })).toHaveLength(1);
});
it("reveals the tasks an integration unlocks, and hides them again", async () => {
render(
wrap(<ConnectionTypePicker types={typesFor("jira")} onPick={vi.fn()} />),
);
expect(
screen.queryByText("portal.policies.operations.jiraComment.label"),
).toBeNull();
await userEvent.click(screen.getByRole("button", { name: INFO }));
expect(
screen.getByText("portal.policies.operations.jiraAttach.label"),
).toBeTruthy();
expect(
screen.getByText("portal.policies.operations.jiraComment.label"),
).toBeTruthy();
expect(
screen.getByText("portal.policies.operations.jiraTransition.label"),
).toBeTruthy();
// Clicking the (i) again collapses the list.
await userEvent.click(screen.getByRole("button", { name: INFO }));
expect(
screen.queryByText("portal.policies.operations.jiraComment.label"),
).toBeNull();
});
it("picks the integration when the card itself is clicked, not the (i)", async () => {
const onPick = vi.fn();
render(
wrap(<ConnectionTypePicker types={typesFor("jira")} onPick={onPick} />),
);
await userEvent.click(
screen.getByText("portal.connections.types.jira.label"),
);
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick.mock.calls[0][0].id).toBe("jira");
});
});
@@ -1,12 +1,14 @@
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchRoundedIcon from "@mui/icons-material/SearchRounded";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import {
CONNECTION_CATEGORIES,
searchConnectionTypes,
type ConnectionCategory,
type CreatableConnectionType,
} from "@portal/components/sources/connectionTypes";
import { operationsForConnectionType } from "@portal/components/policies/stepOperations";
import { BrandMark } from "@portal/components/BrandMarks";
/**
@@ -102,36 +104,92 @@ function Grid({
types: CreatableConnectionType[];
onPick: (type: CreatableConnectionType) => void;
}) {
const { t } = useTranslation();
return (
<div className="portal-conn-picker__grid">
{types.map((type) => {
const label = t(type.labelKey);
return (
<button
key={type.id}
type="button"
className={
"portal-conn-picker__card" +
(type.kind === "custom"
? " portal-conn-picker__card--advanced"
: "")
}
onClick={() => onPick(type)}
>
{/* The vendor's real mark, full colour on the card surface. */}
<span className="portal-conn-picker__mark" aria-hidden>
<BrandMark id={type.id} size={20} />
</span>
<span className="portal-conn-picker__card-text">
<span className="portal-conn-picker__card-name">{label}</span>
<span className="portal-conn-picker__card-desc">
{t(type.descriptionKey)}
</span>
</span>
</button>
);
})}
{types.map((type) => (
<TypeCard key={type.id} type={type} onPick={onPick} />
))}
</div>
);
}
/**
* One vendor card. Its (i) expands the tasks the integration unlocks, inline below the card -
* the picker scrolls, so a floating popover would be clipped at its edges. No (i) for entries
* that add no policy steps (a bucket, a label store).
*/
function TypeCard({
type,
onPick,
}: {
type: CreatableConnectionType;
onPick: (type: CreatableConnectionType) => void;
}) {
const { t } = useTranslation();
const [showTasks, setShowTasks] = useState(false);
const panelId = useId();
const tasks = operationsForConnectionType(type.id);
const label = t(type.labelKey);
return (
<div className="portal-conn-picker__card-wrap">
<button
type="button"
className={
"portal-conn-picker__card" +
(type.kind === "custom"
? " portal-conn-picker__card--advanced"
: "") +
(tasks.length > 0 ? " portal-conn-picker__card--has-tasks" : "")
}
onClick={() => onPick(type)}
>
{/* The vendor's real mark, full colour on the card surface. */}
<span className="portal-conn-picker__mark" aria-hidden>
<BrandMark id={type.id} size={20} />
</span>
<span className="portal-conn-picker__card-text">
<span className="portal-conn-picker__card-name">{label}</span>
<span className="portal-conn-picker__card-desc">
{t(type.descriptionKey)}
</span>
</span>
</button>
{tasks.length > 0 && (
<button
type="button"
className="portal-conn-picker__info"
aria-label={t("portal.connections.picker2.tasksInfo", {
name: label,
})}
aria-expanded={showTasks}
aria-controls={showTasks ? panelId : undefined}
onClick={() => setShowTasks((open) => !open)}
>
<InfoOutlinedIcon fontSize="inherit" />
</button>
)}
{showTasks && (
<div id={panelId} className="portal-conn-picker__tasks">
<p className="portal-conn-picker__tasks-title">
{t("portal.connections.picker2.tasksTitle")}
</p>
<ul className="portal-conn-picker__tasks-list">
{tasks.map((op) => (
<li key={op.id} className="portal-conn-picker__task">
<span className="portal-conn-picker__task-name">
{t(op.labelKey)}
</span>
<span className="portal-conn-picker__task-desc">
{t(op.descriptionKey)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render as baseRender,
screen,
@@ -286,6 +287,116 @@ describe("SourceModal", () => {
);
});
it("ignores a stale source fetch after the modal switches to another source", async () => {
// Two edits in flight: A resolves last (stale). Without the guard its late resolution would
// clobber B's fields, and a save would then write A's config onto source B.
const makeDeferred = () => {
let resolve!: (value: unknown) => void;
const promise = new Promise<unknown>((r) => (resolve = r));
return { promise, resolve };
};
const aDef = makeDeferred();
const bDef = makeDeferred();
fetchSource.mockImplementation((id: string) =>
id === "src-A" ? aDef.promise : bDef.promise,
);
const view = render(
<SourceModal open sourceId="src-A" onClose={vi.fn()} onSaved={vi.fn()} />,
);
// Switch to B while A is still pending; the effect cleanup marks A's fetch stale.
view.rerender(
<SourceModal open sourceId="src-B" onClose={vi.fn()} onSaved={vi.fn()} />,
);
bDef.resolve({
id: "src-B",
name: "Bravo",
type: "folder",
options: { directory: "/bravo", mode: "consume" },
enabled: true,
});
const directory = (await screen.findByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
)) as HTMLInputElement;
await waitFor(() => expect(directory.value).toBe("/bravo"));
// The stale fetch resolves last; the guard must stop it overwriting B. act() flushes the
// React update the resolution schedules - a bare microtask hop would pass even unguarded.
await act(async () => {
aDef.resolve({
id: "src-A",
name: "Alpha",
type: "folder",
options: { directory: "/alpha", mode: "consume" },
enabled: true,
});
});
expect(directory.value).toBe("/bravo");
});
it("does not leave the previous source's values saveable when the edit fetch fails", async () => {
// Edit A loads fine; switching to B fails its fetch. B's form must not sit on A's values -
// saving there would write A's config onto B.
fetchSource.mockImplementation((id: string) =>
id === "src-A"
? Promise.resolve({
id: "src-A",
name: "Alpha",
type: "folder",
options: { directory: "/alpha", mode: "consume" },
enabled: true,
})
: Promise.reject(new Error("boom")),
);
const view = render(
<SourceModal open sourceId="src-A" onClose={vi.fn()} onSaved={vi.fn()} />,
);
const directory = (await screen.findByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
)) as HTMLInputElement;
await waitFor(() => expect(directory.value).toBe("/alpha"));
view.rerender(
<SourceModal open sourceId="src-B" onClose={vi.fn()} onSaved={vi.fn()} />,
);
expect(await screen.findByText("boom")).toBeInTheDocument();
const after = screen.getByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
) as HTMLInputElement;
expect(after.value).toBe("");
expect(
screen.getByText("portal.sources.builder.save").closest("button"),
).toBeDisabled();
});
it("does not stick a spinner on create after an edit was closed mid-fetch", async () => {
// The modal stays mounted; only `open` toggles. Closing an edit before its fetch resolves
// used to leave loading=true forever, so the next create showed a spinner and no form.
fetchSource.mockImplementation(() => new Promise(() => {}));
const view = render(
<SourceModal open sourceId="src-A" onClose={vi.fn()} onSaved={vi.fn()} />,
);
view.rerender(
<SourceModal
open={false}
sourceId="src-A"
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
view.rerender(
<SourceModal open sourceId={null} onClose={vi.fn()} onSaved={vi.fn()} />,
);
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
expect(
screen.getByLabelText(/portal\.integrations\.typedName/),
).toBeInTheDocument();
});
it("deletes an existing source after the inline confirm", async () => {
fetchSource.mockResolvedValue({
id: "src-9",
@@ -143,19 +143,29 @@ export function SourceModal({
setReveal(null);
setSubmitting(false);
setDeleting(false);
// The modal is permanently mounted, so an edit closed mid-fetch leaves loading stuck true
// (its cleanup stops the .finally from resetting it) unless every open resets it.
setLoading(false);
// Reset before any fetch too: a rejected edit fetch must not leave the previous source's
// values in a saveable form - saving would write that source's config onto this one.
setType(OFFERED_TYPES[0]);
setName("");
setOptions(defaultOptions(OFFERED_TYPES[0]));
setEnabled(true);
setLoaded(null);
if (!sourceId) {
setStage("type");
setType(OFFERED_TYPES[0]);
setName("");
setOptions(defaultOptions(OFFERED_TYPES[0]));
setEnabled(true);
setLoaded(null);
return;
}
setStage("configure");
setLoading(true);
// Guard against a stale fetch: a fast close+reopen to another source would otherwise let the
// earlier resolution clobber this one's state, and a save then write one source's config onto
// another.
let ignore = false;
fetchSource(sourceId)
.then((source) => {
if (ignore) return;
const resolved = typeFor(source.type);
setLoaded(source);
setType(resolved);
@@ -163,8 +173,15 @@ export function SourceModal({
setOptions(optionsFor(resolved, source.options));
setEnabled(source.enabled ?? true);
})
.catch((e) => setError(errorMessage(e)))
.finally(() => setLoading(false));
.catch((e) => {
if (!ignore) setError(errorMessage(e));
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [open, sourceId]);
function chooseType(next: CreatableSourceType) {
@@ -12,6 +12,7 @@ export const qk = {
// Tier-independent
policiesList: () => ["portal", "policies", "list"] as const,
policyRuns: () => ["portal", "policies", "runs"] as const,
integrations: () => ["portal", "integrations"] as const,
sources: () => ["portal", "sources"] as const,
pipelines: () => ["portal", "pipelines"] as const,
fleetStats: () => ["portal", "fleetStats"] as const,
@@ -304,6 +304,87 @@ button.portal-integrations__row[aria-expanded="true"] {
bottom: 50%;
}
/* The (i) on a row: quiet until hovered, so the row's name stays the loudest thing on it.
Pinned to the name column's edge so the icons form one rail instead of trailing each name. */
.portal-integrations__info {
flex: none;
margin-left: auto;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: var(--radius-sm, 0.375rem);
background: transparent;
color: var(--c-text-subtle);
font-size: 1.0625rem;
line-height: 0;
cursor: pointer;
}
.portal-integrations__info:hover {
color: var(--c-text);
background: var(--c-hover);
}
/* Tasks a vendor unlocks, shown inside the expanded panel with the same tree rail as instances. */
.portal-integrations__instance-tasks {
padding: 0.5rem 1rem 0.5625rem 3.35rem;
position: relative;
}
.portal-integrations__instance-tasks::before {
content: "";
position: absolute;
left: 1.65rem;
top: 0;
bottom: 0;
width: 2px;
background: var(--c-border);
}
.portal-integrations__instance-tasks + .portal-integrations__instance {
border-top: 1px solid var(--c-border-subtle);
}
.portal-integrations__tasks-title {
display: block;
margin: 0 0 0.3125rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--c-text-muted);
}
.portal-integrations__tasks-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin: 0;
padding: 0;
list-style: none;
}
.portal-integrations__task {
display: flex;
flex-wrap: wrap;
column-gap: 0.5rem;
align-items: baseline;
}
.portal-integrations__task-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--c-text);
}
.portal-integrations__task-desc {
font-size: 0.75rem;
color: var(--c-text-muted);
}
.portal-integrations__instance-name {
font-size: 0.8125rem;
font-weight: 600;
@@ -4,6 +4,7 @@ import {
render as baseRender,
screen,
waitFor,
within,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
@@ -147,4 +148,86 @@ describe("Integrations view", () => {
screen.queryByText("portal.connections.types.api.label"),
).not.toBeInTheDocument();
});
it("expands an available row's (i) into the tasks that integration adds", async () => {
fetchIntegrations.mockResolvedValue([]);
render(<Integrations />);
await screen.findByText("portal.connections.types.jira.label");
expect(
screen.queryByText("portal.policies.operations.jiraComment.label"),
).not.toBeInTheDocument();
// Every step-backed vendor gets an (i); S3 (a bucket, no steps) gets none, so
// there are fewer (i)s than Connect buttons.
const infos = screen.getAllByRole("button", {
name: "portal.connections.picker2.tasksInfo",
});
expect(infos.length).toBeGreaterThan(0);
expect(infos.length).toBeLessThan(
screen.getAllByText("portal.integrations.connect").length,
);
// The Jira row's (i) reveals all three of its tasks inline.
const jiraRow = screen
.getByText("portal.connections.types.jira.label")
.closest(".portal-integrations__group") as HTMLElement;
fireEvent.click(
within(jiraRow).getByRole("button", {
name: "portal.connections.picker2.tasksInfo",
}),
);
expect(
await screen.findByText("portal.policies.operations.jiraAttach.label"),
).toBeInTheDocument();
expect(
screen.getByText("portal.policies.operations.jiraComment.label"),
).toBeInTheDocument();
expect(
screen.getByText("portal.policies.operations.jiraTransition.label"),
).toBeInTheDocument();
});
it("keeps the custom-call task out of a connected group when the server withholds it", async () => {
// A stored custom-API connection still lists; the task the server would refuse must not
// be advertised in its panel (same gate the catalogue honours).
const custom = {
id: 3,
integrationType: "API",
name: "In-house API",
config: { baseUrl: "https://api.internal" },
canManage: true,
} as unknown as IntegrationConfig;
fetchIntegrations.mockResolvedValue([custom]);
render(<Integrations />);
fireEvent.click(
await screen.findByText("portal.connections.types.api.label"),
);
expect(
await screen.findByText("portal.integrations.addAnother"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.policies.operations.customApiCall.label"),
).not.toBeInTheDocument();
});
it("shows a connected group's tasks in its expanded panel", async () => {
const slack = {
id: 9,
integrationType: "API",
name: "Ops alerts",
config: { presetId: "slack", baseUrl: "https://hooks.slack.com/x" },
canManage: true,
} as unknown as IntegrationConfig;
fetchIntegrations.mockResolvedValue([slack]);
render(<Integrations />);
fireEvent.click(
await screen.findByText("portal.connections.types.slack.label"),
);
expect(
await screen.findByText("portal.policies.operations.slackNotify.label"),
).toBeInTheDocument();
});
});
+124 -27
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import SearchRoundedIcon from "@mui/icons-material/SearchRounded";
import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Banner, Button, Skeleton } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import {
@@ -22,7 +23,10 @@ import {
type ConnectionCategory,
type CreatableConnectionType,
} from "@portal/components/sources/connectionTypes";
import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations";
import {
STEP_OPERATIONS,
operationsForConnectionType,
} from "@portal/components/policies/stepOperations";
import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes";
import "@portal/views/Integrations.css";
@@ -361,6 +365,10 @@ export function Integrations() {
</button>
{open && (
<div className="portal-integrations__instances">
<TasksList
typeId={type.id}
allowCustom={capabilities?.customApi !== false}
/>
{list.map((connection) => (
<div
key={connection.id}
@@ -420,31 +428,13 @@ export function Integrations() {
</div>
)}
{availableTypes.map((type) => (
<div key={type.id} className="portal-integrations__row">
<span className="portal-integrations__name">
<BrandMark id={type.id} size={22} />
<span className="portal-integrations__name-text">
<span className="portal-integrations__label">
{t(type.labelKey)}
</span>
<span className="portal-integrations__detail">
{t(type.descriptionKey)}
</span>
</span>
</span>
<span className="portal-integrations__chips">
{worksWith(type).map(chip)}
</span>
<span className="portal-integrations__status">
<Button
variant="secondary"
size="sm"
onClick={() => openCreate(type.id)}
>
{t("portal.integrations.connect")}
</Button>
</span>
</div>
<AvailableRow
key={type.id}
type={type}
chips={worksWith(type).map(chip)}
allowCustom={capabilities?.customApi !== false}
onConnect={() => openCreate(type.id)}
/>
))}
{comingSoon.length > 0 && (
@@ -494,6 +484,113 @@ export function Integrations() {
);
}
/**
* The task list an integration unlocks, shown inside an expanded row panel. The custom-call
* entry follows the same server gate the rest of the UI honours - listing it for a team the
* server refuses it to would advertise a task that cannot run.
*/
function TasksList({
typeId,
allowCustom,
}: {
typeId: string;
allowCustom: boolean;
}) {
const { t } = useTranslation();
const tasks = operationsForConnectionType(typeId).filter(
(op) => allowCustom || !op.custom,
);
if (tasks.length === 0) return null;
return (
<div className="portal-integrations__instance-tasks">
<span className="portal-integrations__tasks-title">
{t("portal.connections.picker2.tasksTitle")}
</span>
<ul className="portal-integrations__tasks-list">
{tasks.map((op) => (
<li key={op.id} className="portal-integrations__task">
<span className="portal-integrations__task-name">
{t(op.labelKey)}
</span>
<span className="portal-integrations__task-desc">
{t(op.descriptionKey)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* One not-yet-connected integration row. The (i) answers "what would connecting this let me do?"
* right on the row, expanding the same kind of inline panel a connected row uses - a floating
* popover would be clipped by the table's overflow. No (i) for entries that add no steps (a
* bucket, a label store).
*/
function AvailableRow({
type,
chips,
allowCustom,
onConnect,
}: {
type: CreatableConnectionType;
chips: React.ReactNode;
allowCustom: boolean;
onConnect: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const panelId = useId();
const hasTasks =
operationsForConnectionType(type.id).filter(
(op) => allowCustom || !op.custom,
).length > 0;
return (
<div className="portal-integrations__group">
<div className="portal-integrations__row">
<span className="portal-integrations__name">
<BrandMark id={type.id} size={22} />
<span className="portal-integrations__name-text">
<span className="portal-integrations__label">
{t(type.labelKey)}
</span>
<span className="portal-integrations__detail">
{t(type.descriptionKey)}
</span>
</span>
{hasTasks && (
<button
type="button"
className="portal-integrations__info"
aria-label={t("portal.connections.picker2.tasksInfo", {
name: t(type.labelKey),
})}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
onClick={() => setOpen((o) => !o)}
>
<InfoOutlinedIcon fontSize="inherit" />
</button>
)}
</span>
<span className="portal-integrations__chips">{chips}</span>
<span className="portal-integrations__status">
<Button variant="secondary" size="sm" onClick={onConnect}>
{t("portal.integrations.connect")}
</Button>
</span>
</div>
{open && (
<div id={panelId} className="portal-integrations__instances">
<TasksList typeId={type.id} allowCustom={allowCustom} />
</div>
)}
</div>
);
}
function FilterChip({
active,
label,
@@ -49,6 +49,9 @@ vi.mock("@portal/api/sources", () => ({
const clearProcessedHistory = vi.fn();
vi.mock("@portal/api/policies", () => ({
clearProcessedHistory: (id: string) => clearProcessedHistory(id),
// The integration step's variable menu asks which scopes this team has.
fetchPoliciesList: () => Promise.resolve([]),
fetchPolicyRuns: () => Promise.resolve([]),
}));
const fetchS3Connections = vi.fn();
@@ -395,8 +395,8 @@ export function PipelineBuilder() {
// saving on it here where the fix is one click away.
const unconfiguredStepLabels = steps
.filter(
(step) =>
!integrationStepConfigured(step) ||
(step, i) =>
!integrationStepConfigured(step, i + 1) ||
stepNeedsConfiguring(step, allTools),
)
.map(stepLabel);
@@ -864,10 +864,15 @@ export function PipelineBuilder() {
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.chooseOperation")}
</span>
) : !integrationStepConfigured(step) ? (
) : !(step.params as Record<string, unknown>)
.connectionId ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.chooseAccount")}
</span>
) : !integrationStepConfigured(step, i + 1) ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.fixStepFields")}
</span>
) : null
) : stepRequiresUpload(step) ? (
<span className="portal-builder__step-note">
@@ -955,6 +960,9 @@ export function PipelineBuilder() {
{selectedStep ? (
<PipelineStepSettings
step={selectedStep}
stepPosition={
selectedIndex !== null ? selectedIndex + 1 : undefined
}
registry={allTools}
onChange={(params) =>
selectedIndex !== null &&
@@ -693,6 +693,92 @@
overflow: hidden;
}
/* The card sits in a wrapper so the (i) button and its task list anchor to it. */
.portal-conn-picker__card-wrap {
position: relative;
display: flex;
flex-direction: column;
}
/* Fill the wrapper rather than shrink to content, so cards keep an even grid. */
.portal-conn-picker__card-wrap .portal-conn-picker__card {
width: 100%;
flex: 1;
}
/* Reserve room top-right so a long name never runs under the (i). */
.portal-conn-picker__card--has-tasks {
padding-right: 2rem;
}
.portal-conn-picker__info {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: grid;
place-items: center;
width: 1.375rem;
height: 1.375rem;
padding: 0;
border: 0;
border-radius: var(--radius-sm, 0.375rem);
background: transparent;
color: var(--c-text-subtle);
font-size: 1rem;
line-height: 0;
cursor: pointer;
}
.portal-conn-picker__info:hover {
color: var(--c-conn-accent);
background: color-mix(in srgb, var(--c-conn-neutral) 10%, transparent);
}
/* Inline below the card - the picker scrolls, so a floating popover would clip at its edges. */
.portal-conn-picker__tasks {
margin-top: 0.25rem;
padding: 0.5rem 0.75rem 0.625rem;
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-sm, 0.375rem);
background: var(--c-surface-sunken);
text-align: left;
}
.portal-conn-picker__tasks-title {
margin: 0 0 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--c-text-muted);
}
.portal-conn-picker__tasks-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.portal-conn-picker__task {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-conn-picker__task-name {
font-size: 0.8125rem;
font-weight: 500;
}
.portal-conn-picker__task-desc {
font-size: 0.75rem;
line-height: 1.35;
color: var(--c-text-muted);
}
.portal-conn-picker__empty {
display: flex;
flex-direction: column;