Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/codegen/browser/code/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function isBrowserScenario(scenario: ir.Scenario) {
case 'Identifier':
case 'StringLiteral':
case 'NullLiteral':
case 'SelectOptionValueExpression':
case 'PromiseAllExpression':
return false

Expand Down
13 changes: 13 additions & 0 deletions src/codegen/browser/code/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,16 @@ function emitSelectOptionsExpression(
.done()
}

function emitSelectOptionValueExpression(
expression: ir.SelectOptionValueExpression
): ts.Expression {
return fromObjectLiteral({
value: expression.value,
label: expression.label,
index: expression.index,
})
}

function emitExpectExpression(
context: ScenarioContext,
expression: ir.ExpectExpression
Expand Down Expand Up @@ -543,6 +553,9 @@ function emitExpression(
case 'CheckExpression':
return emitCheckExpression(context, expression)

case 'SelectOptionValueExpression':
return emitSelectOptionValueExpression(expression)

case 'SelectOptionsExpression':
return emitSelectOptionsExpression(context, expression)

Expand Down
8 changes: 8 additions & 0 deletions src/codegen/browser/intermediate/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export interface CheckExpression {
checked: boolean
}

export interface SelectOptionValueExpression {
type: 'SelectOptionValueExpression'
value?: string
label?: string
index?: number
}

export interface SelectOptionsExpression {
type: 'SelectOptionsExpression'
locator: Expression
Expand Down Expand Up @@ -224,6 +231,7 @@ export type Expression =
| ClickOptionsExpression
| FillTextExpression
| CheckExpression
| SelectOptionValueExpression
| SelectOptionsExpression
| ExpectExpression
| WaitForExpression
Expand Down
17 changes: 13 additions & 4 deletions src/codegen/browser/intermediate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,19 @@ function emitSelectOptionsNode(
expression: {
type: 'SelectOptionsExpression',
locator,
selected: node.selected.map((value) => ({
type: 'StringLiteral',
value,
})),
selected: node.selected.map((value) => {
if (typeof value === 'string') {
return {
type: 'StringLiteral',
value,
}
}

return {
type: 'SelectOptionValueExpression',
...value,
}
}),
multiple: node.multiple,
},
})
Expand Down
1 change: 1 addition & 0 deletions src/codegen/browser/intermediate/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function substituteExpression(
case 'ClickOptionsExpression':
case 'WaitForOptionsExpression':
case 'RoleLocatorOptionsExpression':
case 'SelectOptionValueExpression':
case 'TextLocatorOptionsExpression':
return node

Expand Down
23 changes: 22 additions & 1 deletion src/codegen/browser/test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { keyBy } from 'lodash-es'

import {
Assertion,
BrowserEvent,
Expand Down Expand Up @@ -431,12 +433,31 @@ function buildBrowserNodeGraphFromActions(
locator: getLocator(action.locator),
},
}
case 'locator.selectOption': {
const deduped = Object.values(
keyBy(action.values, (v) => {
if (v.value !== undefined) return `value:${v.value}`
if (v.label !== undefined) return `label:${v.label}`
return `index:${v.index}`
})
)
const selected = deduped.length > 0 ? deduped : ['']
Comment thread
allansson marked this conversation as resolved.

return {
type: 'select-options',
nodeId: crypto.randomUUID(),
selected,
multiple: selected.length > 1,
inputs: {
locator: getLocator(action.locator),
},
}
}
case 'page.waitForNavigation':
case 'page.close':
case 'page.*':
case 'locator.dblclick':
case 'locator.type':
case 'locator.selectOption':
case 'locator.hover':
case 'locator.setChecked':
case 'locator.tap':
Expand Down
2 changes: 1 addition & 1 deletion src/codegen/browser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface TypeTextNode extends NodeBase {

export interface SelectOptionsNode extends NodeBase {
type: 'select-options'
selected: string[]
selected: (string | { value?: string; label?: string; index?: number })[]
multiple: boolean
inputs: {
previous?: NodeRef
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { css } from '@emotion/react'
import {
Button,
Flex,
IconButton,
Popover,
ScrollArea,
Select,
Text,
TextField,
} from '@radix-ui/themes'
import { PlusIcon, XIcon } from 'lucide-react'
import { Fragment, useState } from 'react'

import { SelectOptions } from '@/components/Browser/SelectOptions'
import { FieldGroup } from '@/components/Form'
import { LocatorSelectOptionAction } from '@/main/runner/schema'

import { ValuePopoverBadge } from '../components'

type SelectOptionValue = LocatorSelectOptionAction['values'][number]

type MatchType = 'value' | 'label' | 'index'

interface SelectOptionValuesFormProps {
values: SelectOptionValue[]
onChange: (values: SelectOptionValue[]) => void
}

export function SelectOptionValuesForm({
values,
onChange,
}: SelectOptionValuesFormProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [touchedIndices, setTouchedIndices] = useState(new Set<number>())

const handleChangeType = (index: number, type: MatchType) => {
const current = values[index]
if (!current) return
const input = getMatchInput(current)
const next = [...values]
next[index] = toEntry(type, input)
onChange(next)
}

const handleChangeInput = (index: number, input: string) => {
const current = values[index]
if (!current) return
const type = getMatchType(current)
const next = [...values]
next[index] = toEntry(type, input)
onChange(next)
}

const handleAdd = () => {
onChange([...values, { value: '' }])
}

const handleRemove = (index: number) => {
onChange(values.filter((_, i) => i !== index))
setTouchedIndices((prev) => {
const next = new Set<number>()
for (const i of prev) {
if (i < index) next.add(i)
else if (i > index) next.add(i - 1)
}
return next
})
}

const handleBlur = (index: number) => {
setTouchedIndices((prev) =>
prev.has(index) ? prev : new Set(prev).add(index)
)
}

const handlePopoverOpenChange = (open: boolean) => {
setIsPopoverOpen(open)
if (!open) {
setTouchedIndices(new Set(values.map((_, i) => i)))
}
}

const errors = validateSelectOptions(values)

let badgeError: string | undefined
for (const [i, msg] of errors) {
if (touchedIndices.has(i)) {
badgeError = msg
break
}
}

return (
<Popover.Root open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
<Popover.Trigger>
<ValuePopoverBadge
displayValue={
<span
css={css`
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`}
>
<SelectOptions options={values} />
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted by @allansson, empty values are not necessarily invalid, so SelectOptions needs to handle those in a better way. There should probably also be a better distinction between labels, values, and indexes. I'll try to create a separate PR for that change

Image

</span>
}
error={badgeError}
/>
</Popover.Trigger>
<Popover.Content align="start" size="1" width="360px">
<FieldGroup
name="options"
label="Options to select"
labelSize="1"
mb="0"
>
<Flex direction="column" gap="2">
<ScrollArea
css={css`
max-height: 200px;
`}
>
<Flex direction="column" gap="2" pr="3">
{values.map((entry, index) => {
const error = touchedIndices.has(index)
? errors.get(index)
: undefined

return (
<Fragment key={index}>
<Flex direction="column" gap="1">
<Flex gap="2" align="center">
<Select.Root
size="1"
value={getMatchType(entry)}
onValueChange={(type: MatchType) =>
handleChangeType(index, type)
}
>
<Select.Trigger
css={css`
min-width: 80px;
`}
/>
<Select.Content>
<Select.Item value="value">Value</Select.Item>
<Select.Item value="label">Label</Select.Item>
<Select.Item value="index">Index</Select.Item>
</Select.Content>
</Select.Root>
<TextField.Root
size="1"
color={error ? 'red' : undefined}
type={
getMatchType(entry) === 'index'
? 'number'
: 'text'
}
{...(getMatchType(entry) === 'index'
? { min: 0 }
: {})}
value={getMatchInput(entry)}
onChange={(e) =>
handleChangeInput(index, e.target.value)
}
onBlur={() => handleBlur(index)}
placeholder={
getMatchType(entry) === 'index'
? '0'
: 'Enter text...'
}
css={css`
flex: 1;
`}
/>
<IconButton
aria-label="Remove option"
size="1"
variant="ghost"
color="gray"
onClick={() => handleRemove(index)}
>
<XIcon />
</IconButton>
</Flex>
{error && (
<Text size="1" color="red">
{error}
</Text>
)}
</Flex>
</Fragment>
)
})}
</Flex>
</ScrollArea>
<Button
size="1"
variant="ghost"
color="gray"
onClick={handleAdd}
css={css`
align-self: flex-start;
`}
>
<PlusIcon /> Add option
</Button>
</Flex>
</FieldGroup>
</Popover.Content>
</Popover.Root>
)
}

function getMatchType(entry: SelectOptionValue): MatchType {
if (entry.label !== undefined) return 'label'
if (entry.index !== undefined) return 'index'
return 'value'
}

function getMatchInput(entry: SelectOptionValue): string {
if (entry.label !== undefined) return entry.label
if (entry.index !== undefined) return String(entry.index)
return entry.value ?? ''
}

function toEntry(type: MatchType, input: string): SelectOptionValue {
switch (type) {
case 'value':
return { value: input }
case 'label':
return { label: input }
case 'index':
return { index: input === '' ? 0 : Number(input) }
}
}

function validateSelectOptionEntry(
entry: SelectOptionValue
): string | undefined {
if (entry.label !== undefined && entry.label.trim() === '') {
return 'Label cannot be empty'
}
if (entry.index !== undefined && !Number.isInteger(entry.index)) {
return 'Index must be an integer'
}
return undefined
}

function validateSelectOptions(values: SelectOptionValue[]) {
const errors = new Map<number, string>()
for (let i = 0; i < values.length; i++) {
const msg = validateSelectOptionEntry(values[i]!)
if (msg) errors.set(i, msg)
}
return errors
}
Loading
Loading