Skip to content

Commit

Permalink
feat: Add parameterization rule UI (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
e-fisher authored Oct 22, 2024
1 parent b45780d commit e9d5be3
Show file tree
Hide file tree
Showing 21 changed files with 474 additions and 103 deletions.
13 changes: 10 additions & 3 deletions src/components/Form/ControlledSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Select } from '@radix-ui/themes'
import { ReactNode } from 'react'
import { Control, Controller, FieldValues, Path } from 'react-hook-form'

type Option = { label: string; value: string }
type Option = { label: ReactNode; value: string; disabled?: boolean }

interface ControlledSelectProps<T extends FieldValues, O extends Option> {
name: Path<T>
control: Control<T>
options: O[]
selectProps?: Select.RootProps
contentProps?: Select.ContentProps
onChange?: (value: O['value']) => void
}

Expand All @@ -16,6 +18,7 @@ export function ControlledSelect<T extends FieldValues, O extends Option>({
control,
options,
selectProps = {},
contentProps = {},
onChange,
}: ControlledSelectProps<T, O>) {
return (
Expand All @@ -33,9 +36,13 @@ export function ControlledSelect<T extends FieldValues, O extends Option>({
id={name}
css={{ width: '100%' }}
/>
<Select.Content>
<Select.Content {...contentProps}>
{options.map((option) => (
<Select.Item key={option.value} value={option.value}>
<Select.Item
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</Select.Item>
))}
Expand Down
5 changes: 5 additions & 0 deletions src/globalStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ export const globalStyles = css`
.rt-ScrollAreaScrollbar {
z-index: 2;
}
/* Allow to truncate text in Select options */
.rt-SelectItem > span:not(.rt-SelectItemIndicator) {
width: 100%;
}
`
2 changes: 1 addition & 1 deletion src/rules/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function applyRules(recording: ProxyData[], rules: TestRule[]) {
return { requestSnippetSchemas, ruleInstances }
}

export function createRuleInstance<T extends TestRule>(
function createRuleInstance<T extends TestRule>(
rule: T,
idGenerator: Generator<number>
) {
Expand Down
2 changes: 1 addition & 1 deletion src/store/generator/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function selectSelectedRule(state: GeneratorStore) {

export function selectIsRulePreviewable(state: GeneratorStore) {
const rule = selectSelectedRule(state)
return rule?.type === 'correlation'
return ['correlation', 'parameterization'].includes(rule?.type ?? '')
}

export function selectHasRecording(state: GeneratorStore) {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function createEmptyRule(type: TestRule['type']): TestRule {
begin: '',
end: '',
},
value: { type: 'variable', variableName: '' },
value: { type: 'string', value: '' },
}
case 'verification':
return {
Expand Down
9 changes: 9 additions & 0 deletions src/views/Generator/NewRuleMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export function NewRuleMenu(props: ComponentProps<typeof Button>) {
Correlation
</DropdownMenu.Item>
</Tooltip>
<Tooltip content="Parameterize request data." side="right">
<DropdownMenu.Item
onClick={() => {
createRule('parameterization')
}}
>
Parameterization
</DropdownMenu.Item>
</Tooltip>
<Tooltip content="Insert custom code snippet." side="right">
<DropdownMenu.Item
onClick={() => {
Expand Down
8 changes: 4 additions & 4 deletions src/views/Generator/RuleEditor/CorrelationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export function CorrelationEditor() {
<Text size="2" as="p" mb="2" color="gray">
Extraction value for correlation.
</Text>
<FilterField path="extractor.filter" />
<SelectorField type="extractor" />
<FilterField field="extractor.filter" />
<SelectorField field="extractor.selector" />
</Box>
<Box>
<Label mb="2">
Expand All @@ -58,8 +58,8 @@ export function CorrelationEditor() {

{replacer && (
<>
<FilterField path="replacer.filter" />
<SelectorField type="replacer" />
<FilterField field="replacer.filter" />
<SelectorField field="replacer.selector" />
</>
)}
</Box>
Expand Down
2 changes: 1 addition & 1 deletion src/views/Generator/RuleEditor/CustomCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function CustomCodeEditor() {
return (
<Box>
<Grid gap="2" columns="1fr 1fr">
<FilterField path="filter" />
<FilterField field="filter" />
<FieldGroup name="placement" errors={errors} label="Placement">
<ControlledSelect
name="placement"
Expand Down
6 changes: 3 additions & 3 deletions src/views/Generator/RuleEditor/FilterField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { useFormContext } from 'react-hook-form'
import { FieldGroup } from '@/components/Form'

export function FilterField({
path: path,
field,
}: {
path: 'filter' | 'extractor.filter' | 'replacer.filter'
field: 'filter' | 'extractor.filter' | 'replacer.filter'
}) {
const {
register,
formState: { errors },
} = useFormContext<TestRule>()
const fieldName = `${path}.path` as const
const fieldName = `${field}.path` as const

return (
<FieldGroup
Expand Down
27 changes: 27 additions & 0 deletions src/views/Generator/RuleEditor/ParameterizationEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Box, Grid, Heading, Text } from '@radix-ui/themes'
import { FilterField } from './FilterField'
import { SelectorField } from './SelectorField'
import { ParamaterizationValueEditor } from './ParameterizationValueEditor'

export function ParameterizationEditor() {
return (
<>
<Heading size="2" weight="medium" mb="2">
Parameterization
</Heading>

<Text size="2" as="p" mb="2" color="gray">
Replace request data with variables or custom values.
</Text>
<Grid columns="2" gap="3">
<Box>
<FilterField field="filter" />
<SelectorField field="selector" />
</Box>
<Box>
<ParamaterizationValueEditor />
</Box>
</Grid>
</>
)
}
115 changes: 115 additions & 0 deletions src/views/Generator/RuleEditor/ParameterizationValueEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Code, Flex, Text, TextField } from '@radix-ui/themes'
import { ControlledSelect, FieldGroup } from '@/components/Form'
import { ParameterizationRule } from '@/types/rules'
import { useFormContext } from 'react-hook-form'
import { useGeneratorStore } from '@/store/generator'
import { useMemo } from 'react'
import { exhaustive } from '@/utils/typescript'

export function ParamaterizationValueEditor() {
const {
control,
formState: { errors },
} = useFormContext<ParameterizationRule>()

const variablesExist = useGeneratorStore(
(state) => state.variables.length > 0
)

const variablesLabel = variablesExist
? 'Variables'
: 'Variables (add in test options)'

const VALUE_TYPE_OPTIONS = [
{ value: 'string', label: 'Text value' },
{ value: 'variable', label: variablesLabel, disabled: !variablesExist },
]

return (
<>
<FieldGroup name="value.type" errors={errors} label="Replace with">
<ControlledSelect
control={control}
name="value.type"
options={VALUE_TYPE_OPTIONS}
/>
</FieldGroup>
<ValueTypeSwitch />
</>
)
}

function ValueTypeSwitch() {
const {
watch,
register,
formState: { errors },
} = useFormContext<ParameterizationRule>()

const type = watch('value.type')

switch (type) {
case 'string':
return (
<FieldGroup name="value.value" errors={errors} label="Value">
<TextField.Root placeholder="Value" {...register('value.value')} />
</FieldGroup>
)
case 'variable':
return <VariableSelect />

case 'array':
case 'customCode':
return null

default:
return exhaustive(type)
}
}

function VariableSelect() {
const variables = useGeneratorStore((store) => store.variables)

const options = useMemo(() => {
return variables.map((variable) => ({
value: variable.name,
label: (
<Flex gap="1" align="center">
<Code size="2" truncate>
{variable.name}
</Code>
<Text truncate size="1" css={{ flex: '1' }}>
{variable.value}
</Text>
</Flex>
),
}))
}, [variables])

const {
control,
watch,
formState: { errors },
} = useFormContext<ParameterizationRule>()

const variableName = watch('value.variableName')

return (
<FieldGroup name="value.variableName" errors={errors} label="Variable">
<ControlledSelect
options={options}
control={control}
name="value.variableName"
selectProps={{
// Automatically open the select when switching to variable type
// in new parameterization rule
defaultOpen: !variableName,
}}
contentProps={{
css: { maxWidth: 'var(--radix-select-trigger-width)' },
position: 'popper',
}}
/>
</FieldGroup>
)
}
11 changes: 3 additions & 8 deletions src/views/Generator/RuleEditor/RuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CorrelationEditor } from './CorrelationEditor'
import { CustomCodeEditor } from './CustomCodeEditor'
import { TestRule } from '@/types/rules'
import { TestRuleSchema } from '@/schemas/rules'
import { ParameterizationEditor } from './ParameterizationEditor'

export function RuleEditorSwitch() {
const { watch } = useFormContext<TestRule>()
Expand All @@ -21,14 +22,8 @@ export function RuleEditorSwitch() {
case 'customCode':
return <CustomCodeEditor />
case 'parameterization':
return (
<Callout.Root>
<Callout.Icon>
<InfoCircledIcon />
</Callout.Icon>
<Callout.Text>Not implemented yet</Callout.Text>
</Callout.Root>
)
return <ParameterizationEditor />

case 'verification':
return (
<Callout.Root>
Expand Down
19 changes: 12 additions & 7 deletions src/views/Generator/RuleEditor/SelectorField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,23 @@ const allowedTypes: Record<
url: [typeOptions.beginEnd, typeOptions.regex],
}

export function SelectorField({ type }: { type: 'extractor' | 'replacer' }) {
export function SelectorField({
field,
}: {
field: 'extractor.selector' | 'replacer.selector' | 'selector'
}) {
const {
watch,
control,
setValue,
formState: { errors },
} = useFormContext<TestRule>()
const field = `${type}.selector` as const
const selector = watch(field)

if (!selector) {
return null
}

const handleFromChange = (value: Selector['from']) => {
// When "from" changes reset type to the first allowed type if the current type is not allowed
if (!allowedTypes[value].find(({ value }) => value === selector.type)) {
Expand Down Expand Up @@ -102,25 +109,23 @@ export function SelectorField({ type }: { type: 'extractor' | 'replacer' }) {
</FieldGroup>
</Box>
</Flex>
<SelectorContent selector={selector} type={type} />
<SelectorContent selector={selector} field={field} />
</>
)
}

function SelectorContent({
selector,
type,
field,
}: {
selector: Selector
type: 'extractor' | 'replacer'
field: 'extractor.selector' | 'replacer.selector' | 'selector'
}) {
const {
register,
formState: { errors },
} = useFormContext<TestRule>()

const field = `${type}.selector` as const

switch (selector.type) {
case 'json':
return (
Expand Down
Loading

0 comments on commit e9d5be3

Please sign in to comment.