Skip to content

Commit

Permalink
#7767 AA Run API Task javascript brick (#7996)
Browse files Browse the repository at this point in the history
* implement api task api calls and brick logic

* Implement api task brick options UI

* add new file

* use minimalSchemaFactory and fix capitalization of term

* update comments

* pr nit

* fix null checks

---------

Co-authored-by: Ben Loe <[email protected]>
  • Loading branch information
BLoe and Ben Loe authored Mar 21, 2024
1 parent 3bf4476 commit 8de521b
Show file tree
Hide file tree
Showing 16 changed files with 655 additions and 32 deletions.
7 changes: 6 additions & 1 deletion src/components/form/widgets/SelectWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@ const SelectWidget = <TOption extends Option<TOption["value"]>>({
onInputChange={setTextInputValue}
value={selectedOption}
onChange={patchedOnChange}
components={components}
// This cast is to make strict null checks happy - react-select has funky typing issues for custom components
components={
components as Partial<
SelectComponentsConfig<unknown, boolean, GroupBase<unknown>>
>
}
styles={styles}
isSearchable={isSearchable}
/>
Expand Down
221 changes: 221 additions & 0 deletions src/contrib/automationanywhere/ApiTaskOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useMemo } from "react";
import { type BlockOptionProps } from "@/components/fields/schemaFields/genericOptionsFactory";
import { isEmpty, partial } from "lodash";
import { joinName } from "@/utils/formUtils";
import RequireIntegrationConfig from "@/integrations/components/RequireIntegrationConfig";
import { RUN_API_TASK_INPUT_SCHEMA } from "@/contrib/automationanywhere/RunApiTask";
import { type SanitizedIntegrationConfig } from "@/integrations/integrationTypes";
import { type Schema } from "@/types/schemaTypes";
import { useField } from "formik";
import { useAsyncEffect } from "use-async-effect";
import {
cachedFetchBotFile,
cachedFetchFolder,
cachedFetchSchema,
cachedSearchApiTasks,
} from "@/contrib/automationanywhere/aaApi";
import useAsyncState from "@/hooks/useAsyncState";
import { type WorkspaceType } from "@/contrib/automationanywhere/contract";
import SelectWidget from "@/components/form/widgets/SelectWidget";
import { WORKSPACE_OPTIONS } from "@/contrib/automationanywhere/util";
import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate";
import { Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import AsyncRemoteSelectWidget from "@/components/form/widgets/AsyncRemoteSelectWidget";
import BooleanWidget from "@/components/fields/schemaFields/widgets/BooleanWidget";
import SchemaField from "@/components/fields/schemaFields/SchemaField";
import ChildObjectField from "@/components/fields/schemaFields/ChildObjectField";

const TasksLoadingMessage: React.FC = () => <span>Searching tasks...</span>;
const TasksNoOptionsMessage: React.FC = () => <span>No tasks found</span>;

const ApiTaskOptionsContent: React.FC<{
configName: (...keys: string[]) => string;
sanitizedConfig: SanitizedIntegrationConfig;
}> = ({ configName, sanitizedConfig: controlRoomConfig }) => {
const [{ value: workspaceTypeFieldValue }, , { setValue: setWorkspaceType }] =
useField<WorkspaceType | null>(configName("workspaceType"));
const [{ value: botId }] = useField<string>(configName("botId"));
const [{ value: awaitResult }] = useField<boolean>(configName("awaitResult"));

// If workspaceType is not set, but there is a task selected, look up the file ID and set the workspaceType from the file info
useAsyncEffect(
async (isMounted) => {
if (workspaceTypeFieldValue || !botId) {
return;
}

const { workspaceType: workspaceTypeFromBotFile } =
await cachedFetchBotFile(controlRoomConfig, botId);
const workspaceTypeNewValue: WorkspaceType =
workspaceTypeFromBotFile === "PUBLIC" ? "public" : "private";
if (isMounted()) {
await setWorkspaceType(workspaceTypeNewValue);
}
},
[controlRoomConfig, botId, workspaceTypeFieldValue],
);

const remoteSchemaState = useAsyncState(async () => {
if (!botId) {
return null;
}

return cachedFetchSchema(controlRoomConfig, botId);
}, [controlRoomConfig, botId]);

// Don't care about pending/error state b/c we just fall back to displaying the folderId
const { data: folder } = useAsyncState(async () => {
const folderId = controlRoomConfig.config?.folderId;
if (folderId) {
return cachedFetchFolder(controlRoomConfig, folderId);
}

return null;
}, [controlRoomConfig]);

// Additional args passed to the remote options factories
const factoryArgs = useMemo(
() => ({
// Default to "private" because that's compatible with both CE and EE
// The workspaceType can be temporarily null when switching between CR configurations
workspaceType: workspaceTypeFieldValue ?? "private",
}),
[workspaceTypeFieldValue],
);

return (
<>
<ConnectedFieldTemplate
label="Workspace"
name={configName("workspaceType")}
description="The Control Room Workspace"
as={SelectWidget}
defaultValue="private"
options={WORKSPACE_OPTIONS}
/>

{!isEmpty(controlRoomConfig.config.folderId) && (
<Alert variant="info">
<FontAwesomeIcon icon={faInfoCircle} /> Displaying available bots from
folder{" "}
{folder?.name
? `'${folder.name}' (${controlRoomConfig.config.folderId})`
: controlRoomConfig.config.folderId}{" "}
configured on the integration. To choose from all bots in the
workspace, remove the folder from the integration configuration.
</Alert>
)}

{
// Use AsyncRemoteSelectWidget instead of RemoteSelectWidget because the former can handle
// Control Rooms with lots of bots
// https://github.com/pixiebrix/pixiebrix-extension/issues/5260
<ConnectedFieldTemplate
label="API Task"
name={configName("botId")}
description="The Automation Anywhere API Task to run. Type a query to search available tasks by name"
as={AsyncRemoteSelectWidget}
defaultOptions
// Ensure we get current results, because there's not a refresh button
cacheOptions={false}
optionsFactory={cachedSearchApiTasks}
loadingMessage={TasksLoadingMessage}
noOptonsMessage={TasksNoOptionsMessage}
factoryArgs={factoryArgs}
config={controlRoomConfig}
/>
}

<SchemaField
name={configName("sharedRunAsUserId")}
schema={
RUN_API_TASK_INPUT_SCHEMA.properties.sharedRunAsUserId as Schema
}
isRequired
/>

{/*
TODO: Implement user lookup for API Task users (https://github.com/pixiebrix/pixiebrix-extension/issues/7782)
- This may end up being automatically set to the apitaskrunner
for the current control room, so the user input here may go away
<ConnectedFieldTemplate
label="Run as User"
name={configName("sharedRunAsUserId")}
description="The user to run the api task"
as={RemoteSelectWidget}
optionsFactory={cachedFetchRunAsUsers} // Need a different user lookup here
factoryArgs={factoryArgs}
blankValue={"Select a user"}
config={controlRoomConfig}
/>
*/}

<ConnectedFieldTemplate
label="Await Result?"
name={configName("awaitResult")}
description="Wait for the task to run and return the output"
as={BooleanWidget}
/>
{awaitResult && (
<SchemaField
label="Result Timeout (Milliseconds)"
name={configName("maxWaitMillis")}
schema={RUN_API_TASK_INPUT_SCHEMA.properties.maxWaitMillis as Schema}
// Mark as required so the widget defaults to showing the number entry
isRequired
/>
)}

{botId && (
<ChildObjectField
heading="Input Arguments"
name={configName("data")}
schema={remoteSchemaState.data}
schemaError={remoteSchemaState.error}
schemaLoading={remoteSchemaState.isLoading}
isRequired
/>
)}
</>
);
};

const ApiTaskOptions: React.FC<BlockOptionProps> = ({ name, configKey }) => {
const configName = partial(joinName, name, configKey);
return (
<RequireIntegrationConfig
integrationsSchema={
RUN_API_TASK_INPUT_SCHEMA.properties.integrationConfig as Schema
}
integrationsFieldName={configName("integrationConfig")}
>
{({ sanitizedConfig }) => (
<ApiTaskOptionsContent
configName={configName}
sanitizedConfig={sanitizedConfig}
/>
)}
</RequireIntegrationConfig>
);
};

export default ApiTaskOptions;
4 changes: 2 additions & 2 deletions src/contrib/automationanywhere/BotOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { type ModComponentFormState } from "@/pageEditor/starterBricks/formState
import { render, screen } from "@/extensionConsole/testHelpers";
// eslint-disable-next-line no-restricted-imports -- TODO: Fix over time
import { Formik } from "formik";
import { AUTOMATION_ANYWHERE_RUN_BOT_ID } from "@/contrib/automationanywhere/RunBot";
import BotOptions from "@/contrib/automationanywhere/BotOptions";
import useSanitizedIntegrationConfigFormikAdapter from "@/integrations/useSanitizedIntegrationConfigFormikAdapter";
import registerDefaultWidgets from "@/components/fields/schemaFields/widgets/registerDefaultWidgets";
Expand All @@ -35,6 +34,7 @@ import {
import { validateOutputKey } from "@/runtime/runtimeTypes";
import { CONTROL_ROOM_TOKEN_INTEGRATION_ID } from "@/integrations/constants";
import { toExpression } from "@/utils/expressionUtils";
import { RunBot } from "@/contrib/automationanywhere/RunBot";

const useSanitizedIntegrationConfigFormikAdapterMock = jest.mocked(
useSanitizedIntegrationConfigFormikAdapter,
Expand All @@ -56,7 +56,7 @@ function makeBaseState() {
},
[
{
id: AUTOMATION_ANYWHERE_RUN_BOT_ID,
id: RunBot.BRICK_ID,
config: {
service: null,
fileId: null,
Expand Down
10 changes: 3 additions & 7 deletions src/contrib/automationanywhere/BotOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,7 @@ import AsyncRemoteSelectWidget from "@/components/form/widgets/AsyncRemoteSelect
import { joinName } from "@/utils/formUtils";
import useAsyncState from "@/hooks/useAsyncState";
import { CONTROL_ROOM_TOKEN_INTEGRATION_ID } from "@/integrations/constants";

const WORKSPACE_OPTIONS = [
{ value: "public", label: "Public" },
{ value: "private", label: "Private/Local" },
];
import { WORKSPACE_OPTIONS } from "@/contrib/automationanywhere/util";

const BotLoadingMessage: React.FC = () => <span>Searching bots...</span>;
const BotNoOptionsMessage: React.FC = () => (
Expand All @@ -80,7 +76,7 @@ const BotOptions: React.FunctionComponent<BlockOptionProps> = ({
);

const [{ value: workspaceType }, , { setValue: setWorkspaceType }] =
useField<string>(configName("workspaceType"));
useField<WorkspaceType | null>(configName("workspaceType"));

const [{ value: fileId }] = useField<string>(configName("fileId"));

Expand Down Expand Up @@ -145,7 +141,7 @@ const BotOptions: React.FunctionComponent<BlockOptionProps> = ({
() => ({
// Default to "private" because that's compatible with both CE and EE
// The workspaceType can be temporarily null when switching between CR configurations
workspaceType: (workspaceType as WorkspaceType) ?? "private",
workspaceType: workspaceType ?? "private",
}),
[workspaceType],
);
Expand Down
Loading

0 comments on commit 8de521b

Please sign in to comment.