Multiple prompt-to-edit selection, plus direct editor selections (#5478)
* Add multiple selections and editor selections for promptToEdit * remove unused * re-enable prompt to edit tests * add test for manual code selection * at test for multi-selection * clean up * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * typo --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -171,4 +171,22 @@ export class EditorFixture {
|
||||
{ text, placeCursor }
|
||||
)
|
||||
}
|
||||
async selectText(text: string) {
|
||||
// First make sure the code pane is open
|
||||
const wasPaneOpen = await this.checkIfPaneIsOpen()
|
||||
if (!wasPaneOpen) {
|
||||
await this.openPane()
|
||||
}
|
||||
|
||||
// Use Playwright's built-in text selection on the code content
|
||||
// it seems to only select whole divs, which works out to align with syntax highlighting
|
||||
// for code mirror, so you can probably select "sketch002 = startSketchOn('XZ')"
|
||||
// but less so for exactly "sketch002 = startS"
|
||||
await this.codeContent.getByText(text).first().selectText()
|
||||
|
||||
// Reset pane state if needed
|
||||
if (!wasPaneOpen) {
|
||||
await this.closePane()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ extrude003 = extrude(sketch003, length = 20)
|
||||
`
|
||||
|
||||
test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
|
||||
test.fixme('Check the happy path, for basic changing color', () => {
|
||||
test.describe('Check the happy path, for basic changing color', () => {
|
||||
const cases = [
|
||||
{
|
||||
desc: 'User accepts change',
|
||||
@ -70,7 +70,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
|
||||
body1CapCoords.y
|
||||
)
|
||||
const yellow: [number, number, number] = [179, 179, 131]
|
||||
const green: [number, number, number] = [108, 152, 75]
|
||||
const green: [number, number, number] = [128, 194, 88]
|
||||
const notGreen: [number, number, number] = [132, 132, 132]
|
||||
const body2NotGreen: [number, number, number] = [88, 88, 88]
|
||||
const submittingToast = page.getByText(
|
||||
@ -109,7 +109,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
|
||||
})
|
||||
|
||||
await test.step('verify initial change', async () => {
|
||||
await scene.expectPixelColor(green, greenCheckCoords, 15)
|
||||
await scene.expectPixelColor(green, greenCheckCoords, 20)
|
||||
await scene.expectPixelColor(body2NotGreen, body2WallCoords, 15)
|
||||
await editor.expectEditor.toContain('appearance(')
|
||||
})
|
||||
@ -142,7 +142,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
test(`bad edit prompt`, async ({
|
||||
test('bad edit prompt', async ({
|
||||
context,
|
||||
homePage,
|
||||
cmdBar,
|
||||
@ -195,4 +195,150 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
|
||||
await expect(failToast).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test(`manual code selection rename`, async ({
|
||||
context,
|
||||
homePage,
|
||||
cmdBar,
|
||||
editor,
|
||||
page,
|
||||
scene,
|
||||
}) => {
|
||||
const body1CapCoords = { x: 571, y: 351 }
|
||||
|
||||
await context.addInitScript((file) => {
|
||||
localStorage.setItem('persistCode', file)
|
||||
}, file)
|
||||
await homePage.goToModelingScene()
|
||||
await scene.waitForExecutionDone()
|
||||
|
||||
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
|
||||
const successToast = page.getByText('Prompt to edit successful')
|
||||
const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' })
|
||||
|
||||
await test.step('wait for scene to load and select code in editor', async () => {
|
||||
// Find and select the text "sketch002" in the editor
|
||||
await editor.selectText('sketch002')
|
||||
|
||||
// Verify the selection was made
|
||||
await editor.expectState({
|
||||
highlightedCode: '',
|
||||
activeLines: ["sketch002 = startSketchOn('XZ')"],
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
await test.step('fire off edit prompt', async () => {
|
||||
await scene.expectPixelColor([134, 134, 134], body1CapCoords, 15)
|
||||
await cmdBar.openCmdBar('promptToEdit')
|
||||
await page
|
||||
.getByTestId('cmd-bar-arg-value')
|
||||
.fill('Please rename to mySketch')
|
||||
await page.waitForTimeout(100)
|
||||
await cmdBar.progressCmdBar()
|
||||
await expect(submittingToast).toBeVisible()
|
||||
await expect(submittingToast).not.toBeVisible({
|
||||
timeout: 2 * 60_000,
|
||||
})
|
||||
await expect(successToast).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('verify rename change and accept it', async () => {
|
||||
await editor.expectEditor.toContain('mySketch = startSketchOn')
|
||||
await editor.expectEditor.not.toContain('sketch002 = startSketchOn')
|
||||
await editor.expectEditor.toContain(
|
||||
'extrude002 = extrude(mySketch, length = 50)'
|
||||
)
|
||||
|
||||
await acceptBtn.click()
|
||||
await expect(successToast).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('multiple body selections', async ({
|
||||
context,
|
||||
homePage,
|
||||
cmdBar,
|
||||
editor,
|
||||
page,
|
||||
scene,
|
||||
}) => {
|
||||
const body1CapCoords = { x: 571, y: 351 }
|
||||
const body2WallCoords = { x: 620, y: 152 }
|
||||
const [clickBody1Cap] = scene.makeMouseHelpers(
|
||||
body1CapCoords.x,
|
||||
body1CapCoords.y
|
||||
)
|
||||
const [clickBody2Cap] = scene.makeMouseHelpers(
|
||||
body2WallCoords.x,
|
||||
body2WallCoords.y
|
||||
)
|
||||
const grey: [number, number, number] = [132, 132, 132]
|
||||
|
||||
await context.addInitScript((file) => {
|
||||
localStorage.setItem('persistCode', file)
|
||||
}, file)
|
||||
await homePage.goToModelingScene()
|
||||
await scene.waitForExecutionDone()
|
||||
|
||||
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
|
||||
const successToast = page.getByText('Prompt to edit successful')
|
||||
const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' })
|
||||
|
||||
await test.step('select multiple bodies and fire prompt', async () => {
|
||||
// Initial color check
|
||||
await scene.expectPixelColor(grey, body1CapCoords, 15)
|
||||
|
||||
// Open command bar first (without selection)
|
||||
await cmdBar.openCmdBar('promptToEdit')
|
||||
|
||||
// Select first body
|
||||
await page.waitForTimeout(100)
|
||||
await clickBody1Cap()
|
||||
|
||||
// Hold shift and select second body
|
||||
await editor.expectState({
|
||||
highlightedCode: '',
|
||||
activeLines: ['|>startProfileAt([-73.64,-42.89],%)'],
|
||||
diagnostics: [],
|
||||
})
|
||||
await page.keyboard.down('Shift')
|
||||
await page.waitForTimeout(100)
|
||||
await clickBody2Cap()
|
||||
await editor.expectState({
|
||||
highlightedCode:
|
||||
'line(end=[121.13,56.63],tag=$seg02)extrude(profile001,length=200)',
|
||||
activeLines: [
|
||||
'|>line(end=[121.13,56.63],tag=$seg02)',
|
||||
'|>startProfileAt([-73.64,-42.89],%)',
|
||||
],
|
||||
diagnostics: [],
|
||||
})
|
||||
await page.keyboard.up('Shift')
|
||||
await page.waitForTimeout(100)
|
||||
await cmdBar.progressCmdBar()
|
||||
|
||||
// Enter prompt and submit
|
||||
await page
|
||||
.getByTestId('cmd-bar-arg-value')
|
||||
.fill('make these neon green please, use #39FF14')
|
||||
await page.waitForTimeout(100)
|
||||
await cmdBar.progressCmdBar()
|
||||
|
||||
// Wait for API response
|
||||
await expect(submittingToast).toBeVisible()
|
||||
await expect(submittingToast).not.toBeVisible({
|
||||
timeout: 2 * 60_000,
|
||||
})
|
||||
await expect(successToast).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('verify code changed', async () => {
|
||||
await editor.expectEditor.toContain('appearance(')
|
||||
|
||||
// Accept changes
|
||||
await acceptBtn.click()
|
||||
await expect(successToast).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
@ -17,7 +17,9 @@ export const CommandBar = () => {
|
||||
const {
|
||||
context: { selectedCommand, currentArgument, commands },
|
||||
} = commandBarState
|
||||
const isSelectionArgument = currentArgument?.inputType === 'selection'
|
||||
const isSelectionArgument =
|
||||
currentArgument?.inputType === 'selection' ||
|
||||
currentArgument?.inputType === 'selectionMixed'
|
||||
const WrapperComponent = isSelectionArgument ? Popover : Dialog
|
||||
|
||||
// Close the command bar when navigating
|
||||
|
@ -1,6 +1,7 @@
|
||||
import CommandArgOptionInput from './CommandArgOptionInput'
|
||||
import CommandBarBasicInput from './CommandBarBasicInput'
|
||||
import CommandBarSelectionInput from './CommandBarSelectionInput'
|
||||
import CommandBarSelectionMixedInput from './CommandBarSelectionMixedInput'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import CommandBarHeader from './CommandBarHeader'
|
||||
import CommandBarKclInput from './CommandBarKclInput'
|
||||
@ -84,6 +85,14 @@ function ArgumentInput({
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'selectionMixed':
|
||||
return (
|
||||
<CommandBarSelectionMixedInput
|
||||
arg={arg}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
case 'kcl':
|
||||
return (
|
||||
<CommandBarKclInput arg={arg} stepBack={stepBack} onSubmit={onSubmit} />
|
||||
|
@ -124,7 +124,8 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||
<span className="sr-only">: </span>
|
||||
<span data-testid="header-arg-value">
|
||||
{argValue ? (
|
||||
arg.inputType === 'selection' ? (
|
||||
arg.inputType === 'selection' ||
|
||||
arg.inputType === 'selectionMixed' ? (
|
||||
getSelectionTypeDisplayText(argValue as Selections)
|
||||
) : arg.inputType === 'kcl' ? (
|
||||
roundOff(
|
||||
|
135
src/components/CommandBar/CommandBarSelectionMixedInput.tsx
Normal file
135
src/components/CommandBar/CommandBarSelectionMixedInput.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import {
|
||||
Selections,
|
||||
canSubmitSelectionArg,
|
||||
getSelectionCountByType,
|
||||
getSelectionTypeDisplayText,
|
||||
} from 'lib/selections'
|
||||
import { useSelector } from '@xstate/react'
|
||||
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
|
||||
|
||||
const selectionSelector = (snapshot: any) => snapshot?.context.selectionRanges
|
||||
|
||||
export default function CommandBarSelectionMixedInput({
|
||||
arg,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
arg: CommandArgument<unknown> & { inputType: 'selectionMixed'; name: string }
|
||||
stepBack: () => void
|
||||
onSubmit: (data: unknown) => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const commandBarState = useCommandBarState()
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const [hasAutoSkipped, setHasAutoSkipped] = useState(false)
|
||||
const selection: Selections = useSelector(arg.machineActor, selectionSelector)
|
||||
|
||||
const selectionsByType = useMemo(() => {
|
||||
return getSelectionCountByType(selection)
|
||||
}, [selection])
|
||||
|
||||
const canSubmitSelection = useMemo<boolean>(() => {
|
||||
if (!selection) return false
|
||||
const isNonZeroRange = selection.graphSelections.some((sel) => {
|
||||
const range = sel.codeRef.range
|
||||
return range[1] - range[0] !== 0 // Non-zero range is always valid
|
||||
})
|
||||
if (isNonZeroRange) return true
|
||||
return canSubmitSelectionArg(selectionsByType, arg)
|
||||
}, [selectionsByType, selection])
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [selection, inputRef])
|
||||
|
||||
// Only auto-skip on initial mount if we have a valid selection
|
||||
// different from the component CommandBarSelectionInput in the the dependency array
|
||||
// is empty
|
||||
useEffect(() => {
|
||||
if (!hasAutoSkipped && canSubmitSelection && arg.skip) {
|
||||
const argValue = commandBarState.context.argumentsToSubmit[arg.name]
|
||||
if (argValue === undefined) {
|
||||
handleSubmit()
|
||||
setHasAutoSkipped(true)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
function handleChange() {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleSubmit(e?: React.FormEvent<HTMLFormElement>) {
|
||||
e?.preventDefault()
|
||||
|
||||
if (!canSubmitSelection) {
|
||||
setHasSubmitted(true)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(selection)
|
||||
}
|
||||
|
||||
const isMixedSelection = arg.inputType === 'selectionMixed'
|
||||
const allowNoSelection = isMixedSelection && arg.allowNoSelection
|
||||
const showSceneSelection =
|
||||
isMixedSelection && arg.selectionSource?.allowSceneSelection
|
||||
|
||||
return (
|
||||
<form id="arg-form" onSubmit={handleSubmit}>
|
||||
<label
|
||||
className={
|
||||
'relative flex flex-col mx-4 my-4 ' +
|
||||
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
|
||||
}
|
||||
>
|
||||
{canSubmitSelection
|
||||
? 'Select objects in the scene'
|
||||
: 'Select code or objects in the scene'}
|
||||
|
||||
{showSceneSelection && (
|
||||
<div className="scene-selection mt-2">
|
||||
<p className="text-sm text-chalkboard-60">
|
||||
Select objects in the scene
|
||||
</p>
|
||||
{/* Scene selection UI will be handled by the parent component */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowNoSelection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit(null)}
|
||||
className="mt-2 px-4 py-2 rounded border border-chalkboard-30 text-chalkboard-90 dark:text-chalkboard-10 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-90 transition-colors"
|
||||
>
|
||||
Continue without selection
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span data-testid="cmd-bar-arg-name" className="sr-only">
|
||||
{arg.name}
|
||||
</span>
|
||||
<input
|
||||
id="selection"
|
||||
name="selection"
|
||||
ref={inputRef}
|
||||
required
|
||||
data-testid="cmd-bar-arg-value"
|
||||
placeholder="Select an entity with your mouse"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
stepBack()
|
||||
} else if (event.key === 'Escape') {
|
||||
commandBarActor.send({ type: 'Close' })
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
value={JSON.stringify(selection || {})}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)
|
||||
}
|
@ -666,7 +666,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
|
||||
icon: 'chat',
|
||||
args: {
|
||||
selection: {
|
||||
inputType: 'selection',
|
||||
inputType: 'selectionMixed',
|
||||
selectionTypes: [
|
||||
'solid2d',
|
||||
'segment',
|
||||
@ -678,6 +678,10 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
|
||||
],
|
||||
multiple: true,
|
||||
required: true,
|
||||
selectionSource: {
|
||||
allowSceneSelection: true,
|
||||
allowCodeSelection: true,
|
||||
},
|
||||
skip: true,
|
||||
},
|
||||
prompt: {
|
||||
|
@ -16,6 +16,7 @@ const INPUT_TYPES = [
|
||||
'text',
|
||||
'kcl',
|
||||
'selection',
|
||||
'selectionMixed',
|
||||
'boolean',
|
||||
] as const
|
||||
export interface KclExpression {
|
||||
@ -156,6 +157,23 @@ export type CommandArgumentConfig<
|
||||
context: CommandBarContext
|
||||
}) => Promise<boolean | string>
|
||||
}
|
||||
| {
|
||||
inputType: 'selectionMixed'
|
||||
selectionTypes: Artifact['type'][]
|
||||
multiple: boolean
|
||||
allowNoSelection?: boolean
|
||||
validation?: ({
|
||||
data,
|
||||
context,
|
||||
}: {
|
||||
data: any
|
||||
context: CommandBarContext
|
||||
}) => Promise<boolean | string>
|
||||
selectionSource?: {
|
||||
allowSceneSelection?: boolean
|
||||
allowCodeSelection?: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
inputType: 'kcl'
|
||||
createVariableByDefault?: boolean
|
||||
@ -252,6 +270,23 @@ export type CommandArgument<
|
||||
context: CommandBarContext
|
||||
}) => Promise<boolean | string>
|
||||
}
|
||||
| {
|
||||
inputType: 'selectionMixed'
|
||||
selectionTypes: Artifact['type'][]
|
||||
multiple: boolean
|
||||
allowNoSelection?: boolean
|
||||
validation?: ({
|
||||
data,
|
||||
context,
|
||||
}: {
|
||||
data: any
|
||||
context: CommandBarContext
|
||||
}) => Promise<boolean | string>
|
||||
selectionSource?: {
|
||||
allowSceneSelection?: boolean
|
||||
allowCodeSelection?: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
inputType: 'kcl'
|
||||
createVariableByDefault?: boolean
|
||||
|
@ -187,6 +187,16 @@ export function buildCommandArgument<
|
||||
selectionTypes: arg.selectionTypes,
|
||||
validation: arg.validation,
|
||||
} satisfies CommandArgument<O, T> & { inputType: 'selection' }
|
||||
} else if (arg.inputType === 'selectionMixed') {
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
...baseCommandArgument,
|
||||
multiple: arg.multiple,
|
||||
selectionTypes: arg.selectionTypes,
|
||||
validation: arg.validation,
|
||||
allowNoSelection: arg.allowNoSelection,
|
||||
selectionSource: arg.selectionSource,
|
||||
} satisfies CommandArgument<O, T> & { inputType: 'selectionMixed' }
|
||||
} else if (arg.inputType === 'kcl') {
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
|
@ -43,15 +43,33 @@ export async function submitPromptToEditToQueue({
|
||||
projectName,
|
||||
}: {
|
||||
prompt: string
|
||||
selections: Selections
|
||||
selections: Selections | null
|
||||
code: string
|
||||
projectName: string
|
||||
token?: string
|
||||
artifactGraph: ArtifactGraph
|
||||
}): Promise<Models['TextToCadIteration_type'] | Error> {
|
||||
// If no selection, use whole file
|
||||
if (selections === null) {
|
||||
const body: Models['TextToCadIterationBody_type'] = {
|
||||
original_source_code: code,
|
||||
prompt,
|
||||
source_ranges: [], // Empty ranges indicates whole file
|
||||
project_name:
|
||||
projectName !== '' && projectName !== 'browser'
|
||||
? projectName
|
||||
: undefined,
|
||||
kcl_version: kclManager.kclVersion,
|
||||
}
|
||||
return submitToApi(body, token)
|
||||
}
|
||||
|
||||
// Handle manual code selections and artifact selections differently
|
||||
const ranges: Models['TextToCadIterationBody_type']['source_ranges'] =
|
||||
selections.graphSelections.flatMap((selection) => {
|
||||
const artifact = selection.artifact
|
||||
|
||||
// For artifact selections, add context
|
||||
const prompts: Models['TextToCadIterationBody_type']['source_ranges'] = []
|
||||
|
||||
if (artifact?.type === 'cap') {
|
||||
@ -153,8 +171,17 @@ See later source ranges for more context. about the sweep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!artifact) {
|
||||
// manually selected code is more likely to not have an artifact
|
||||
// an example might be highlighting the variable name only in a variable declaration
|
||||
prompts.push({
|
||||
prompt: '',
|
||||
range: convertAppRangeToApiRange(selection.codeRef.range, code),
|
||||
})
|
||||
}
|
||||
return prompts
|
||||
})
|
||||
|
||||
const body: Models['TextToCadIterationBody_type'] = {
|
||||
original_source_code: code,
|
||||
prompt,
|
||||
@ -163,6 +190,15 @@ See later source ranges for more context. about the sweep`,
|
||||
projectName !== '' && projectName !== 'browser' ? projectName : undefined,
|
||||
kcl_version: kclManager.kclVersion,
|
||||
}
|
||||
|
||||
return submitToApi(body, token)
|
||||
}
|
||||
|
||||
// Helper function to handle API submission
|
||||
async function submitToApi(
|
||||
body: Models['TextToCadIterationBody_type'],
|
||||
token?: string
|
||||
): Promise<Models['TextToCadIteration_type'] | Error> {
|
||||
const url = VITE_KC_API_BASE_URL + '/ml/text-to-cad/iteration'
|
||||
const data: Models['TextToCadIteration_type'] | Error =
|
||||
await crossPlatformFetch(
|
||||
|
@ -481,7 +481,9 @@ export function getSelectionTypeDisplayText(
|
||||
|
||||
export function canSubmitSelectionArg(
|
||||
selectionsByType: 'none' | Map<ResolvedSelectionType, number>,
|
||||
argument: CommandArgument<unknown> & { inputType: 'selection' }
|
||||
argument: CommandArgument<unknown> & {
|
||||
inputType: 'selection' | 'selectionMixed'
|
||||
}
|
||||
) {
|
||||
return (
|
||||
selectionsByType !== 'none' &&
|
||||
|
@ -295,7 +295,8 @@ export const commandBarMachine = setup({
|
||||
if (
|
||||
context.currentArgument &&
|
||||
context.selectedCommand &&
|
||||
argConfig?.inputType === 'selection' &&
|
||||
(argConfig?.inputType === 'selection' ||
|
||||
argConfig?.inputType === 'selectionMixed') &&
|
||||
argConfig?.validation
|
||||
) {
|
||||
argConfig
|
||||
|
Reference in New Issue
Block a user