Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add parameterization rule custom code UI #344

Merged
merged 10 commits into from
Nov 18, 2024
Merged
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"@vitejs/plugin-react": "^4.3.1",
"allotment": "^1.20.2",
"chokidar": "^3.6.0",
"constrained-editor-plugin": "^1.3.0",
"electron-log": "^5.2.0",
"electron-squirrel-startup": "^1.0.1",
"find-process": "^1.4.7",
Expand Down
74 changes: 74 additions & 0 deletions src/components/Monaco/ConstrainerCodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as monacoTypes from 'monaco-editor'
import { ReactMonacoEditor } from './ReactMonacoEditor'
// Locally added types
// eslint-disable-next-line import/no-named-as-default
import constrainedEditor, {
ConstrainedEditorInstance,
RestrictionObject,
} from 'constrained-editor-plugin'
import { useEffect, useState } from 'react'
import { EditorProps } from '@monaco-editor/react'

interface CodeEditorProps {
value: string
onChange?: (value: string) => void
editableRange: RestrictionObject['range']
options: EditorProps['options']
}

export function ConstrainedCodeEditor({
value,
onChange,
editableRange,
options,
}: CodeEditorProps) {
const [model, setModel] = useState<monacoTypes.editor.ITextModel | null>()
const [constrainedInstance, setConstrainedInstance] =
useState<ConstrainedEditorInstance>()

useEffect(() => {
if (!model || !constrainedInstance) {
return
}

// Add editable range to editor
const constrainedModel = constrainedInstance.addRestrictionsTo(model, [
{
range: editableRange,
label: 'editableRange', // Used for reading value onDidChangeContentInEditableRange
allowMultiline: true,
},
])

// Listen to changes in the editable range
constrainedModel.onDidChangeContentInEditableRange(
(currentlyChangedContent) =>
onChange?.(currentlyChangedContent.editableRange ?? '')
)

// Cleanup
return constrainedModel.disposeRestrictions
}, [model, constrainedInstance, editableRange, onChange])

const handleEditorMount = (
editor: monacoTypes.editor.IStandaloneCodeEditor,
monaco: typeof monacoTypes
) => {
const instance = constrainedEditor(monaco)
instance.initializeIn(editor)

const model = editor.getModel()
setModel(model)

setConstrainedInstance(instance)
}

return (
<ReactMonacoEditor
defaultLanguage="javascript"
options={options}
value={value}
onMount={handleEditorMount}
/>
)
}
4 changes: 2 additions & 2 deletions src/rules/parameterization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@ function getBeforeSnippet(rule: ParameterizationRule, id: number) {
}
}

