#824 - Field actions

This commit is contained in:
Elio Struyf
2024-06-28 09:23:26 +02:00
parent 0e6e776f70
commit b03d972d31
15 changed files with 347 additions and 238 deletions
+14 -6
View File
@@ -67,11 +67,11 @@ export class CustomScript {
* @param path
* @returns
*/
private static async singleRun(
public static async singleRun(
wsPath: string,
script: ICustomScript,
path: string | null = null
): Promise<void> {
): Promise<any> {
let articlePath: string | null = path;
let article: ParsedFrontMatter | null | undefined = null;
@@ -99,7 +99,7 @@ export class CustomScript {
articlePath as string,
script
);
await CustomScript.showOutput(output, script, articlePath);
return await CustomScript.showOutput(output, script, articlePath);
}
);
} else {
@@ -133,7 +133,7 @@ export class CustomScript {
title: l10n.t(LocalizationKey.helpersCustomScriptExecuting, script.title),
cancellable: false
},
async (progress, token) => {
async (_, __) => {
for await (const folder of folders) {
if (folder.lastModified.length > 0) {
for await (const file of folder.lastModified) {
@@ -266,15 +266,21 @@ export class CustomScript {
output: string | null,
script: ICustomScript,
articlePath?: string | null
): Promise<void> {
): Promise<any> {
if (output) {
try {
const data: {
frontmatter?: { [key: string]: any };
fmAction?: 'open' | 'copyMediaMetadata' | 'copyMediaMetadataAndDelete' | 'deleteMedia';
fmAction?:
| 'open'
| 'copyMediaMetadata'
| 'copyMediaMetadataAndDelete'
| 'deleteMedia'
| 'fieldAction';
fmPath?: string;
fmSourcePath?: string;
fmDestinationPath?: string;
fmFieldValue?: any;
} = JSON.parse(output);
if (data.frontmatter) {
@@ -326,6 +332,8 @@ export class CustomScript {
await MediaHelpers.deleteFile(data.fmSourcePath);
} else if (data.fmAction === 'deleteMedia' && data.fmPath) {
await MediaHelpers.deleteFile(data.fmPath);
} else if (data.fmAction === 'fieldAction') {
return data.fmFieldValue || undefined;
}
} else {
Logger.error(`No frontmatter found.`);
+28 -1
View File
@@ -1,5 +1,6 @@
import { Folders } from '../../commands';
import { SETTING_CUSTOM_SCRIPTS } from '../../constants';
import { CustomScript, Settings } from '../../helpers';
import { CustomScript, Notifications, Settings } from '../../helpers';
import { CustomScript as ICustomScript, PostMessageData } from '../../models';
import { CommandToCode } from '../../panelWebView/CommandToCode';
import { BaseListener } from './BaseListener';
@@ -16,6 +17,32 @@ export class ScriptListener extends BaseListener {
case CommandToCode.runCustomScript:
this.runCustomScript(msg);
break;
case CommandToCode.runFieldAction:
this.runFieldAction(msg);
break;
}
}
private static async runFieldAction({ command, payload, requestId }: PostMessageData) {
if (!payload || !requestId || !command) {
return;
}
const script = payload as ICustomScript;
if (script.script) {
const wsFolder = Folders.getWorkspaceFolder();
if (!wsFolder) {
return;
}
const fieldValue = await CustomScript.singleRun(wsFolder.fsPath, script);
if (fieldValue) {
this.sendRequest(command, requestId, fieldValue);
} else {
Notifications.error('The script did not return a field value');
this.sendRequestError(command, requestId, 'The script did not return a field value');
}
}
}
+1
View File
@@ -1 +1,2 @@
export * from './localization.enum';
export * from './localize';
+8
View File
@@ -1416,6 +1416,14 @@ export enum LocalizationKey {
* Unkown field type: {0}
*/
panelFieldsWrapperFieldUnknown = 'panel.fields.wrapperField.unknown',
/**
* Custom action
*/
panelFieldsFieldCustomActionButtonTitle = 'panel.fields.fieldCustomAction.button.title',
/**
* Executing field action...
*/
panelFieldsFieldCustomActionExecuting = 'panel.fields.fieldCustomAction.executing',
/**
* Actions
*/
+5
View File
@@ -0,0 +1,5 @@
import * as l10n from '@vscode/l10n';
export const localize = (key: string, ...args: any[]): string => {
return l10n.t(key, ...args);
};
+3
View File
@@ -141,6 +141,9 @@ export interface Field {
// When clause
when?: WhenClause;
// Custom action
action?: CustomScript;
}
export interface NumberOptions {
+2 -1
View File
@@ -46,5 +46,6 @@ export enum CommandToCode {
copilotSuggestTaxonomy = 'copilot-suggest-taxonomy',
searchByType = 'search-by-type',
processMediaData = 'process-media-data',
isServerStarted = 'is-server-started'
isServerStarted = 'is-server-started',
runFieldAction = 'run-field-action'
}
@@ -0,0 +1,48 @@
import * as React from 'react';
import { CustomScript } from '../../../models';
import { messageHandler } from '@estruyf/vscode/dist/client';
import { CodeBracketIcon } from '@heroicons/react/24/outline';
import { CommandToCode } from '../../CommandToCode';
import { LocalizationKey, localize } from '../../../localization';
export interface IFieldCustomActionProps {
action: CustomScript;
disabled?: boolean;
triggerLoading?: (message?: string) => void;
onChange: (value: any) => void;
}
export const FieldCustomAction: React.FunctionComponent<IFieldCustomActionProps> = ({ action, disabled, triggerLoading, onChange }: React.PropsWithChildren<IFieldCustomActionProps>) => {
return (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={action?.title || localize(LocalizationKey.panelFieldsFieldCustomActionButtonTitle)}
type="button"
onClick={() => {
if (triggerLoading) {
triggerLoading(localize(LocalizationKey.panelFieldsFieldCustomActionExecuting));
}
messageHandler.request(CommandToCode.runFieldAction, {
...action
}).then((value: any) => {
onChange(value);
if (triggerLoading) {
triggerLoading();
}
}).catch(() => {
console.error('Error while running the custom action');
if (triggerLoading) {
triggerLoading();
}
});
}}
disabled={disabled}
>
<span className='sr-only'>{action?.title || localize(LocalizationKey.panelFieldsFieldCustomActionButtonTitle)}</span>
<CodeBracketIcon style={{ height: "16px", width: "16px" }} aria-hidden="true" />
</button>
);
};
@@ -1,6 +1,8 @@
import * as React from 'react';
import { useMemo } from 'react';
import { RequiredAsterix } from './RequiredAsterix';
import { CustomScript } from '../../../models';
import { FieldCustomAction } from './FieldCustomAction';
export interface IFieldTitleProps {
label: string | JSX.Element;
@@ -8,6 +10,10 @@ export interface IFieldTitleProps {
className?: string;
required?: boolean;
actionElement?: JSX.Element;
customAction?: CustomScript;
isDisabled?: boolean;
triggerLoading?: (message?: string) => void;
onChange?: (value: any) => void;
}
export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
@@ -16,6 +22,10 @@ export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
className,
required,
actionElement,
customAction,
isDisabled,
triggerLoading,
onChange,
}: React.PropsWithChildren<IFieldTitleProps>) => {
const Icon = useMemo(() => {
return icon ? React.cloneElement(icon, { style: { width: '16px', height: '16px' } }) : null;
@@ -29,7 +39,19 @@ export const FieldTitle: React.FunctionComponent<IFieldTitleProps> = ({
<RequiredAsterix required={required} />
</label>
{actionElement}
<div className="flex gap-4">
{
customAction && onChange && (
<FieldCustomAction
action={customAction}
disabled={isDisabled}
triggerLoading={triggerLoading}
onChange={onChange} />
)
}
{actionElement}
</div>
</div>
);
};
@@ -5,7 +5,7 @@ import { CommandToCode } from '../../CommandToCode';
import { TagType } from '../../TagType';
import Downshift from 'downshift';
import { AddIcon } from '../Icons/AddIcon';
import { BlockFieldData, CustomTaxonomyData } from '../../../models';
import { BlockFieldData, CustomScript, CustomTaxonomyData } from '../../../models';
import { useCallback, useEffect, useMemo } from 'react';
import { messageHandler, Messenger } from '@estruyf/vscode/dist/client';
import { FieldMessage } from '../Fields/FieldMessage';
@@ -13,8 +13,7 @@ import { FieldTitle } from '../Fields/FieldTitle';
import { useRecoilValue } from 'recoil';
import { PanelSettingsAtom } from '../../state';
import { SparklesIcon } from '@heroicons/react/24/outline';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { LocalizationKey, localize } from '../../../localization';
import useDropdownStyle from '../../hooks/useDropdownStyle';
import { CopilotIcon } from '../Icons';
@@ -37,6 +36,7 @@ export interface ITagPickerProps {
limit?: number;
required?: boolean;
renderAsString?: boolean;
action?: CustomScript;
}
const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
@@ -56,7 +56,8 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
blockData,
limit,
required,
renderAsString
renderAsString,
action
}: React.PropsWithChildren<ITagPickerProps>) => {
const [selected, setSelected] = React.useState<string[]>([]);
const [inputValue, setInputValue] = React.useState<string>('');
@@ -65,7 +66,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
const { getDropdownStyle } = useDropdownStyle(inputRef as any);
const dsRef = React.useRef<Downshift<string> | null>(null);
const settings = useRecoilValue(PanelSettingsAtom);
const [loading, setLoading] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<string | undefined>(undefined);
/**
* Removes an option
@@ -249,25 +250,29 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
[options, inputRef, selected, freeform]
);
const updateTaxonomy = (values: string[]) => {
if (values && values instanceof Array && values.length > 0) {
const uniqValues = Array.from(new Set([...selected, ...values]));
setSelected(uniqValues);
sendUpdate(uniqValues);
setInputValue('');
}
}
const suggestTaxonomy = useCallback(
(aiType: 'ai' | 'copilot', type: TagType) => {
setLoading(true);
setLoading(localize(LocalizationKey.panelTagPickerAiGenerating));
const command =
aiType === 'ai' ? CommandToCode.aiSuggestTaxonomy : CommandToCode.copilotSuggestTaxonomy;
messageHandler
.request<string[]>(command, type)
.then((values) => {
setLoading(false);
if (values && values instanceof Array && values.length > 0) {
const uniqValues = Array.from(new Set([...selected, ...values]));
setSelected(uniqValues);
sendUpdate(uniqValues);
setInputValue('');
}
setLoading(undefined);
updateTaxonomy(values)
})
.catch(() => {
setLoading(false);
setLoading(undefined);
});
},
[selected]
@@ -286,13 +291,13 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
const inputPlaceholder = useMemo((): string => {
if (checkIsDisabled()) {
return l10n.t(
return localize(
LocalizationKey.panelTagPickerInputPlaceholderDisabled,
`${limit} ${label || type.toLowerCase()}`
);
}
return l10n.t(LocalizationKey.panelTagPickerInputPlaceholderEmpty, label || type.toLowerCase());
return localize(LocalizationKey.panelTagPickerInputPlaceholderEmpty, label || type.toLowerCase());
}, [label, type, checkIsDisabled]);
const showRequiredState = useMemo(() => {
@@ -305,17 +310,17 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
}
return (
<div className="flex gap-4">
<>
{settings?.aiEnabled && (
<button
className="metadata_field__title__action"
title={l10n.t(
title={localize(
LocalizationKey.panelTagPickerAiSuggest,
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy('ai', type)}
disabled={loading}
disabled={!!loading}
>
<SparklesIcon />
</button>
@@ -324,18 +329,18 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action"
title={l10n.t(
title={localize(
LocalizationKey.panelTagPickerCopilotSuggest,
label?.toLowerCase() || type.toLowerCase()
)}
type="button"
onClick={() => suggestTaxonomy('copilot', type)}
disabled={loading}
disabled={!!loading}
>
<CopilotIcon />
</button>
)}
</div>
</>
);
}, [settings?.aiEnabled, settings?.copilotEnabled, label, type]);
@@ -376,7 +381,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
<>
{` `}
<span style={{ fontWeight: 'lighter' }}>
({l10n.t(LocalizationKey.panelTagPickerLimit, limit)})
({localize(LocalizationKey.panelTagPickerLimit, limit)})
</span>
</>
) : (
@@ -387,12 +392,16 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
actionElement={actionElement}
icon={icon}
required={required}
isDisabled={!!loading}
customAction={action}
triggerLoading={(message) => setLoading(message)}
onChange={updateTaxonomy}
/>
<div className="relative">
{loading && (
<div className="metadata_field__loading">
{l10n.t(LocalizationKey.panelTagPickerAiGenerating)}
{loading}
</div>
)}
@@ -418,9 +427,8 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
<>
<div
{...getRootProps(undefined, { suppressRefError: true })}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${
showRequiredState ? 'required' : ''
}`}
className={`article__tags__input ${freeform ? 'freeform' : ''} ${showRequiredState ? 'required' : ''
}`}
>
<input
{...getInputProps({
@@ -443,7 +451,7 @@ const TagPicker: React.FunctionComponent<ITagPickerProps> = ({
{freeform && (
<button
className={`article__tags__input__button`}
title={l10n.t(LocalizationKey.panelTagPickerUnkown)}
title={localize(LocalizationKey.panelTagPickerUnkown)}
disabled={!inputValue || checkIsDisabled()}
onClick={() => insertUnkownTag(closeMenu)}
>
@@ -2,14 +2,13 @@ import { PencilIcon, SparklesIcon } from '@heroicons/react/24/outline';
import * as React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { BaseFieldProps, PanelSettings } from '../../../models';
import { BaseFieldProps, CustomScript, PanelSettings } from '../../../models';
import { RequiredFieldsAtom } from '../../state';
import { FieldTitle } from './FieldTitle';
import { FieldMessage } from './FieldMessage';
import { messageHandler } from '@estruyf/vscode/dist/client';
import { CommandToCode } from '../../CommandToCode';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { LocalizationKey, localize } from '../../../localization';
import { useDebounce } from '../../../hooks/useDebounce';
import { CopilotIcon } from '../Icons';
@@ -23,6 +22,7 @@ export interface ITextFieldProps extends BaseFieldProps<string> {
name: string;
placeholder?: string;
settings: PanelSettings;
action?: CustomScript;
onChange: (txtValue: string) => void;
}
@@ -40,11 +40,12 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
name,
settings,
onChange,
action,
required
}: React.PropsWithChildren<ITextFieldProps>) => {
const [, setRequiredFields] = useRecoilState(RequiredFieldsAtom);
const [text, setText] = React.useState<string | null | undefined>(undefined);
const [loading, setLoading] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<string | undefined>(undefined);
const [lastUpdated, setLastUpdated] = React.useState<number | null>(null);
const debouncedText = useDebounce<string | null | undefined>(text, DEBOUNCE_TIME);
@@ -93,13 +94,14 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
}, [showRequiredState, isValid]);
const suggestDescription = (type: 'ai' | 'copilot') => {
setLoading(true);
setLoading(localize(LocalizationKey.panelFieldsTextFieldAiGenerate));
messageHandler
.request<string>(
type === 'copilot' ? CommandToCode.copilotSuggestDescription : CommandToCode.aiSuggestDescription
)
.then((suggestion) => {
setLoading(false);
setLoading(undefined);
if (suggestion) {
setText(suggestion);
@@ -107,7 +109,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
}
})
.catch(() => {
setLoading(false);
setLoading(undefined);
});
};
@@ -117,14 +119,14 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
}
return (
<div className="flex gap-4">
<>
{settings?.aiEnabled && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={l10n.t(LocalizationKey.panelFieldsTextFieldAiMessage, label?.toLowerCase())}
title={localize(LocalizationKey.panelFieldsTextFieldAiMessage, label?.toLowerCase())}
type="button"
onClick={() => suggestDescription('ai')}
disabled={loading}
disabled={!!loading}
>
<SparklesIcon />
</button>
@@ -133,17 +135,17 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
{settings?.copilotEnabled && (
<button
className="metadata_field__title__action inline-block text-[var(--vscode-editor-foreground)] disabled:opacity-50"
title={l10n.t(LocalizationKey.panelFieldsTextFieldCopilotMessage, label?.toLowerCase())}
title={localize(LocalizationKey.panelFieldsTextFieldCopilotMessage, label?.toLowerCase())}
type="button"
onClick={() => suggestDescription('copilot')}
disabled={loading}
disabled={!!loading}
>
<CopilotIcon />
</button>
)}
</div>
</>
);
}, [settings?.aiEnabled, name]);
}, [settings?.aiEnabled, settings?.copilotEnabled, name, action, loading]);
useEffect(() => {
if (text !== value && (lastUpdated === null || Date.now() - DEBOUNCE_TIME > lastUpdated)) {
@@ -165,18 +167,22 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
actionElement={actionElement}
icon={<PencilIcon />}
required={required}
isDisabled={!!loading}
customAction={action}
triggerLoading={(message) => setLoading(message)}
onChange={onTextChange}
/>
<div className='relative'>
{loading && (
<div className="metadata_field__loading">
{l10n.t(LocalizationKey.panelFieldsTextFieldAiGenerate)}
{loading}
</div>
)}
{wysiwyg ? (
<React.Suspense
fallback={<div>{l10n.t(LocalizationKey.panelFieldsTextFieldLoading)}</div>}
fallback={<div>{localize(LocalizationKey.panelFieldsTextFieldLoading)}</div>}
>
<WysiwygField text={text || ''} onChange={onTextChange} />
</React.Suspense>
@@ -206,7 +212,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
{limit && limit > 0 && (text || '').length > limit && (
<div className={`metadata_field__limit`}>
{l10n.t(LocalizationKey.panelFieldsTextFieldLimit, `${(text || '').length}/${limit}`)}
{localize(LocalizationKey.panelFieldsTextFieldLimit, `${(text || '').length}/${limit}`)}
</div>
)}
@@ -216,6 +216,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
value={(fieldValue as string) || null}
required={!!field.required}
settings={settings}
action={field.action}
/>
</FieldBoundary>
);
@@ -307,6 +308,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
limit={field.taxonomyLimit}
renderAsString={field.singleValueAsString}
required={!!field.required}
action={field.action}
/>
</FieldBoundary>
);