mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c600c0d89 | ||
|
|
5ef2947c01 | ||
|
|
ca3b87e7fd | ||
|
|
7435b4f7ed | ||
|
|
a4f1696aba | ||
|
|
9eb8e2b7fd | ||
|
|
08d670cfb3 | ||
|
|
a9c50f2501 | ||
|
|
0b953f26b3 | ||
|
|
6b4e20b705 | ||
|
|
4e73d62530 | ||
|
|
8f50e373c4 |
+17
@@ -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;
|
||||
|
||||
+13
-2
@@ -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;
|
||||
}
|
||||
|
||||
+101
-1
@@ -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
|
||||
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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());
|
||||
|
||||
+92
-1
@@ -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
|
||||
|
||||
+128
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user