A bit more chaining cleanup

This commit is contained in:
Gregory Schier
2024-08-19 16:38:28 -07:00
parent 96125a0741
commit dbfe2dc93d
16 changed files with 173 additions and 51 deletions

View File

@@ -1,6 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TemplateFunctionCheckboxArg } from "./TemplateFunctionCheckboxArg";
import type { TemplateFunctionHttpRequestArg } from "./TemplateFunctionHttpRequestArg"; import type { TemplateFunctionHttpRequestArg } from "./TemplateFunctionHttpRequestArg";
import type { TemplateFunctionSelectArg } from "./TemplateFunctionSelectArg"; import type { TemplateFunctionSelectArg } from "./TemplateFunctionSelectArg";
import type { TemplateFunctionTextArg } from "./TemplateFunctionTextArg"; import type { TemplateFunctionTextArg } from "./TemplateFunctionTextArg";
export type TemplateFunctionArg = { "type": "text" } & TemplateFunctionTextArg | { "type": "select" } & TemplateFunctionSelectArg | { "type": "http_request" } & TemplateFunctionHttpRequestArg; export type TemplateFunctionArg = { "type": "text" } & TemplateFunctionTextArg | { "type": "select" } & TemplateFunctionSelectArg | { "type": "checkbox" } & TemplateFunctionCheckboxArg | { "type": "http_request" } & TemplateFunctionHttpRequestArg;

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TemplateFunctionCheckboxArg = { name: string, optional?: boolean | null, label?: string | null, defaultValue?: string | null, };

View File

@@ -56,6 +56,7 @@ export * from './gen/ShowToastRequest';
export * from './gen/TemplateFunction'; export * from './gen/TemplateFunction';
export * from './gen/TemplateFunctionArg'; export * from './gen/TemplateFunctionArg';
export * from './gen/TemplateFunctionBaseArg'; export * from './gen/TemplateFunctionBaseArg';
export * from './gen/TemplateFunctionCheckboxArg';
export * from './gen/TemplateFunctionHttpRequestArg'; export * from './gen/TemplateFunctionHttpRequestArg';
export * from './gen/TemplateFunctionSelectArg'; export * from './gen/TemplateFunctionSelectArg';
export * from './gen/TemplateFunctionSelectOption'; export * from './gen/TemplateFunctionSelectOption';

View File

