diff --git a/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx b/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx index 63a0632523530..4dab268bccfc8 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/app/DefaultFeatureFlags.oss.tsx @@ -8,6 +8,9 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial> [FeatureFlag.flagAssetSelectionSyntax]: new URLSearchParams(global?.location?.search ?? '').has( 'new-asset-selection-syntax', ), + [FeatureFlag.flagRunSelectionSyntax]: new URLSearchParams(global?.location?.search ?? '').has( + 'new-run-selection-syntax', + ), // Flags for tests [FeatureFlag.__TestFlagDefaultTrue]: true, diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx index f3060d7f460f8..ef87451179f9b 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionInput.oss.tsx @@ -1,19 +1,15 @@ -import {Colors, Icon, Icons} from '@dagster-io/ui-components'; -import CodeMirror, {Editor, HintFunction} from 'codemirror'; -import {useLayoutEffect, useMemo, useRef} from 'react'; -import styled, {createGlobalStyle, css} from 'styled-components'; +import {Icons} from '@dagster-io/ui-components'; +import {useMemo} from 'react'; +import styled from 'styled-components'; -import {lintAssetSelection} from './AssetSelectionLinter'; import {assertUnreachable} from '../../app/Util'; import {AssetGraphQueryItem} from '../../asset-graph/useAssetGraphData'; -import {useUpdatingRef} from '../../hooks/useUpdatingRef'; -import {createSelectionHint} from '../../selection/SelectionAutoComplete'; -import { - SelectionAutoCompleteInputCSS, - applyStaticSyntaxHighlighting, -} from '../../selection/SelectionAutoCompleteHighlighter'; +import {SelectionAutoCompleteInput, iconStyle} from '../../selection/SelectionAutoCompleteInput'; +import {createSelectionLinter} from '../../selection/createSelectionLinter'; import {placeholderTextForItems} from '../../ui/GraphQueryInput'; import {buildRepoPathForHuman} from '../../workspace/buildRepoAddress'; +import {AssetSelectionLexer} from '../generated/AssetSelectionLexer'; +import {AssetSelectionParser} from '../generated/AssetSelectionParser'; import 'codemirror/addon/edit/closebrackets'; import 'codemirror/lib/codemirror.css'; @@ -32,215 +28,86 @@ interface AssetSelectionInputProps { const FUNCTIONS = ['sinks', 'roots']; export const AssetSelectionInput = ({value, onChange, assets}: AssetSelectionInputProps) => { - const editorRef = useRef(null); - const cmInstance = useRef(null); - - const currentValueRef = useRef(value); - - const hintRef = useUpdatingRef( - useMemo(() => { - const assetNamesSet: Set = new Set(); - const tagNamesSet: Set = new Set(); - const ownersSet: Set = new Set(); - const groupsSet: Set = new Set(); - const kindsSet: Set = new Set(); - const codeLocationSet: Set = new Set(); - - assets.forEach((asset) => { - assetNamesSet.add(asset.name); - asset.node.tags.forEach((tag) => { - if (tag.key && tag.value) { - tagNamesSet.add(`${tag.key}=${tag.value}`); - } else { - tagNamesSet.add(tag.key); - } - }); - asset.node.owners.forEach((owner) => { - switch (owner.__typename) { - case 'TeamAssetOwner': - ownersSet.add(owner.team); - break; - case 'UserAssetOwner': - ownersSet.add(owner.email); - break; - default: - assertUnreachable(owner); - } - }); - if (asset.node.groupName) { - groupsSet.add(asset.node.groupName); + const attributesMap = useMemo(() => { + const assetNamesSet: Set = new Set(); + const tagNamesSet: Set = new Set(); + const ownersSet: Set = new Set(); + const groupsSet: Set = new Set(); + const kindsSet: Set = new Set(); + const codeLocationSet: Set = new Set(); + + assets.forEach((asset) => { + assetNamesSet.add(asset.name); + asset.node.tags.forEach((tag) => { + if (tag.key && tag.value) { + tagNamesSet.add(`${tag.key}=${tag.value}`); + } else { + tagNamesSet.add(tag.key); } - asset.node.kinds.forEach((kind) => { - kindsSet.add(kind); - }); - const location = buildRepoPathForHuman( - asset.node.repository.name, - asset.node.repository.location.name, - ); - codeLocationSet.add(location); - }); - - const assetNames = Array.from(assetNamesSet); - const tagNames = Array.from(tagNamesSet); - const owners = Array.from(ownersSet); - const groups = Array.from(groupsSet); - const kinds = Array.from(kindsSet); - const codeLocations = Array.from(codeLocationSet); - - return createSelectionHint( - 'key', - { - key: assetNames, - tag: tagNames, - owner: owners, - group: groups, - kind: kinds, - code_location: codeLocations, - }, - FUNCTIONS, - ); - }, [assets]), - ); - - useLayoutEffect(() => { - if (editorRef.current && !cmInstance.current) { - cmInstance.current = CodeMirror(editorRef.current, { - value, - mode: 'assetSelection', - lineNumbers: false, - lineWrapping: false, - scrollbarStyle: 'native', - autoCloseBrackets: true, - lint: { - getAnnotations: lintAssetSelection, - async: false, - }, - placeholder: placeholderTextForItems('Type an asset subset…', assets), - extraKeys: { - 'Ctrl-Space': 'autocomplete', - Tab: (cm: Editor) => { - cm.replaceSelection(' ', 'end'); - }, - }, }); - - cmInstance.current.setSize('100%', 20); - - // Enforce single line by preventing newlines - cmInstance.current.on('beforeChange', (_instance: Editor, change) => { - if (change.text.some((line) => line.includes('\n'))) { - change.cancel(); - } - }); - - cmInstance.current.on('change', (instance: Editor, change) => { - const newValue = instance.getValue().replace(/\s+/g, ' '); - currentValueRef.current = newValue; - onChange(newValue); - - if (change.origin === 'complete' && change.text[0]?.endsWith('()')) { - // Set cursor inside the right parenthesis - const cursor = instance.getCursor(); - instance.setCursor({...cursor, ch: cursor.ch - 1}); + asset.node.owners.forEach((owner) => { + switch (owner.__typename) { + case 'TeamAssetOwner': + ownersSet.add(owner.team); + break; + case 'UserAssetOwner': + ownersSet.add(owner.email); + break; + default: + assertUnreachable(owner); } }); - - cmInstance.current.on('inputRead', (instance: Editor) => { - showHint(instance, hintRef.current); - }); - - cmInstance.current.on('cursorActivity', (instance: Editor) => { - applyStaticSyntaxHighlighting(instance); - showHint(instance, hintRef.current); - }); - - requestAnimationFrame(() => { - if (!cmInstance.current) { - return; - } - - applyStaticSyntaxHighlighting(cmInstance.current); + if (asset.node.groupName) { + groupsSet.add(asset.node.groupName); + } + asset.node.kinds.forEach((kind) => { + kindsSet.add(kind); }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Update CodeMirror when value prop changes - useLayoutEffect(() => { - const noNewLineValue = value.replace('\n', ' '); - if (cmInstance.current && cmInstance.current.getValue() !== noNewLineValue) { - const instance = cmInstance.current; - const cursor = instance.getCursor(); - instance.setValue(noNewLineValue); - instance.setCursor(cursor); - showHint(instance, hintRef.current); - } - }, [hintRef, value]); + const location = buildRepoPathForHuman( + asset.node.repository.name, + asset.node.repository.location.name, + ); + codeLocationSet.add(location); + }); + const assetNames = Array.from(assetNamesSet); + const tagNames = Array.from(tagNamesSet); + const owners = Array.from(ownersSet); + const groups = Array.from(groupsSet); + const kinds = Array.from(kindsSet); + const codeLocations = Array.from(codeLocationSet); + + return { + key: assetNames, + tag: tagNames, + owner: owners, + group: groups, + kind: kinds, + code_location: codeLocations, + }; + }, [assets]); + + const linter = useMemo( + () => createSelectionLinter({Lexer: AssetSelectionLexer, Parser: AssetSelectionParser}), + [], + ); return ( - <> - - - -
- - - + + + ); }; -const iconStyle = (img: string) => css` - &:before { - content: ' '; - width: 14px; - mask-size: contain; - mask-repeat: no-repeat; - mask-position: center; - mask-image: url(${img}); - background: ${Colors.accentPrimary()}; - display: inline-block; - } -`; - -const InputDiv = styled.div` - ${SelectionAutoCompleteInputCSS} +const WrapperDiv = styled.div` .attribute-owner { ${iconStyle(Icons.owner.src)} } `; - -const GlobalHintStyles = createGlobalStyle` - .CodeMirror-hints { - background: ${Colors.popoverBackground()}; - border: none; - border-radius: 4px; - padding: 8px 4px; - .CodeMirror-hint { - border-radius: 4px; - font-size: 14px; - padding: 6px 8px 6px 12px; - color: ${Colors.textDefault()}; - &.CodeMirror-hint-active { - background-color: ${Colors.backgroundBlue()}; - color: ${Colors.textDefault()}; - } - } - } -`; - -function showHint(instance: Editor, hint: HintFunction) { - requestAnimationFrame(() => { - instance.showHint({ - hint, - completeSingle: false, - moveOnOverlap: true, - updateOnCursorActivity: true, - }); - }); -} diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts deleted file mode 100644 index f1f1eb059c23e..0000000000000 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionLinter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {CharStreams, CommonTokenStream} from 'antlr4ts'; -import CodeMirror from 'codemirror'; - -import {AssetSelectionSyntaxErrorListener} from './AssetSelectionSyntaxErrorListener'; -import {AssetSelectionLexer} from '../generated/AssetSelectionLexer'; -import {AssetSelectionParser} from '../generated/AssetSelectionParser'; - -export const lintAssetSelection = (text: string) => { - const errorListener = new AssetSelectionSyntaxErrorListener(); - - const inputStream = CharStreams.fromString(text); - const lexer = new AssetSelectionLexer(inputStream); - - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); - - const tokens = new CommonTokenStream(lexer); - const parser = new AssetSelectionParser(tokens); - - parser.removeErrorListeners(); // Remove default console error listener - parser.addErrorListener(errorListener); - - parser.start(); - - // Map syntax errors to CodeMirror's lint format - const lintErrors = errorListener.errors.map((error) => ({ - message: error.message.replace(', ', ''), - severity: 'error', - from: CodeMirror.Pos(error.line, error.column), - to: CodeMirror.Pos(error.line, text.length), - })); - - return lintErrors; -}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx b/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx deleted file mode 100644 index 89d87f2dbf09e..0000000000000 --- a/js_modules/dagster-ui/packages/ui-core/src/asset-selection/input/AssetSelectionSyntaxErrorListener.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {ANTLRErrorListener, RecognitionException, Recognizer} from 'antlr4ts'; - -interface SyntaxError { - message: string; - line: number; - column: number; -} - -export class AssetSelectionSyntaxErrorListener implements ANTLRErrorListener { - public errors: SyntaxError[] = []; - - syntaxError( - _recognizer: Recognizer, - _offendingSymbol: T | undefined, - line: number, - charPositionInLine: number, - msg: string, - _e: RecognitionException | undefined, - ): void { - this.errors.push({ - message: msg, - line: line - 1, // CodeMirror lines are 0-based - column: charPositionInLine, - }); - } -} diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts index 8603de6447fe8..8973cd0835cdd 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoComplete.ts @@ -38,7 +38,7 @@ type TextCallback = (value: string) => string; const DEFAULT_TEXT_CALLBACK = (value: string) => value; // set to true for useful debug output. -const DEBUG = true; +const DEBUG = false; export class SelectionAutoCompleteVisitor extends AbstractParseTreeVisitor @@ -507,11 +507,15 @@ export class SelectionAutoCompleteVisitor } } -export function createSelectionHint, N extends keyof T>( - _nameBase: N, - attributesMap: T, - functions: string[], -): CodeMirror.HintFunction { +export function createSelectionHint, N extends keyof T>({ + nameBase: _nameBase, + attributesMap, + functions, +}: { + nameBase: N; + attributesMap: T; + functions: string[]; +}): CodeMirror.HintFunction { const nameBase = _nameBase as string; return function (cm: CodeMirror.Editor, _options: CodeMirror.ShowHintOptions): any { diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx new file mode 100644 index 0000000000000..e84b930cf248d --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/SelectionAutoCompleteInput.tsx @@ -0,0 +1,212 @@ +import {Colors, Icon} from '@dagster-io/ui-components'; +import CodeMirror, {Editor, HintFunction} from 'codemirror'; +import {Linter} from 'codemirror/addon/lint/lint'; +import {useLayoutEffect, useMemo, useRef} from 'react'; +import styled, {createGlobalStyle, css} from 'styled-components'; + +import { + SelectionAutoCompleteInputCSS, + applyStaticSyntaxHighlighting, +} from './SelectionAutoCompleteHighlighter'; +import {useUpdatingRef} from '../hooks/useUpdatingRef'; +import {createSelectionHint} from '../selection/SelectionAutoComplete'; + +import 'codemirror/addon/edit/closebrackets'; +import 'codemirror/lib/codemirror.css'; +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/hint/show-hint.css'; +import 'codemirror/addon/lint/lint.css'; +import 'codemirror/addon/display/placeholder'; + +type SelectionAutoCompleteInputProps, N extends keyof T> = { + nameBase: N; + attributesMap: T; + placeholder: string; + functions: string[]; + linter: Linter; + value: string; + onChange: (value: string) => void; +}; + +export const SelectionAutoCompleteInput = , N extends keyof T>({ + value, + nameBase, + placeholder, + onChange, + functions, + linter, + attributesMap, +}: SelectionAutoCompleteInputProps) => { + const editorRef = useRef(null); + const cmInstance = useRef(null); + + const currentValueRef = useUpdatingRef(value); + const currentPendingValueRef = useRef(value); + const setValueTimeoutRef = useRef>(null); + + const hintRef = useUpdatingRef( + useMemo(() => { + return createSelectionHint({nameBase, attributesMap, functions}); + }, [nameBase, attributesMap, functions]), + ); + + useLayoutEffect(() => { + if (editorRef.current && !cmInstance.current) { + cmInstance.current = CodeMirror(editorRef.current, { + value, + mode: 'assetSelection', + lineNumbers: false, + lineWrapping: false, + scrollbarStyle: 'native', + autoCloseBrackets: true, + lint: { + getAnnotations: linter, + async: false, + }, + placeholder, + extraKeys: { + 'Ctrl-Space': 'autocomplete', + Tab: (cm: Editor) => { + cm.replaceSelection(' ', 'end'); + }, + }, + }); + + cmInstance.current.setSize('100%', 20); + + // Enforce single line by preventing newlines + cmInstance.current.on('beforeChange', (_instance: Editor, change) => { + if (change.text.some((line) => line.includes('\n'))) { + change.cancel(); + } + }); + + cmInstance.current.on('change', (instance: Editor, change) => { + const newValue = instance.getValue().replace(/\s+/g, ' '); + currentPendingValueRef.current = newValue; + if (setValueTimeoutRef.current) { + clearTimeout(setValueTimeoutRef.current); + } + setValueTimeoutRef.current = setTimeout(() => { + onChange(newValue); + }, 2000); + + if (change.origin === 'complete' && change.text[0]?.endsWith('()')) { + // Set cursor inside the right parenthesis + const cursor = instance.getCursor(); + instance.setCursor({...cursor, ch: cursor.ch - 1}); + } + }); + + cmInstance.current.on('inputRead', (instance: Editor) => { + showHint(instance, hintRef.current); + }); + + cmInstance.current.on('cursorActivity', (instance: Editor) => { + applyStaticSyntaxHighlighting(instance); + showHint(instance, hintRef.current); + }); + + cmInstance.current.on('blur', () => { + if (currentPendingValueRef.current !== currentValueRef.current) { + onChange(currentPendingValueRef.current); + } + }); + + requestAnimationFrame(() => { + if (!cmInstance.current) { + return; + } + + applyStaticSyntaxHighlighting(cmInstance.current); + }); + } + + return () => { + const cm = cmInstance.current; + if (cm) { + // Clean up the instance... + cm.closeHint(); + cm.getWrapperElement()?.parentNode?.removeChild(cm.getWrapperElement()); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update CodeMirror when value prop changes + useLayoutEffect(() => { + const noNewLineValue = value.replace('\n', ' '); + if (cmInstance.current && cmInstance.current.getValue() !== noNewLineValue) { + const instance = cmInstance.current; + const cursor = instance.getCursor(); + instance.setValue(noNewLineValue); + instance.setCursor(cursor); + showHint(instance, hintRef.current); + } + }, [hintRef, value]); + + return ( + <> + + + +
+ + + ); +}; + +export const iconStyle = (img: string) => css` + &:before { + content: ' '; + width: 14px; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url(${img}); + background: ${Colors.accentPrimary()}; + display: inline-block; + } +`; + +export const InputDiv = styled.div` + ${SelectionAutoCompleteInputCSS} +`; + +const GlobalHintStyles = createGlobalStyle` + .CodeMirror-hints { + background: ${Colors.popoverBackground()}; + border: none; + border-radius: 4px; + padding: 8px 4px; + .CodeMirror-hint { + border-radius: 4px; + font-size: 14px; + padding: 6px 8px 6px 12px; + color: ${Colors.textDefault()}; + &.CodeMirror-hint-active { + background-color: ${Colors.backgroundBlue()}; + color: ${Colors.textDefault()}; + } + } + } +`; + +function showHint(instance: Editor, hint: HintFunction) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + instance.showHint({ + hint, + completeSingle: false, + moveOnOverlap: true, + updateOnCursorActivity: true, + }); + }); + }); +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts index 9e63b325f6d1a..88322779bd09a 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/__tests__/SelectionAutoComplete.test.ts @@ -3,9 +3,9 @@ import {Hint, Hints, Position} from 'codemirror'; import {createSelectionHint} from '../SelectionAutoComplete'; describe('createAssetSelectionHint', () => { - const selectionHint = createSelectionHint( - 'key', - { + const selectionHint = createSelectionHint({ + nameBase: 'key', + attributesMap: { key: ['asset1', 'asset2', 'asset3'], tag: ['tag1', 'tag2', 'tag3'], owner: ['marco@dagsterlabs.com', 'team:frontend'], @@ -13,8 +13,8 @@ describe('createAssetSelectionHint', () => { kind: ['kind1', 'kind2'], code_location: ['repo1@location1', 'repo2@location2'], }, - ['sinks', 'roots'], - ); + functions: ['sinks', 'roots'], + }); type HintsModified = Omit & { list: Array; @@ -818,4 +818,65 @@ describe('createAssetSelectionHint', () => { to: 60, }); }); + + it('handles complex ands/ors', () => { + expect(testAutocomplete('key:"value"* or tag:"value"+ and owner:"owner" and |')).toEqual({ + from: 51, + list: [ + { + displayText: 'key_substring:', + text: 'key_substring:', + }, + { + displayText: 'key:', + text: 'key:', + }, + { + displayText: 'tag:', + text: 'tag:', + }, + { + displayText: 'owner:', + text: 'owner:', + }, + { + displayText: 'group:', + text: 'group:', + }, + { + displayText: 'kind:', + text: 'kind:', + }, + { + displayText: 'code_location:', + text: 'code_location:', + }, + { + displayText: 'sinks()', + text: 'sinks()', + }, + { + displayText: 'roots()', + text: 'roots()', + }, + { + displayText: 'not', + text: 'not ', + }, + { + displayText: '*', + text: '*', + }, + { + displayText: '+', + text: '+', + }, + { + displayText: '(', + text: '()', + }, + ], + to: 51, + }); + }); }); diff --git a/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts b/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts new file mode 100644 index 0000000000000..ac10d4c98f504 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/selection/createSelectionLinter.ts @@ -0,0 +1,46 @@ +import {CharStreams, CommonTokenStream, Lexer, Parser, ParserRuleContext} from 'antlr4ts'; +import CodeMirror from 'codemirror'; +import {Linter} from 'codemirror/addon/lint/lint'; + +import {CustomErrorListener} from './CustomErrorListener'; + +type LexerConstructor = new (...args: any[]) => Lexer; +type ParserConstructor = new (...args: any[]) => Parser & { + start: () => ParserRuleContext; +}; + +export function createSelectionLinter({ + Lexer: LexerKlass, + Parser: ParserKlass, +}: { + Lexer: LexerConstructor; + Parser: ParserConstructor; +}): Linter { + return (text: string) => { + const errorListener = new CustomErrorListener(); + + const inputStream = CharStreams.fromString(text); + const lexer = new LexerKlass(inputStream); + + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); + + const tokens = new CommonTokenStream(lexer); + const parser = new ParserKlass(tokens); + + parser.removeErrorListeners(); // Remove default console error listener + parser.addErrorListener(errorListener); + + parser.start(); + + // Map syntax errors to CodeMirror's lint format + const lintErrors = errorListener.getErrors().map((error) => ({ + message: error.message.replace(', ', ''), + severity: 'error', + from: CodeMirror.Pos(error.line, error.column), + to: CodeMirror.Pos(error.line, text.length), + })); + + return lintErrors; + }; +}