mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-28 12:11:53 +01:00
Official 1Password Template Function (#305)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
@@ -5,10 +6,12 @@ import type {
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import { useTemplateFunctionConfig } from '../hooks/useTemplateFunctionConfig';
|
||||
@@ -17,14 +20,17 @@ import {
|
||||
useTemplateTokensToString,
|
||||
} from '../hooks/useTemplateTokensToString';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { convertTemplateToInsecure } from '../lib/encryption';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
|
||||
import { Button } from './core/Button';
|
||||
import { collectArgumentValues } from './core/Editor/twig/util';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
|
||||
|
||||
interface Props {
|
||||
@@ -115,7 +121,7 @@ function InitializedTemplateFunctionDialog({
|
||||
}, [argValues, name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model).data;
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
||||
|
||||
const handleDone = () => {
|
||||
if (tagText.data) {
|
||||
@@ -136,7 +142,7 @@ function InitializedTemplateFunctionDialog({
|
||||
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
|
||||
const dataContainsSecrets = useMemo(() => {
|
||||
for (const [name, value] of Object.entries(argValues)) {
|
||||
const arg = templateFunction?.args.find((a) => 'name' in a && a.name === name);
|
||||
const arg = templateFunction.data?.args.find((a) => 'name' in a && a.name === name);
|
||||
const isTextPassword = arg?.type === 'text' && arg.password;
|
||||
if (isTextPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
|
||||
return true;
|
||||
@@ -147,7 +153,13 @@ function InitializedTemplateFunctionDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rendered.data]);
|
||||
|
||||
if (templateFunction == null) return null;
|
||||
if (templateFunction.data == null || templateFunction.isPending) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -172,16 +184,16 @@ function InitializedTemplateFunctionDialog({
|
||||
<DynamicForm
|
||||
autocompleteVariables
|
||||
autocompleteFunctions
|
||||
inputs={templateFunction.args}
|
||||
inputs={templateFunction.data.args}
|
||||
data={argValues}
|
||||
onChange={setArgValues}
|
||||
stateKey={`template_function.${templateFunction.name}`}
|
||||
stateKey={`template_function.${templateFunction.data.name}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4">
|
||||
{previewType !== 'none' ? (
|
||||
<VStack className="w-full">
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
@@ -202,7 +214,7 @@ function InitializedTemplateFunctionDialog({
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'relative',
|
||||
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars !border-text-subtlest',
|
||||
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest',
|
||||
tooLarge && 'italic text-danger',
|
||||
)}
|
||||
>
|
||||
@@ -219,25 +231,25 @@ function InitializedTemplateFunctionDialog({
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 flex items-center">
|
||||
<div className="absolute right-0 top-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isLoading}
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InlineCode>
|
||||
</VStack>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
|
||||
{templateFunction.name === 'secure' && (
|
||||
{templateFunction.data.name === 'secure' && (
|
||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Reveal Encryption Key
|
||||
</Button>
|
||||
@@ -251,37 +263,35 @@ function InitializedTemplateFunctionDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the initial tokens from the template and merge those with the default values pulled from
|
||||
* the template function definition.
|
||||
*/
|
||||
function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
|
||||
const initial: Record<string, string | boolean> = {};
|
||||
const initialArgs =
|
||||
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
|
||||
? initialTokens.tokens[0]?.val.args
|
||||
: [];
|
||||
|
||||
const processArg = (arg: FormInput) => {
|
||||
if ('inputs' in arg && arg.inputs) {
|
||||
arg.inputs.forEach(processArg);
|
||||
}
|
||||
if (!('name' in arg)) return;
|
||||
|
||||
const initialArg = initialArgs.find((a) => a.name === arg.name);
|
||||
const initialArgValue =
|
||||
initialArg?.value.type === 'str'
|
||||
? initialArg?.value.text
|
||||
: initialArg?.value.type === 'bool'
|
||||
? initialArg.value.value
|
||||
: undefined;
|
||||
const value = initialArgValue ?? arg.defaultValue;
|
||||
if (value != null) {
|
||||
initial[arg.name] = value;
|
||||
}
|
||||
};
|
||||
|
||||
templateFunction.args.forEach(processArg);
|
||||
|
||||
return initial;
|
||||
}
|
||||
TemplateFunctionDialog.show = function (
|
||||
fn: TemplateFunction,
|
||||
tagValue: string,
|
||||
startPos: number,
|
||||
view: EditorView,
|
||||
) {
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
showDialog({
|
||||
id: 'template-function-' + Math.random(), // Allow multiple at once
|
||||
size: 'md',
|
||||
className: 'h-[60rem]',
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => {
|
||||
const model = jotaiStore.get(activeWorkspaceAtom)!;
|
||||
return (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
model={model}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import type { EditorKeymap } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import classNames from 'classnames';
|
||||
import type { GraphQLSchema } from 'graphql';
|
||||
import { useAtomValue } from 'jotai';
|
||||
@@ -27,20 +26,17 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
|
||||
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useRandomKey } from '../../../hooks/useRandomKey';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { showDialog } from '../../../lib/dialog';
|
||||
import { editEnvironment } from '../../../lib/editEnvironment';
|
||||
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
|
||||
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { InlineCode } from '../InlineCode';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
import {
|
||||
@@ -285,32 +281,10 @@ export function Editor({
|
||||
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
const show = () =>
|
||||
showDialog({
|
||||
id: 'template-function-' + Math.random(), // Allow multiple at once
|
||||
size: 'md',
|
||||
className: 'h-[90vh] max-h-[60rem]',
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => {
|
||||
const model = jotaiStore.get(activeWorkspaceAtom)!;
|
||||
return (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
model={model}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
cm.current?.view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
const show = () => {
|
||||
if (cm.current === null) return;
|
||||
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
|
||||
};
|
||||
|
||||
if (fn.name === 'secure') {
|
||||
withEncryptionEnabled(show);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import { defaultBoost } from './twig/completion';
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
@@ -23,7 +24,12 @@ export function genericCompletion(config?: GenericCompletionConfig) {
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
|
||||
const optionsWithoutExactMatches = options
|
||||
.filter((o) => o.label !== toMatch.text)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
boost: defaultBoost(o),
|
||||
}));
|
||||
return {
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import type { TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
@@ -11,9 +13,7 @@ export type TwigCompletionOptionNamespace = {
|
||||
type: 'namespace';
|
||||
};
|
||||
|
||||
export type TwigCompletionOptionFunction = {
|
||||
args: { name: string }[];
|
||||
aliases?: string[];
|
||||
export type TwigCompletionOptionFunction = TemplateFunction & {
|
||||
type: 'function';
|
||||
};
|
||||
|
||||
@@ -34,32 +34,24 @@ export interface TwigCompletionConfig {
|
||||
options: TwigCompletionOption[];
|
||||
}
|
||||
|
||||
const MIN_MATCH_VAR = 1;
|
||||
const MIN_MATCH_NAME = 1;
|
||||
const MIN_MATCH_NAME = 2;
|
||||
|
||||
export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/[\w_.]*/);
|
||||
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/);
|
||||
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
|
||||
const toMatch = toStartOfName ?? null;
|
||||
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
|
||||
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
|
||||
if (failedVarLen && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
|
||||
if (failedNameLen && !context.explicit) {
|
||||
if (toMatch.from >0 && matchLen < MIN_MATCH_NAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completions: Completion[] = options
|
||||
.flatMap((o): Completion[] => {
|
||||
const matchSegments = toStartOfName!.text.split('.');
|
||||
const matchSegments = toMatch!.text.replace(/^\$/, '').split('.');
|
||||
const optionSegments = o.name.split('.');
|
||||
|
||||
// If not on the last segment, only complete the namespace
|
||||
@@ -68,8 +60,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return [
|
||||
{
|
||||
label: prefix + '.*',
|
||||
apply: prefix,
|
||||
type: 'namespace',
|
||||
detail: 'namespace',
|
||||
apply: (view, _completion, from, to) => {
|
||||
const insert = `${prefix}.`;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
// Leave the autocomplete open so the user can continue typing the rest of the namespace
|
||||
startCompletion(view);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -79,24 +80,49 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return [
|
||||
{
|
||||
label: o.name,
|
||||
apply: openTag + inner + closeTag,
|
||||
info: o.description,
|
||||
detail: o.type,
|
||||
type: o.type === 'variable' ? 'variable' : 'function',
|
||||
apply: (view, _completion, from, to) => {
|
||||
const insert = openTag + inner + closeTag;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((v) => v != null);
|
||||
|
||||
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
|
||||
// open it, then it closes when you type the next character.
|
||||
const uniqueCompletions = uniqueBy(completions, 'label');
|
||||
const sortedCompletions = uniqueCompletions.sort((a, b) => {
|
||||
const boostDiff = defaultBoost(b) - defaultBoost(a);
|
||||
if (boostDiff !== 0) return boostDiff;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return {
|
||||
matchLen,
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
matchLen,
|
||||
options: completions
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
options: sortedCompletions,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
|
||||
const map = new Map<T[K], T>();
|
||||
for (const item of arr) {
|
||||
map.set(item[key], item); // overwrites → keeps last
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function defaultBoost(o: Completion) {
|
||||
if (o.type === 'variable') return 4;
|
||||
if (o.type === 'constant') return 3;
|
||||
if (o.type === 'function') return 2;
|
||||
if (o.type === 'namespace') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { Range } from '@codemirror/state';
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view';
|
||||
import type { SyntaxNodeRef } from '@lezer/common';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
import { collectArgumentValues } from './util';
|
||||
|
||||
class TemplateTagWidget extends WidgetType {
|
||||
readonly #clickListenerCallback: () => void;
|
||||
@@ -40,10 +42,7 @@ class TemplateTagWidget extends WidgetType {
|
||||
}`;
|
||||
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
|
||||
elt.setAttribute('data-tag-type', this.option.type);
|
||||
elt.textContent =
|
||||
this.option.type === 'function'
|
||||
? `${this.option.name}(${this.option.args.length ? '…' : ''})`
|
||||
: this.option.name;
|
||||
elt.textContent = this.option.label;
|
||||
elt.addEventListener('click', this.#clickListenerCallback);
|
||||
return elt;
|
||||
}
|
||||
@@ -107,7 +106,20 @@ function templateTags(
|
||||
};
|
||||
}
|
||||
|
||||
const widget = new TemplateTagWidget(option, rawTag, node.from);
|
||||
let invalid = false;
|
||||
if (option.type === 'function') {
|
||||
const tokens = parseTemplate(rawTag);
|
||||
const values = collectArgumentValues(tokens, option);
|
||||
for (const arg of option.args) {
|
||||
if (!('optional' in arg)) continue;
|
||||
if (!arg.optional && values[arg.name] == null) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const widget = new TemplateTagWidget({ ...option, invalid }, rawTag, node.from);
|
||||
const deco = Decoration.replace({ widget, inclusive: true });
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
}
|
||||
|
||||
37
src-web/components/core/Editor/twig/util.ts
Normal file
37
src-web/components/core/Editor/twig/util.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { Tokens } from '@yaakapp-internal/templates';
|
||||
|
||||
/**
|
||||
* Process the initial tokens from the template and merge those with the default values pulled from
|
||||
* the template function definition.
|
||||
*/
|
||||
export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
|
||||
const initial: Record<string, string | boolean> = {};
|
||||
const initialArgs =
|
||||
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
|
||||
? initialTokens.tokens[0]?.val.args
|
||||
: [];
|
||||
|
||||
const processArg = (arg: FormInput) => {
|
||||
if ('inputs' in arg && arg.inputs) {
|
||||
arg.inputs.forEach(processArg);
|
||||
}
|
||||
if (!('name' in arg)) return;
|
||||
|
||||
const initialArg = initialArgs.find((a) => a.name === arg.name);
|
||||
const initialArgValue =
|
||||
initialArg?.value.type === 'str'
|
||||
? initialArg?.value.text
|
||||
: initialArg?.value.type === 'bool'
|
||||
? initialArg.value.value
|
||||
: undefined;
|
||||
const value = initialArgValue ?? arg.defaultValue;
|
||||
if (value != null) {
|
||||
initial[arg.name] = value;
|
||||
}
|
||||
};
|
||||
|
||||
templateFunction.args.forEach(processArg);
|
||||
|
||||
return initial;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface SelectProps<T extends string> {
|
||||
size?: ButtonProps['size'];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
@@ -40,6 +41,7 @@ export function Select<T extends string>({
|
||||
onChange,
|
||||
className,
|
||||
defaultValue,
|
||||
filterable,
|
||||
size = 'md',
|
||||
}: SelectProps<T>) {
|
||||
const [focused, setFocused] = useState<boolean>(false);
|
||||
@@ -64,7 +66,7 @@ export function Select<T extends string>({
|
||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
||||
{label}
|
||||
</Label>
|
||||
{type() === 'macos' ? (
|
||||
{type() === 'macos' && !filterable ? (
|
||||
<HStack
|
||||
space={2}
|
||||
className={classNames(
|
||||
|
||||
Reference in New Issue
Block a user