Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -57,6 +57,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 @@ -298,6 +298,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 @@ -527,6 +537,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 @@ -116,6 +116,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 @@ -218,6 +225,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 @@ -288,10 +288,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
3 changes: 3 additions & 0 deletions src/codegen/browser/intermediate/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ function substituteExpression(
locator: substituteExpression(node.locator, substitutions),
}

case 'SelectOptionValueExpression':
return node

case 'SelectOptionsExpression':
return {
type: 'SelectOptionsExpression',
Expand Down
19 changes: 18 additions & 1 deletion src/codegen/browser/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,12 +423,29 @@ function buildBrowserNodeGraphFromActions(
locator: getLocator(action.locator),
},
}
case 'locator.selectOption': {
const nonEmpty = action.values.filter(
(v) =>
(v.value !== undefined && v.value !== '') ||
(v.label !== undefined && v.label !== '') ||
v.index !== undefined
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

value is allowed to be an empty string according to the HTML specification (but label is not).

Suggested change
const nonEmpty = action.values.filter(
(v) =>
(v.value !== undefined && v.value !== '') ||
(v.label !== undefined && v.label !== '') ||
v.index !== undefined
)
const nonEmpty = action.values.filter(
(v) =>
(v.label !== undefined && v.label !== '') ||
v.value !== undefined ||
v.index !== undefined
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe this is a dumb optimization, but we could dedupe the values for the user:

function getKey(v) {
  if (v.value !== undefined) {
    return `value:${v.value}`
  }

  if (v.label !== undefined) {
    return `label:${v.label}`
  }

  return `index:${v.index}`
}

const deduped = Object.values(keyBy(nonEmpty, getKey))

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.

No, I think it's a good suggestion 👍

const selected = nonEmpty.length > 0 ? nonEmpty : ['']
return {
type: 'select-options',
nodeId: crypto.randomUUID(),
selected,
multiple: nonEmpty.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,190 @@
import { css } from '@emotion/react'
import {
Button,
Flex,
IconButton,
Popover,
ScrollArea,
Select,
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'

function isNonEmptyValue(entry: SelectOptionValue): boolean {
if (entry.label !== undefined) return entry.label !== ''
if (entry.index !== undefined) return true
return (entry.value ?? '') !== ''
}

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) }
}
}

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

export function SelectOptionValuesForm({
values,
onChange,
}: SelectOptionValuesFormProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false)

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))
}

const nonEmptyValues = values.filter(isNonEmptyValue)

return (
<Popover.Root open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<Popover.Trigger>
<ValuePopoverBadge
displayValue={
nonEmptyValues.length === 0 ? (
'option(s)'
) : (
<SelectOptions options={nonEmptyValues} />
)
}
error={null}
/>
</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) => (
<Fragment key={index}>
<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"
type={
getMatchType(entry) === 'index' ? 'number' : 'text'
}
{...(getMatchType(entry) === 'index' ? { min: 0 } : {})}
value={getMatchInput(entry)}
onChange={(e) =>
handleChangeInput(index, e.target.value)
}
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>
</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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Grid } from '@radix-ui/themes'

import { LocatorSelectOptionAction } from '@/main/runner/schema'

import { LocatorForm } from '../../ActionForms/forms/LocatorForm'
import { SelectOptionValuesForm } from '../../ActionForms/forms/SelectOptionValuesForm'
import { WithEditorMetadata } from '../../types'

interface SelectOptionActionBodyProps {
action: WithEditorMetadata<LocatorSelectOptionAction>
onChange: (action: WithEditorMetadata<LocatorSelectOptionAction>) => void
}

export function SelectOptionActionBody({
action,
onChange,
}: SelectOptionActionBodyProps) {
const handleChangeLocator = (
locator: WithEditorMetadata<LocatorSelectOptionAction>['locator']
) => {
onChange({ ...action, locator })
}

const handleChangeValues = (values: LocatorSelectOptionAction['values']) => {
onChange({ ...action, values })
}

return (
<Grid
columns="max-content minmax(0, max-content) max-content minmax(0, max-content) 1fr"
gap="2"
align="center"
width="100%"
>
Select
<SelectOptionValuesForm
values={action.values}
onChange={handleChangeValues}
/>
in
Comment on lines +35 to +40
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.

This order is different from other actions, but feels more natural/easier to read IMO

<LocatorForm
state={action.locator}
onChange={handleChangeLocator}
suggestedRoles={['combobox', 'listbox', 'menu', 'radiogroup']}
/>
</Grid>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SelectOptionActionBody'
1 change: 1 addition & 0 deletions src/views/BrowserTestEditor/Actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './ClickAction'
export * from './FillAction'
export * from './GoToAction'
export * from './PageReloadAction'
export * from './SelectOptionAction'
export * from './UncheckAction'
export * from './WaitForAction'
7 changes: 7 additions & 0 deletions src/views/BrowserTestEditor/EditableBrowserActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ function NewActionMenu({ onAddAction }: NewActionMenuProps) {
>
Fill input
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={() => {
onAddAction('locator.selectOption')
}}
>
Select option
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={() => {
Expand Down
Loading
Loading