feat(frontend): detect expr in flow input transform + filter right panel based on expr (#4651)

This commit is contained in:
Guilhem
2024-11-07 07:55:01 +01:00
committed by GitHub
parent 43b8a5ade3
commit e9b7dca203
6 changed files with 383 additions and 129 deletions
@@ -1,3 +1,12 @@
<script context="module">
const dynamicTemplateRegexPairs = buildPrefixRegex([
'flow_input',
'results',
'resource',
'variable'
])
</script>
<script lang="ts">
import type { Schema } from '$lib/common'
import type { InputCat } from '$lib/utils'
@@ -13,8 +22,9 @@
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { tick } from 'svelte'
import { fade } from 'svelte/transition'
import { buildPrefixRegex } from './flows/previousResults'
import type VariableEditor from './VariableEditor.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type { InputTransform } from '$lib/gen'
@@ -43,6 +53,7 @@
let monaco: SimpleEditor | undefined = undefined
let monacoTemplate: TemplateEditor | undefined = undefined
let argInput: ArgInput | undefined = undefined
let focusedPrev = false
const dispatch = createEventDispatcher()
@@ -59,6 +70,9 @@
const { shouldUpdatePropertyType, exprsToSet } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
const { inputMatches, focusProp, propPickerConfig } =
getContext<PropPickerWrapperContext>('PropPickerWrapper')
function setExpr() {
const newArg = $exprsToSet?.[argName]
if (newArg) {
@@ -133,6 +147,50 @@
}
}
let codeInjectionDetected = false
function checkCodeInjection(rawValue: string) {
if (!arg || !rawValue || rawValue.length < 3 || !dynamicTemplateRegexPairs) {
return undefined
}
if (rawValue.trim() !== rawValue) {
return undefined
}
const matches = dynamicTemplateRegexPairs.filter(({ regex }) => regex.test(rawValue))
if (matches.length > 0) {
return matches.map((m) => ({ word: m.word, value: rawValue }))
}
return undefined
}
async function setJavaScriptExpr(rawValue: string) {
arg = {
type: 'javascript',
expr: rawValue
}
propertyType = 'javascript'
monaco?.setCode('')
monaco?.insertAtCursor(rawValue)
await tick()
monaco?.focus()
await tick()
monaco?.setCursorToEnd()
}
function handleKeyUp(e: KeyboardEvent) {
if (
e.key === 'Tab' &&
isStaticTemplate(inputCat) &&
propertyType == 'static' &&
!noDynamicToggle &&
codeInjectionDetected
) {
setJavaScriptExpr(arg.value)
} else {
stepInputGen?.onKeyUp?.(e)
}
}
function isStaticTemplate(inputCat: InputCat) {
return inputCat === 'string' || inputCat === 'sql' || inputCat == 'yaml'
}
@@ -166,9 +224,24 @@
}
}
const { focusProp, propPickerConfig } = getContext<PropPickerWrapperContext>('PropPickerWrapper')
$: updateStaticInput(inputCat, propertyType, arg)
$: isStaticTemplate(inputCat) && propertyType == 'static' && setPropertyType(arg?.value)
function updateStaticInput(
inputCat: InputCat,
propertyType: 'static' | 'javascript',
arg: InputTransform | any
) {
if (!isStaticTemplate(inputCat)) {
return
}
if (propertyType == 'static') {
setPropertyType(arg?.value)
codeInjectionDetected = checkCodeInjection(arg?.value) != undefined
} else if (propertyType == 'javascript' && focused) {
setPropertyType(arg?.expr)
$inputMatches = checkCodeInjection(arg?.expr)
}
}
function setDefaultCode() {
if (!arg?.value) {
@@ -176,6 +249,14 @@
}
}
function updateFocused(newFocused: boolean) {
if (focusedPrev && !newFocused) {
$inputMatches = undefined
}
focusedPrev = focused
}
$: updateFocused(focused)
$: schema?.properties?.[argName].default && setDefaultCode()
let resourceTypes: string[] | undefined = undefined
@@ -325,13 +406,27 @@
<ToggleButton small label="Static" value="static" />
{/if}
<ToggleButton
small
light
tooltip="JavaScript expression ('flow_input' or 'results')."
value="javascript"
icon={FunctionSquare}
/>
{#if codeInjectionDetected && propertyType == 'static'}
<Button
size="xs2"
color="light"
btnClasses="font-normal text-xs w-fit bg-green-100 text-green-800 hover:bg-green-100 dark:text-green-300 dark:bg-green-700 dark:hover:bg-green-600"
on:click={() => setJavaScriptExpr(arg.value)}
>
<span class="font-normal whitespace-nowrap flex gap-2 items-center"
><FunctionSquare size={14} /> detected -
<span class="font-bold">TAB</span>
</span>
</Button>
{:else}
<ToggleButton
small
light
tooltip="JavaScript expression ('flow_input' or 'results')."
value="javascript"
icon={FunctionSquare}
/>
{/if}
</ToggleButtonGroup>
</div>
@@ -363,7 +458,7 @@
<div class="max-w-xs" />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="relative" on:keyup={stepInputGen?.onKeyUp}>
<div class="relative" on:keyup={handleKeyUp}>
<!-- {#if $propPickerConfig?.propName == argName && $propPickerConfig?.insertionMode == 'connect'}
<span
class={'text-white z-50 px-1 text-2xs py-0.5 font-bold rounded-t-sm w-fit absolute top-0 right-0 bg-blue-500'}
@@ -405,6 +405,15 @@
editor && editor.dispose()
} catch (err) {}
})
export function setCursorToEnd(): void {
if (editor) {
const lastLine = editor.getModel()?.getLineCount() ?? 1
const lastColumn = editor.getModel()?.getLineMaxColumn(lastLine) ?? 1
editor.setPosition({ lineNumber: lastLine, column: lastColumn })
editor.focus()
}
}
</script>
<EditorTheme />
@@ -77,6 +77,8 @@
.filter(([i, f, m]) => f.length > 0)
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
inputMatches: writable(undefined),
filteredPickableProperties: writable(undefined),
focusProp: () => {},
propPickerConfig: writable(undefined),
clearFocus: () => {}
@@ -260,3 +260,40 @@ declare const approvers: string
}
`
}
export function buildPrefixRegex(words: string[]): Array<{ regex: RegExp; word: string }> {
return words.map((word) => {
const prefixes: string[] = []
for (let i = 1; i <= word.length; i++) {
prefixes.push(word.slice(0, i) + '$')
}
prefixes.push(word + '\\.')
prefixes.push(word + '\\[')
return {
regex: new RegExp(`^(${prefixes.join('|')}).*`),
word
}
})
}
export function filterNestedObject(obj: any, nestedKeys: string[]) {
if (nestedKeys.length === 0) return {}
if (nestedKeys.length === 1) {
if (nestedKeys[0] === '') {
return obj
}
const regexes = buildPrefixRegex(Object.keys(obj))
const matches = regexes.filter(({ regex }) => regex.test(nestedKeys[0]))
return Object.fromEntries(
Object.entries(obj).filter(([key]) => matches.some(({ word }) => word === key))
)
}
const [key, ...rest] = nestedKeys
if (obj && typeof obj === 'object' && key in obj) {
const result = {}
result[key] = filterNestedObject(obj[key], rest)
return result
}
return {}
}
@@ -11,6 +11,8 @@
export type PropPickerWrapperContext = {
propPickerConfig: Writable<PropPickerConfig | undefined>
inputMatches: Writable<{ word: string; value: string }[] | undefined>
filteredPickableProperties: Writable<PickableProperties | undefined>
focusProp: (propName: string, insertionMode: InsertionMode, onSelect: SelectCallback) => void
clearFocus: () => void
}
@@ -37,10 +39,14 @@
export let noPadding: boolean = false
const propPickerConfig = writable<PropPickerConfig | undefined>(undefined)
const filteredPickableProperties = writable<PickableProperties | undefined>(undefined)
const inputMatches = writable<{ word: string; value: string }[] | undefined>(undefined)
const dispatch = createEventDispatcher()
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
propPickerConfig,
inputMatches,
filteredPickableProperties,
focusProp: (propName, insertionMode, onSelect) => {
propPickerConfig.set({
propName,
@@ -9,6 +9,7 @@
import { keepByKey } from './utils'
import type { PickableProperties } from '../flows/previousResults'
import ClearableInput from '../common/clearableInput/ClearableInput.svelte'
import { filterNestedObject } from '../flows/previousResults'
export let pickableProperties: PickableProperties
export let displayContext = true
@@ -20,18 +21,31 @@
let resources: Record<string, any> = {}
let displayVariable = false
let displayResources = false
let allResultsCollapsed = true
let collapsableInitialState:
| {
allResultsCollapsed: boolean
displayVariable: boolean
displayResources: boolean
}
| undefined
let filterActive = false
const EMPTY_STRING = ''
let search = ''
const { propPickerConfig } = getContext<PropPickerWrapperContext>('PropPickerWrapper')
const { propPickerConfig, filteredPickableProperties, inputMatches } =
getContext<PropPickerWrapperContext>('PropPickerWrapper')
$filteredPickableProperties = { ...pickableProperties }
let flowInputsFiltered = pickableProperties.flow_input
let resultByIdFiltered = pickableProperties.priorIds
let timeout: NodeJS.Timeout
function onSearch(search: string) {
filterActive = false
clearTimeout(timeout)
setTimeout(() => {
flowInputsFiltered =
@@ -72,29 +86,124 @@
).map((resource) => [resource.path, resource.description ?? ''])
)
}
async function filterPickableProperties() {
if (!filterActive) {
return
}
if (!$inputMatches?.some((match) => match.word === 'flow_input')) {
flowInputsFiltered = []
}
if (!$inputMatches?.some((match) => match.word === 'results')) {
resultByIdFiltered = []
}
if ($inputMatches?.length == 1) {
if ($inputMatches[0].word === 'flow_input') {
flowInputsFiltered = pickableProperties.flow_input
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(flowInputsFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
flowInputsFiltered = filtered
}
} else if ($inputMatches[0].word === 'results') {
resultByIdFiltered = pickableProperties.priorIds
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(resultByIdFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
resultByIdFiltered = filtered
}
}
}
if ($filteredPickableProperties) {
resultByIdFiltered && ($filteredPickableProperties.priorIds = resultByIdFiltered)
flowInputsFiltered && ($filteredPickableProperties.flow_input = flowInputsFiltered)
}
}
async function updateCollapsable() {
if (!$inputMatches || $inputMatches.length !== 1) {
resetCollapsable()
return
}
if (!collapsableInitialState) {
collapsableInitialState = { allResultsCollapsed, displayVariable, displayResources }
}
if ($inputMatches[0].word === 'variable') {
await loadVariables()
displayVariable = true
return
}
if ($inputMatches[0].word === 'resource') {
await loadResources()
displayResources = true
return
}
if ($inputMatches[0].word === 'results') {
allResultsCollapsed = false
return
}
}
function resetCollapsable() {
if (!collapsableInitialState) {
return
}
;({ allResultsCollapsed, displayVariable, displayResources } = collapsableInitialState)
collapsableInitialState = undefined
}
async function updateFilterActive() {
const prev = filterActive
filterActive = Boolean(
$inputMatches &&
$inputMatches?.length > 0 &&
$propPickerConfig?.insertionMode === 'insert' &&
search === EMPTY_STRING
)
if (prev && !filterActive) {
flowInputsFiltered = pickableProperties.flow_input
resultByIdFiltered = pickableProperties.priorIds
}
}
async function updateState() {
await updateFilterActive()
await filterPickableProperties()
await updateCollapsable()
}
$: search, $inputMatches, $propPickerConfig, updateState()
</script>
<div class="flex flex-col h-full rounded overflow-hidden">
<div class="px-2 py-2">
<ClearableInput bind:value={search} placeholder="Search prop..." />
</div>
<div class="overflow-y-auto px-2 pt-2 grow">
<div class="flex justify-between items-center space-x-1">
<span class="font-normal text-sm text-secondary">Flow Input</span>
<div class="flex space-x-2 items-center" />
</div>
<div class="overflow-y-auto mb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={flowInputsFiltered}
prefix="flow_input"
on:select
/>
</div>
<div class="overflow-y-auto px-2 pt-2 grow" class:bg-surface-secondary={!$propPickerConfig}>
{#if flowInputsFiltered && (Object.keys(flowInputsFiltered).length > 0 || !filterActive)}
<div class="flex justify-between items-center space-x-1">
<span class="font-normal text-sm text-secondary">Flow Input</span>
<div class="flex space-x-2 items-center" />
</div>
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
json={flowInputsFiltered}
prefix="flow_input"
on:select
/>
</div>
{/if}
{#if error}
<span class="font-normal text-sm text-secondary">Error</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
@@ -112,7 +221,7 @@
{#if Object.keys(pickableProperties.priorIds).length > 0}
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
<span class="font-normal text-sm text-secondary">Suggested Results</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
@@ -124,11 +233,11 @@
</div>
{/if}
<span class="font-normal text-sm text-secondary">All Results</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
collapsed={true}
collapseLevel={allResultsCollapsed ? 1 : undefined}
json={resultByIdFiltered}
prefix="results"
on:select
@@ -136,9 +245,12 @@
</div>
{/if}
{:else}
{#if previousId}
{@const json = Object.fromEntries(
Object.entries(resultByIdFiltered).filter(([k, v]) => k == previousId)
)}
{#if previousId && Object.keys(json).length > 0}
<span class="font-normal text-sm text-secondary">Previous Result</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
@@ -152,7 +264,7 @@
{/if}
{#if pickableProperties.hasResume}
<span class="font-normal text-sm text-secondary">Resume payloads</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
@@ -166,9 +278,9 @@
</div>
{/if}
{#if Object.keys(pickableProperties.priorIds).length > 0}
{#if suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
{#if !filterActive && suggestedPropsFiltered && Object.keys(suggestedPropsFiltered).length > 0}
<span class="font-normal text-sm text-secondary">Suggested Results</span>
<div class="overflow-y-auto mb-2">
<div class="overflow-y-auto pb-2">
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
@@ -179,112 +291,105 @@
/>
</div>
{/if}
<div class="overflow-y-auto mb-2">
<span class="font-normal text-sm text-secondary">All Results</span>
{#if !allResultsCollapsed}
{#if Object.keys(resultByIdFiltered).length > 0}
<div class="overflow-y-auto pb-2">
<span class="font-normal text-sm text-secondary">All Results</span>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
collapseLevel={allResultsCollapsed ? 1 : undefined}
json={resultByIdFiltered}
prefix="results"
on:select
/>
</div>
{/if}
{/if}
{/if}
{#if displayContext}
{#if !filterActive || $inputMatches?.some((match) => match.word === 'variable')}
<div class="overflow-y-auto pb-2">
<span class="font-normal text-sm text-secondary">Variables:</span>
{#if displayVariable}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
allResultsCollapsed = true
displayVariable = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
{/if}
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
bind:collapsed={allResultsCollapsed}
json={resultByIdFiltered}
prefix="results"
on:select
/>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={variables}
prefix="variable"
on:select
/>
{:else}
<Button
color="light"
size="xs2"
variant="border"
on:click={async () => {
await loadVariables()
displayVariable = true
}}
wrapperClasses="inline-flex w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
{#if !filterActive || $inputMatches?.some((match) => match.word === 'resource')}
<div class="overflow-y-auto pb-2">
<span class="font-normal text-sm text-secondary">Resources:</span>
{#if displayResources}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
displayResources = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={resources}
prefix="resource"
on:select
/>
{:else}
<Button
color="light"
size="xs2"
variant="border"
on:click={async () => {
await loadResources()
displayResources = true
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
{/if}
{#if displayContext}
<div class="overflow-y-auto mb-2">
<span class="font-normal text-sm text-secondary">Variables:</span>
{#if displayVariable}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
displayVariable = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={variables}
prefix="variable"
on:select
/>
{:else}
<Button
color="light"
size="xs2"
variant="border"
on:click={async () => {
await loadVariables()
displayVariable = true
}}
wrapperClasses="inline-flex w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
<div class="overflow-y-auto mb-2">
<span class="font-normal text-sm text-secondary">Resources:</span>
{#if displayResources}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
displayResources = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={true}
json={resources}
prefix="resource"
on:select
/>
{:else}
<Button
color="light"
size="xs2"
variant="border"
on:click={async () => {
await loadResources()
displayResources = true
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
</div>
</div>