@@ -1,4 +1,5 @@
import { import {
Context,
FindHttpResponsesResponse, FindHttpResponsesResponse,
GetHttpRequestByIdResponse, GetHttpRequestByIdResponse,
HttpRequestAction, HttpRequestAction,
@@ -9,7 +10,6 @@ import {
SendHttpRequestResponse, SendHttpRequestResponse,
TemplateFunction, TemplateFunction,
} from '@yaakapp/api'; } from '@yaakapp/api';
import { Context } from '@yaakapp/api';
import { HttpRequestActionPlugin } from '@yaakapp/api/lib/plugins/httpRequestAction'; import { HttpRequestActionPlugin } from '@yaakapp/api/lib/plugins/httpRequestAction';
import { TemplateFunctionPlugin } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin'; import { TemplateFunctionPlugin } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import interceptStdout from 'intercept-stdout'; import interceptStdout from 'intercept-stdout';
@@ -18,7 +18,6 @@ import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import * as util from 'node:util'; import * as util from 'node:util';
import { parentPort, workerData } from 'node:worker_threads'; import { parentPort, workerData } from 'node:worker_threads';
import { text } from '../../src-web/components/core/Editor/text/extension';
new Promise<void>(async (resolve, reject) => { new Promise<void>(async (resolve, reject) => {
const { pluginDir, pluginRefId } = workerData; const { pluginDir, pluginRefId } = workerData;
@@ -244,7 +243,6 @@ new Promise<void>(async (resolve, reject) => {
const action = mod.plugin.templateFunctions.find((a) => a.name === payload.name); const action = mod.plugin.templateFunctions.find((a) => a.name === payload.name);
if (typeof action?.onRender === 'function') { if (typeof action?.onRender === 'function') {
const result = await action.onRender(ctx, payload.args); const result = await action.onRender(ctx, payload.args);
console.log('GOT VALUE', result);
sendPayload({ type: 'call_template_function_response', value: result ?? null }, replyId); sendPayload({ type: 'call_template_function_response', value: result ?? null }, replyId);
return; return;
} }

View File

@@ -54,7 +54,7 @@ pub enum InternalEventPayload {
GetHttpRequestByIdRequest(GetHttpRequestByIdRequest), GetHttpRequestByIdRequest(GetHttpRequestByIdRequest),
GetHttpRequestByIdResponse(GetHttpRequestByIdResponse), GetHttpRequestByIdResponse(GetHttpRequestByIdResponse),
FindHttpResponsesRequest(FindHttpResponsesRequest), FindHttpResponsesRequest(FindHttpResponsesRequest),
FindHttpResponsesResponse(FindHttpResponsesResponse), FindHttpResponsesResponse(FindHttpResponsesResponse),
@@ -210,6 +210,7 @@ pub struct TemplateFunction {
pub enum TemplateFunctionArg { pub enum TemplateFunctionArg {
Text(TemplateFunctionTextArg), Text(TemplateFunctionTextArg),
Select(TemplateFunctionSelectArg), Select(TemplateFunctionSelectArg),
Checkbox(TemplateFunctionCheckboxArg),
HttpRequest(TemplateFunctionHttpRequestArg), HttpRequest(TemplateFunctionHttpRequestArg),
} }
@@ -253,6 +254,14 @@ pub struct TemplateFunctionSelectArg {
pub options: Vec<TemplateFunctionSelectOption>, pub options: Vec<TemplateFunctionSelectOption>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export)]
pub struct TemplateFunctionCheckboxArg {
#[serde(flatten)]
pub base: TemplateFunctionBaseArg,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export)] #[ts(export)]

View File

@@ -40,6 +40,7 @@ impl Display for FnArg {
pub enum Val { pub enum Val {
Str { text: String }, Str { text: String },
Var { name: String }, Var { name: String },
Bool { value: bool },
Fn { name: String, args: Vec<FnArg> }, Fn { name: String, args: Vec<FnArg> },
Null, Null,
} }
@@ -49,6 +50,7 @@ impl Display for Val {
let str = match self { let str = match self {
Val::Str { text } => format!(r#""{}""#, text.to_string().replace(r#"""#, r#"\""#)), Val::Str { text } => format!(r#""{}""#, text.to_string().replace(r#"""#, r#"\""#)),
Val::Var { name } => name.to_string(), Val::Var { name } => name.to_string(),
Val::Bool { value } => value.to_string(),
Val::Fn { name, args } => { Val::Fn { name, args } => {
format!( format!(
"{name}({})", "{name}({})",
@@ -176,6 +178,10 @@ impl Parser {
} else if let Some(v) = self.parse_ident() { } else if let Some(v) = self.parse_ident() {
if v == "null" { if v == "null" {
Some(Val::Null) Some(Val::Null)
} else if v == "true" {
Some(Val::Bool { value: true })
} else if v == "false" {
Some(Val::Bool { value: false })
} else { } else {
Some(Val::Var { name: v }) Some(Val::Var { name: v })
} }
@@ -397,6 +403,23 @@ mod tests {
); );
} }
#[test]
fn var_boolean() {
let mut p = Parser::new("${[ true ]}${[ false ]}");
assert_eq!(
p.parse().tokens,
vec![
Token::Tag {
val: Val::Bool { value: true },
},
Token::Tag {
val: Val::Bool { value: false },
},
Token::Eof
]
);
}
#[test] #[test]
fn var_multiple_names_invalid() { fn var_multiple_names_invalid() {
let mut p = Parser::new("${[ foo bar ]}"); let mut p = Parser::new("${[ foo bar ]}");
@@ -516,7 +539,7 @@ mod tests {
#[test] #[test]
fn fn_mixed_args() { fn fn_mixed_args() {
let mut p = Parser::new(r#"${[ foo(aaa=bar,bb="baz \"hi\"", c=qux ) ]}"#); let mut p = Parser::new(r#"${[ foo(aaa=bar,bb="baz \"hi\"", c=qux, z=true ) ]}"#);
assert_eq!( assert_eq!(
p.parse().tokens, p.parse().tokens,
vec![ vec![
@@ -538,6 +561,10 @@ mod tests {
name: "c".into(), name: "c".into(),
value: Val::Var { name: "qux".into() } value: Val::Var { name: "qux".into() }
}, },
FnArg {
name: "z".into(),
value: Val::Bool { value: true }
},
], ],
} }
}, },

View File

@@ -50,6 +50,7 @@ async fn render_tag<T: TemplateCallback>(
Some(v) => v.to_string(), Some(v) => v.to_string(),
None => "".into(), None => "".into(),
}, },
Val::Bool { value } => value.to_string(),
Val::Fn { name, args } => { Val::Fn { name, args } => {
let empty = "".to_string(); let empty = "".to_string();
let mut resolved_args: HashMap<String, String> = HashMap::new(); let mut resolved_args: HashMap<String, String> = HashMap::new();

View File

@@ -1,6 +1,7 @@
import type { import type {
TemplateFunction, TemplateFunction,
TemplateFunctionArg, TemplateFunctionArg,
TemplateFunctionCheckboxArg,
TemplateFunctionHttpRequestArg, TemplateFunctionHttpRequestArg,
TemplateFunctionSelectArg, TemplateFunctionSelectArg,
TemplateFunctionTextArg, TemplateFunctionTextArg,
@@ -14,6 +15,7 @@ import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString'; import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
import { fallbackRequestName } from '../lib/fallbackRequestName'; import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { Select } from './core/Select'; import { Select } from './core/Select';
@@ -29,7 +31,7 @@ interface Props {
} }
export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) { export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) {
const [argValues, setArgValues] = useState<Record<string, string>>(() => { const [argValues, setArgValues] = useState<Record<string, string | boolean>>(() => {
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
const initialArgs = const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn' initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
@@ -48,20 +50,20 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
return initial; return initial;
}); });
const setArgValue = useCallback((name: string, value: string) => { const setArgValue = useCallback((name: string, value: string | boolean) => {
setArgValues((v) => ({ ...v, [name]: value })); setArgValues((v) => ({ ...v, [name]: value }));
}, []); }, []);
const tokens: Tokens = useMemo(() => { const tokens: Tokens = useMemo(() => {
console.log('HELLO', argValues);
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({ const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
name, name,
value: value:
argValues[name] === NULL_ARG argValues[name] === NULL_ARG
? { type: 'null' } ? { type: 'null' }
: { : typeof argValues[name] === 'boolean'
type: 'str', ? { type: 'bool', value: argValues[name] }
text: argValues[name] ?? '', : { type: 'str', text: argValues[name] ?? '' },
},
})); }));
return { return {
@@ -101,7 +103,7 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
key={i} key={i}
arg={a} arg={a}
onChange={(v) => setArgValue(a.name, v)} onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'} value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
/> />
); );
case 'text': case 'text':
@@ -110,7 +112,16 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
key={i} key={i}
arg={a} arg={a}
onChange={(v) => setArgValue(a.name, v)} onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'} value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
/>
);
case 'checkbox':
return (
<CheckboxArg
key={i}
arg={a}
onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] !== undefined ? argValues[a.name] === true : false}
/> />
); );
case 'http_request': case 'http_request':
@@ -119,7 +130,7 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
key={i} key={i}
arg={a} arg={a}
onChange={(v) => setArgValue(a.name, v)} onChange={(v) => setArgValue(a.name, v)}
value={argValues[a.name] ?? '__ERROR__'} value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
/> />
); );
} }
@@ -211,3 +222,22 @@ function HttpRequestArg({
/> />
); );
} }
function CheckboxArg({
arg,
onChange,
value,
}: {
arg: TemplateFunctionCheckboxArg;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Checkbox
onChange={onChange}
checked={value}
title={arg.label ?? arg.name}
hideLabel={arg.label == null}
/>
);
}

View File

@@ -1,4 +1,3 @@
import type { EnvironmentVariable } from '@yaakapp/api';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import type { Tokens } from '../gen/Tokens'; import type { Tokens } from '../gen/Tokens';
import { useActiveEnvironmentVariables } from '../hooks/useActiveEnvironmentVariables'; import { useActiveEnvironmentVariables } from '../hooks/useActiveEnvironmentVariables';
@@ -10,7 +9,6 @@ import { Select } from './core/Select';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
interface Props { interface Props {
definition: EnvironmentVariable;
initialTokens: Tokens; initialTokens: Tokens;
hide: () => void; hide: () => void;
onChange: (rawTag: string) => void; onChange: (rawTag: string) => void;

View File

@@ -180,7 +180,30 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
); );
const onClickVariable = useCallback( const onClickVariable = useCallback(
async (v: EnvironmentVariable, tagValue: string, startPos: number) => { async (_v: EnvironmentVariable, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
dialog.show({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[dialog],
);
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue); const initialTokens = await parseTemplate(tagValue);
dialog.show({ dialog.show({
size: 'dynamic', size: 'dynamic',
@@ -188,7 +211,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
title: 'Configure Variable', title: 'Configure Variable',
render: ({ hide }) => ( render: ({ hide }) => (
<TemplateVariableDialog <TemplateVariableDialog
definition={v}
hide={hide} hide={hide}
initialTokens={initialTokens} initialTokens={initialTokens}
onChange={(insert) => { onChange={(insert) => {
@@ -215,6 +237,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
templateFunctions, templateFunctions,
onClickFunction, onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable,
}); });
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [ }, [
@@ -225,6 +248,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
templateFunctions, templateFunctions,
onClickFunction, onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable,
]); ]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
@@ -247,6 +271,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
templateFunctions, templateFunctions,
onClickVariable, onClickVariable,
onClickFunction, onClickFunction,
onClickMissingVariable,
}); });
const state = EditorState.create({ const state = EditorState.create({
@@ -358,7 +383,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
justifyContent="end" justifyContent="end"
className={classNames( className={classNames(
'absolute bottom-2 left-0 right-0', 'absolute bottom-2 left-0 right-0',
'pointer-events-none', // No pointer events so we don't block the editor 'pointer-events-none', // No pointer events, so we don't block the editor
)} )}
> >
{decoratedActions} {decoratedActions}

View File

@@ -31,10 +31,9 @@ import {
rectangularSelection, rectangularSelection,
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp/api'; import type { EnvironmentVariable, TemplateFunction } from '@yaakapp/api';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql'; import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { TemplateFunction } from '../../../hooks/useTemplateFunctions';
import type { EditorProps } from './index'; import type { EditorProps } from './index';
import { pairs } from './pairs/extension'; import { pairs } from './pairs/extension';
import { text } from './text/extension'; import { text } from './text/extension';
@@ -84,11 +83,13 @@ export function getLanguageExtension({
templateFunctions, templateFunctions,
onClickVariable, onClickVariable,
onClickFunction, onClickFunction,
onClickMissingVariable,
}: { }: {
environmentVariables: EnvironmentVariable[]; environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[]; templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void; onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
} & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) { } & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') { if (justContentType === 'application/graphql') {
@@ -106,6 +107,7 @@ export function getLanguageExtension({
autocomplete, autocomplete,
onClickFunction, onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable,
}); });
} }

View File

@@ -3,13 +3,22 @@ import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ '; const openTag = '${[ ';
const closeTag = ' ]}'; const closeTag = ' ]}';
export interface TwigCompletionOption { export type TwigCompletionOptionVariable = {
type: 'variable';
};
export type TwigCompletionOptionFunction = {
args: { name: string }[];
type: 'function';
};
export type TwigCompletionOption = (TwigCompletionOptionFunction | TwigCompletionOptionVariable) & {
name: string; name: string;
label: string; label: string;
type: 'function' | 'variable' | 'unknown'; onClick: (rawTag: string, startPos: number) => void;
value: string | null; value: string | null;
onClick?: (rawTag: string, startPos: number) => void; invalid?: boolean;
} };
export interface TwigCompletionConfig { export interface TwigCompletionConfig {
options: TwigCompletionOption[]; options: TwigCompletionOption[];
@@ -46,10 +55,9 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
options: options options: options
.filter((v) => v.name.trim()) .filter((v) => v.name.trim())
.map((v) => { .map((v) => {
const innerLabel = v.type === 'function' ? `${v.name}()` : v.name; const tagSyntax = openTag + v.label + closeTag;
const tagSyntax = openTag + innerLabel + closeTag;
return { return {
label: innerLabel, label: v.label,
apply: tagSyntax, apply: tagSyntax,
type: v.type === 'variable' ? 'variable' : 'function', type: v.type === 'variable' ? 'variable' : 'function',
matchLen: matchLen, matchLen: matchLen,

View File

@@ -1,8 +1,7 @@
import type { LanguageSupport } from '@codemirror/language'; import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import type { EnvironmentVariable } from '@yaakapp/api'; import type { EnvironmentVariable, TemplateFunction } from '@yaakapp/api';
import type { TemplateFunction } from '../../../../hooks/useTemplateFunctions';
import type { GenericCompletionConfig } from '../genericCompletion'; import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
@@ -18,6 +17,7 @@ export function twig({
autocomplete, autocomplete,
onClickFunction, onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable,
}: { }: {
base: LanguageSupport; base: LanguageSupport;
environmentVariables: EnvironmentVariable[]; environmentVariables: EnvironmentVariable[];
@@ -25,6 +25,7 @@ export function twig({
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void; onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
}) { }) {
const language = mixLanguage(base); const language = mixLanguage(base);
@@ -35,14 +36,23 @@ export function twig({
label: v.name, label: v.name,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos), onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? []; })) ?? [];
const functionOptions: TwigCompletionOption[] = const functionOptions: TwigCompletionOption[] =
templateFunctions.map((fn) => ({ templateFunctions.map((fn) => {
name: fn.name, const shortArgs =
type: 'function', fn.args
value: null, .slice(0, 2)
label: fn.name + '(' + fn.args.length + ')', .map((a) => a.name)
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos), .join(', ') + (fn.args.length > 2 ? ', …' : '');
})) ?? []; return {
name: fn.name,
type: 'function',
args: fn.args.map((a) => ({ name: a.name })),
value: null,
label: `${fn.name}(${shortArgs})`,
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
};
}) ?? [];
const options = [...variableOptions, ...functionOptions]; const options = [...variableOptions, ...functionOptions];
@@ -51,7 +61,7 @@ export function twig({
return [ return [
language, language,
base.support, base.support,
templateTags(options), templateTags(options, onClickMissingVariable),
language.data.of({ autocomplete: completions }), language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }), base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }), language.data.of({ autocomplete: genericCompletion(autocomplete) }),

View File

@@ -1,11 +1,8 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view'; import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { truncate } from '../../../../lib/truncate';
import { BetterMatchDecorator } from '../BetterMatchDecorator'; import { BetterMatchDecorator } from '../BetterMatchDecorator';
import type { TwigCompletionOption } from './completion'; import type { TwigCompletionOption } from './completion';
const TAG_TRUNCATE_LEN = 30;
class TemplateTagWidget extends WidgetType { class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void; readonly #clickListenerCallback: () => void;
@@ -32,17 +29,15 @@ class TemplateTagWidget extends WidgetType {
toDOM() { toDOM() {
const elt = document.createElement('span'); const elt = document.createElement('span');
elt.className = `x-theme-templateTag template-tag ${ elt.className = `x-theme-templateTag template-tag ${
this.option.type === 'unknown' this.option.invalid
? 'x-theme-templateTag--danger' ? 'x-theme-templateTag--danger'
: this.option.type === 'variable' : this.option.type === 'variable'
? 'x-theme-templateTag--primary' ? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info' : 'x-theme-templateTag--info'
}`; }`;
elt.title = this.option.type === 'unknown' ? '__NOT_FOUND__' : this.option.value ?? ''; elt.title = this.option.invalid ? 'Not Found' : this.option.value ?? '';
elt.textContent = truncate( elt.setAttribute('data-tag-type', this.option.type);
this.rawTag.replace('${[', '').replace(']}', '').trim(), elt.textContent = this.option.label;
TAG_TRUNCATE_LEN,
);
elt.addEventListener('click', this.#clickListenerCallback); elt.addEventListener('click', this.#clickListenerCallback);
return elt; return elt;
} }
@@ -57,7 +52,10 @@ class TemplateTagWidget extends WidgetType {
} }
} }
export function templateTags(options: TwigCompletionOption[]) { export function templateTags(
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
) {
const templateTagMatcher = new BetterMatchDecorator({ const templateTagMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*(.+)(?!]})\s*]}/g, regexp: /\$\{\[\s*(.+)(?!]})\s*]}/g,
decoration(match, view, matchStartPos) { decoration(match, view, matchStartPos) {
@@ -82,7 +80,14 @@ export function templateTags(options: TwigCompletionOption[]) {
let option = options.find((v) => v.name === name); let option = options.find((v) => v.name === name);
if (option == null) { if (option == null) {
option = { type: 'unknown', name: innerTagMatch, value: null, label: innerTagMatch }; option = {
invalid: true,
type: 'variable',
name: innerTagMatch,
value: null,
label: innerTagMatch,
onClick: () => onClickMissingVariable(name, match[0], matchStartPos),
};
} }
return Decoration.replace({ return Decoration.replace({

View File

@@ -38,6 +38,8 @@ export function Select<T extends string>({
const osInfo = useOsInfo(); const osInfo = useOsInfo();
const [focused, setFocused] = useState<boolean>(false); const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`; const id = `input-${name}`;
const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null;
return ( return (
<div <div
className={classNames( className={classNames(
@@ -67,6 +69,7 @@ export function Select<T extends string>({
'pl-2', 'pl-2',
'border', 'border',
focused ? 'border-border-focus' : 'border-border', focused ? 'border-border-focus' : 'border-border',
isInvalidSelection && 'border-danger',
size === 'xs' && 'h-xs', size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm', size === 'sm' && 'h-sm',
size === 'md' && 'h-md', size === 'md' && 'h-md',
@@ -81,6 +84,7 @@ export function Select<T extends string>({
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')} className={classNames('pr-7 w-full outline-none bg-transparent')}
> >
{isInvalidSelection && <option value={'__NONE__'}>-- Select an Option --</option>}
{options.map((o) => { {options.map((o) => {
if (o.type === 'separator') return null; if (o.type === 'separator') return null;
return ( return (

View File

@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FnArg } from "./FnArg"; import type { FnArg } from "./FnArg";
export type Val = { "type": "str", text: string, } | { "type": "var", name: string, } | { "type": "fn", name: string, args: Array<FnArg>, } | { "type": "null" }; export type Val = { "type": "str", text: string, } | { "type": "var", name: string, } | { "type": "bool", value: boolean, } | { "type": "fn", name: string, args: Array<FnArg>, } | { "type": "null" };