Skip to content

Commit 0b86948

Browse files
authored
feat: filter pubs using jsonata (#1421)
1 parent 8f68d37 commit 0b86948

15 files changed

Lines changed: 3703 additions & 250 deletions

File tree

core/actions/_lib/resolveAutomationInput.ts

Lines changed: 194 additions & 160 deletions
Large diffs are not rendered by default.

core/app/c/[communitySlug]/stages/manage/components/panel/automationsTab/StagePanelAutomationForm.tsx

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ import { Input } from "ui/input"
4545
import { Item, ItemContent, ItemHeader } from "ui/item"
4646
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"
4747
import { FormSubmitButton } from "ui/submit-button"
48+
import { Textarea } from "ui/textarea"
4849
import { type TokenContext, TokenProvider } from "ui/tokens"
50+
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"
4951
import { cn } from "utils"
5052

5153
import { ActionConfigBuilder } from "~/actions/_lib/ActionConfigBuilder"
@@ -386,62 +388,82 @@ const ResolverFieldSection = memo(
386388
form: UseFormReturn<CreateAutomationsSchema>
387389
resolver: string | null | undefined
388390
}) {
391+
const hasResolver = props.resolver !== undefined && props.resolver !== null
392+
393+
if (!hasResolver) {
394+
return (
395+
<Button
396+
type="button"
397+
variant="ghost"
398+
size="sm"
399+
className="h-8 p-0 text-muted-foreground text-xs"
400+
onClick={() => props.form.setValue("resolver", "")}
401+
>
402+
<Plus size={14} />
403+
Add resolver
404+
</Button>
405+
)
406+
}
407+
389408
return (
390409
<Controller
391410
control={props.form.control}
392411
name="resolver"
393412
render={({ field, fieldState }) => (
394-
<Field data-invalid={fieldState.invalid}>
395-
<div className="flex items-center justify-between">
396-
<FieldLabel>
397-
Resolver (optional)
398-
<InfoButton>
399-
<p className="text-xs">
400-
A JSONata expression to resolve a different Pub or transform
401-
JSON input before actions run.
402-
<br />
403-
<br />
404-
<strong>Comparison expressions</strong> like{" "}
405-
<code>$.json.some.id = $.pub.values.fieldname</code> will
406-
find a Pub where the field matches the left side value.
407-
<br />
408-
<br />
409-
<strong>Transform expressions</strong> can restructure the
410-
input data for the automation's actions.
411-
</p>
412-
</InfoButton>
413-
</FieldLabel>
414-
{props.resolver && (
415-
<Button
416-
type="button"
417-
variant="ghost"
418-
size="sm"
419-
className="h-7 text-neutral-500 text-xs"
420-
onClick={() => {
421-
field.onChange(undefined)
422-
}}
423-
>
424-
Remove resolver
425-
</Button>
426-
)}
413+
<div>
414+
<div className="mb-2 flex items-center justify-between">
415+
<div className="flex items-center gap-2">
416+
<Tooltip delayDuration={300}>
417+
<TooltipTrigger asChild>
418+
<span className="cursor-help rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-800 text-xs">
419+
Resolver
420+
</span>
421+
</TooltipTrigger>
422+
<TooltipContent className="max-w-sm text-xs">
423+
<p>
424+
A JSONata expression to resolve a different Pub or
425+
transform JSON input before actions run.
426+
</p>
427+
<p className="mt-2">
428+
<strong>Query:</strong>{" "}
429+
<code className="text-xs">
430+
{"$.pub.values.externalId = {{ $.json.body.id }}"}
431+
</code>
432+
</p>
433+
<p className="mt-1">
434+
<strong>Transform:</strong>{" "}
435+
<code className="text-xs">
436+
{'{ "title": $.json.body.name }'}
437+
</code>
438+
</p>
439+
</TooltipContent>
440+
</Tooltip>
441+
</div>
442+
<Button
443+
type="button"
444+
variant="ghost"
445+
size="sm"
446+
className="h-6 p-1 text-muted-foreground hover:text-destructive"
447+
onClick={() => field.onChange(undefined)}
448+
>
449+
<X size={14} />
450+
</Button>
427451
</div>
428-
<Input
452+
<Textarea
429453
{...field}
430454
value={field.value ?? ""}
431-
placeholder="Enter JSONata expression (e.g., $.json.articleId = $.pub.values.externalId)"
455+
placeholder="$.pub.values.externalId = {{ $.json.body.articleId }}"
432456
className={cn(
433-
"font-mono text-sm",
457+
"min-h-[60px] border-amber-300 bg-white font-mono text-sm focus:border-amber-400 focus-visible:ring-amber-400 dark:bg-input/30 dark:text-white",
434458
fieldState.invalid && "border-red-300"
435459
)}
436460
/>
437-
<FieldDescription>
438-
Use a JSONata expression to resolve a Pub by comparing values, e.g.,{" "}
439-
<code>$.json.id = $.pub.values.externalId</code>
440-
</FieldDescription>
441461
{fieldState.error && (
442-
<FieldError className="text-xs">{fieldState.error.message}</FieldError>
462+
<p className="mt-1 text-destructive text-xs">
463+
{fieldState.error.message}
464+
</p>
443465
)}
444-
</Field>
466+
</div>
445467
)}
446468
/>
447469
)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# JSONata Query Syntax
2+
3+
Query language for filtering pubs. Based on JSONata with restrictions.
4+
5+
## Paths
6+
7+
### Pub field values
8+
9+
```
10+
$.pub.values.title
11+
$.pub.values.externalId
12+
```
13+
14+
### Builtin fields
15+
16+
```
17+
$.pub.id
18+
$.pub.createdAt
19+
$.pub.updatedAt
20+
$.pub.pubTypeId
21+
$.pub.title
22+
$.pub.stageId
23+
```
24+
25+
### Pub type
26+
27+
```
28+
$.pub.pubType.name
29+
$.pub.pubType.id
30+
```
31+
32+
## Comparison operators
33+
34+
### Equality
35+
36+
```
37+
$.pub.values.title = "Test"
38+
$.pub.values.count != 0
39+
```
40+
41+
### Numeric comparison
42+
43+
```
44+
$.pub.values.count > 10
45+
$.pub.values.count >= 10
46+
$.pub.values.count < 100
47+
$.pub.values.count <= 100
48+
```
49+
50+
### In array
51+
52+
```
53+
$.pub.values.status in ["draft", "published"]
54+
```
55+
56+
## Logical operators
57+
58+
### And
59+
60+
```
61+
$.pub.values.status = "published" and $.pub.values.count > 0
62+
```
63+
64+
### Or
65+
66+
```
67+
$.pub.values.status = "draft" or $.pub.values.status = "pending"
68+
```
69+
70+
### Not
71+
72+
```
73+
$not($.pub.values.archived = true)
74+
```
75+
76+
## String functions
77+
78+
### Contains
79+
80+
```
81+
$contains($.pub.values.title, "chapter")
82+
```
83+
84+
### Starts with
85+
86+
```
87+
$startsWith($.pub.values.title, "Introduction")
88+
```
89+
90+
### Ends with
91+
92+
```
93+
$endsWith($.pub.values.filename, ".pdf")
94+
```
95+
96+
## Case-insensitive matching
97+
98+
Wrap the path in `$lowercase()` or `$uppercase()`.
99+
100+
### Case-insensitive contains
101+
102+
```
103+
$contains($lowercase($.pub.values.title), "snap")
104+
```
105+
106+
### Case-insensitive equality
107+
108+
```
109+
$lowercase($.pub.values.status) = "draft"
110+
```
111+
112+
## Existence check
113+
114+
```
115+
$exists($.pub.values.optionalField)
116+
```
117+
118+
## Full-text search
119+
120+
Searches across all pub values using PostgreSQL full-text search.
121+
122+
```
123+
$search("climate change")
124+
```
125+
126+
## Relations
127+
128+
### Outgoing relations
129+
130+
Find pubs that have outgoing relations via a field.
131+
132+
```
133+
$.pub.out.contributors
134+
```
135+
136+
### Outgoing relations with filter
137+
138+
Filter by the relation value.
139+
140+
```
141+
$.pub.out.contributors[$.value = "Editor"]
142+
```
143+
144+
Filter by the related pub's field.
145+
146+
```
147+
$.pub.out.contributors[$.relatedPub.values.institution = "MIT"]
148+
```
149+
150+
Filter by the related pub's type.
151+
152+
```
153+
$.pub.out.contributors[$.relatedPub.pubType.name = "Author"]
154+
```
155+
156+
Combined filters.
157+
158+
```
159+
$.pub.out.contributors[$.value = "Editor" and $contains($.relatedPub.values.name, "Smith")]
160+
```
161+
162+
### Incoming relations
163+
164+
Find pubs that are referenced by other pubs via a field.
165+
166+
```
167+
$.pub.in.chapters
168+
```
169+
170+
Filter by the source pub.
171+
172+
```
173+
$.pub.in.chapters[$.relatedPub.values.title = "The Big Book"]
174+
```
175+
176+
## Interpolation (resolver only)
177+
178+
At the moment only used when configuring automations.
179+
180+
Use `{{ }}` to interpolate values from the context when using as a resolver.
181+
182+
```
183+
$.pub.values.externalId = {{ $.json.body.articleId }}
184+
```
185+
186+
The expression inside `{{ }}` is evaluated against the automation context before the query runs.
187+
188+
## Limits
189+
190+
Maximum relation depth: 3 levels.
191+
192+
## Unsupported
193+
194+
Variable assignment, lambda functions, recursive descent, and other advanced JSONata features are not supported.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ParsedCondition } from "./parser"
2+
3+
import { parseJsonataQuery } from "./parser"
4+
5+
export interface CompiledQuery {
6+
condition: ParsedCondition
7+
originalExpression: string
8+
}
9+
10+
/**
11+
* compiles a jsonata expression into a query that can be used for
12+
* both sql generation and in-memory filtering
13+
*/
14+
export function compileJsonataQuery(expression: string): CompiledQuery {
15+
const parsed = parseJsonataQuery(expression)
16+
return {
17+
condition: parsed.condition,
18+
originalExpression: expression,
19+
}
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export class JsonataQueryError extends Error {
2+
constructor(
3+
message: string,
4+
public readonly expression?: string
5+
) {
6+
super(message)
7+
this.name = "JsonataQueryError"
8+
}
9+
}
10+
11+
export class UnsupportedExpressionError extends JsonataQueryError {
12+
constructor(
13+
message: string,
14+
public readonly nodeType?: string,
15+
expression?: string
16+
) {
17+
super(message, expression)
18+
this.name = "UnsupportedExpressionError"
19+
}
20+
}
21+
22+
export class InvalidPathError extends JsonataQueryError {
23+
constructor(
24+
message: string,
25+
public readonly path?: string,
26+
expression?: string
27+
) {
28+
super(message, expression)
29+
this.name = "InvalidPathError"
30+
}
31+
}

0 commit comments

Comments
 (0)