function getCustomCodeSnippet(code: string, id: number) {
export function getCustomCodeSnippet(code: string, id: number) {
return `function getParameterizationValue${id}() {
${code}
${code}
}`
}

Expand Down
11 changes: 11 additions & 0 deletions src/store/generator/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,14 @@ export function selectHasVerificationRule(state: GeneratorStore) {
export function selectHasGroups(state: GeneratorStore) {
return state.requests.some((request) => request.group)
}

export function selectSelectedRuleIndex(state: GeneratorStore) {
const selectedRule = selectSelectedRule(state)
if (!selectedRule) {
return 0
}

return state.rules
.filter((rule) => rule.type === selectedRule.type)
.findIndex((rule) => rule.id === state.selectedRuleId)
}
59 changes: 59 additions & 0 deletions src/types/constrainedEditor.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
declare module 'constrained-editor-plugin' {
Copy link
Collaborator

Choose a reason for hiding this comment

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

😮 Are they accepting contributions?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The repository doesn't look active lately 😞 But we have already been using this in GCk6 and had types defined there

import * as monacoTypes from 'monaco-editor'

type Range = [number, number, number, number] | number[]
type EditableRangesMap = Record<string, RestrictionObject>
type ValueMap = Record<string, string>

type ValueInEditableRanges = Record<string, string>

type EditableRangeObject = Record<
string,
{ allowMultiline: boolean; range: Range; originalRange: Range }
>

export interface RestrictionObject {
label: string
range: Range
allowMultiline?: boolean
validate?: (currentValue: string, currentRange: Range) => boolean
}

export interface ConstrainedModel extends monacoTypes.editor.ITextModel {
editInRestrictedArea: boolean
getCurrentEditableRanges: () => EditableRangesMap
getValueInEditableRanges: () => ValueMap
disposeRestrictions: () => void
onDidChangeContentInEditableRange: (
callback: (
currentlyChangedContent: ValueInEditableRanges,
allValuesInEditableRanges: ValueInEditableRanges,
currentEditableRangeObject: EditableRangeObject
) => void
) => void
updateRestrictions: (ranges: RestrictionObject[]) => void
updateValueInEditableRanges: (
object: ValueMap,
forceMoveMarkers?: boolean
) => void
toggleHighlightOfEditableAreas: () => void
}

export interface ConstrainedEditorInstance {
initializeIn: (
editor: monacoTypes.editor.IStandaloneCodeEditor
) => boolean | never
addRestrictionsTo: (
model: monacoTypes.editor.ITextModel,
ranges: RestrictionObject[]
) => ConstrainedModel
removeRestrictionsIn: () => boolean | never
disposeConstrainer: () => boolean
toggleDevMode: () => void
}

export declare function constrainedEditor(
monaco: typeof monacoTypes
): ConstrainedEditorInstance
export default constrainedEditor
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FieldGroup } from '@/components/Form'
import { ConstrainedCodeEditor } from '@/components/Monaco/ConstrainerCodeEditor'
import { getCustomCodeSnippet } from '@/rules/parameterization'
import { selectSelectedRuleIndex, useGeneratorStore } from '@/store/generator'
import { ParameterizationRule } from '@/types/rules'
import { Controller, useFormContext } from 'react-hook-form'

export function CustomCode() {
const {
control,
formState: { errors },
} = useFormContext<ParameterizationRule>()
const ruleIndex = useGeneratorStore(selectSelectedRuleIndex)

return (
<FieldGroup name="value.code" errors={errors} label="Snippet">
<Controller
name="value.code"
control={control}
render={({ field }) => (
<div css={{ height: 200 }}>
<ConstrainedCodeEditor
value={getCustomCodeSnippet(
valueWithFallback(field.value),
ruleIndex
)}
onChange={field.onChange}
editableRange={getEditableRanges(valueWithFallback(field.value))}
options={{ wordWrap: 'on' }}
/>
</div>
)}
/>
</FieldGroup>
)
}

function valueWithFallback(value?: string) {
return (
value ?? ' // Enter your code here, make sure to add a return statement'
)
}

function getEditableRanges(value?: string) {
const lines = (value ?? '').split('\n')
const lastLine = lines[lines.length - 1] ?? '1'
const startLine = 2 // Skip the first line with the function declaration
const startColumn = 1
const endLine = lines.length + 1
const endColumn = lastLine.length + 1

return [startLine, startColumn, endLine, endColumn]
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box, Grid, Heading, Text } from '@radix-ui/themes'
import { FilterField } from './FilterField'
import { SelectorField } from './SelectorField'
import { ParamaterizationValueEditor } from './ParameterizationValueEditor'
import { ValueEditor } from './ValueEditor'
import { FilterField } from '../FilterField'
import { SelectorField } from '../SelectorField'

export function ParameterizationEditor() {
return (
Expand All @@ -19,7 +19,7 @@ export function ParameterizationEditor() {
<SelectorField field="selector" />
</Box>
<Box>
<ParamaterizationValueEditor />
<ValueEditor />
</Box>
</Grid>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Code, Flex, Text, TextField } from '@radix-ui/themes'
import { 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'
import { VariableSelect } from './VariableSelect'
import { CustomCode } from './CustomCode'

export function ParamaterizationValueEditor() {
export function ValueEditor() {
const {
control,
formState: { errors },
Expand All @@ -23,6 +24,7 @@ export function ParamaterizationValueEditor() {
const VALUE_TYPE_OPTIONS = [
{ value: 'string', label: 'Text value' },
{ value: 'variable', label: variablesLabel, disabled: !variablesExist },
{ value: 'customCode', label: 'Custom code' },
]

return (
Expand Down Expand Up @@ -58,58 +60,13 @@ function ValueTypeSwitch() {
case 'variable':
return <VariableSelect />

case 'array':
case 'customCode':
return <CustomCode />

case 'array':
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>
)
}
Loading
Loading