diff --git a/packages/backend/src/apps/anthropic/actions/index.js b/packages/backend/src/apps/anthropic/actions/index.js new file mode 100644 index 00000000..92d67c2c --- /dev/null +++ b/packages/backend/src/apps/anthropic/actions/index.js @@ -0,0 +1,3 @@ +import sendMessage from './send-message/index.js'; + +export default [sendMessage]; diff --git a/packages/backend/src/apps/anthropic/actions/send-message/index.js b/packages/backend/src/apps/anthropic/actions/send-message/index.js new file mode 100644 index 00000000..3215bafb --- /dev/null +++ b/packages/backend/src/apps/anthropic/actions/send-message/index.js @@ -0,0 +1,124 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send message', + key: 'send Message', + description: + 'Sends a structured list of input messages with text content, and the model will generate the next message in the conversation.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + description: 'The model that will complete your prompt.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'assistant', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: true, + variables: true, + description: 'The maximum number of tokens to generate before stopping.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + value: '1.0', + description: + 'Amount of randomness injected into the response. Defaults to 1.0. Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for creative and generative tasks.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'Custom text sequences that will cause the model to stop generating.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop_sequences: nonEmptyStopSequences, + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + }; + + const { data } = await $.http.post('/v1/messages', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/anthropic/assets/favicon.svg b/packages/backend/src/apps/anthropic/assets/favicon.svg new file mode 100644 index 00000000..affdadef --- /dev/null +++ b/packages/backend/src/apps/anthropic/assets/favicon.svg @@ -0,0 +1,8 @@ + + + Anthropic + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/anthropic/auth/index.js b/packages/backend/src/apps/anthropic/auth/index.js new file mode 100644 index 00000000..947c8f8a --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Anthropic AI API key of your account.', + docUrl: 'https://automatisch.io/docs/anthropic#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/anthropic/auth/is-still-verified.js b/packages/backend/src/apps/anthropic/auth/is-still-verified.js new file mode 100644 index 00000000..531fc23a --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/is-still-verified.js @@ -0,0 +1,7 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/anthropic/auth/verify-credentials.js b/packages/backend/src/apps/anthropic/auth/verify-credentials.js new file mode 100644 index 00000000..7f43f884 --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js b/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js new file mode 100644 index 00000000..9ff91ce2 --- /dev/null +++ b/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js @@ -0,0 +1,7 @@ +const addAuthHeader = ($, requestConfig) => { + requestConfig.headers['anthropic-version'] = '2023-06-01'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/anthropic/common/add-auth-header.js b/packages/backend/src/apps/anthropic/common/add-auth-header.js new file mode 100644 index 00000000..01bcae10 --- /dev/null +++ b/packages/backend/src/apps/anthropic/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['x-api-key'] = $.auth.data.apiKey; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/anthropic/dynamic-data/index.js b/packages/backend/src/apps/anthropic/dynamic-data/index.js new file mode 100644 index 00000000..6db48046 --- /dev/null +++ b/packages/backend/src/apps/anthropic/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js b/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js new file mode 100644 index 00000000..f47f5fb9 --- /dev/null +++ b/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const models = { + data: [], + }; + + const params = { + limit: 999, + }; + + let hasMore = false; + + do { + const { data } = await $.http.get('/v1/models', { params }); + params.after_id = data.last_id; + hasMore = data.has_more; + + for (const base of data.data) { + models.data.push({ + value: base.id, + name: base.display_name, + }); + } + } while (hasMore); + + return models; + }, +}; diff --git a/packages/backend/src/apps/anthropic/index.js b/packages/backend/src/apps/anthropic/index.js new file mode 100644 index 00000000..65f29ba2 --- /dev/null +++ b/packages/backend/src/apps/anthropic/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import addAnthropicVersionHeader from './common/add-anthropic-version-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Anthropic', + key: 'anthropic', + baseUrl: 'https://anthropic.com', + apiBaseUrl: 'https://api.anthropic.com', + iconUrl: '{BASE_URL}/apps/anthropic/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/anthropic/connection', + primaryColor: '#181818', + supportsConnections: true, + beforeRequest: [addAuthHeader, addAnthropicVersionHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/freescout/assets/favicon.svg b/packages/backend/src/apps/freescout/assets/favicon.svg new file mode 100644 index 00000000..db766fa8 --- /dev/null +++ b/packages/backend/src/apps/freescout/assets/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/packages/backend/src/apps/freescout/auth/index.js b/packages/backend/src/apps/freescout/auth/index.js new file mode 100644 index 00000000..f6c8aedb --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/index.js @@ -0,0 +1,44 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'instanceUrl', + label: 'FreeScout instance URL', + type: 'string', + required: true, + readOnly: false, + description: 'Your FreeScout instance URL.', + docUrl: 'https://automatisch.io/docs/freescout#instance-url', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'FreeScout API key of your account.', + docUrl: 'https://automatisch.io/docs/freescout#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/freescout/auth/is-still-verified.js b/packages/backend/src/apps/freescout/auth/is-still-verified.js new file mode 100644 index 00000000..c5514911 --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/api/mailboxes'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/freescout/auth/verify-credentials.js b/packages/backend/src/apps/freescout/auth/verify-credentials.js new file mode 100644 index 00000000..1ac858af --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/api/mailboxes'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/freescout/common/add-auth-header.js b/packages/backend/src/apps/freescout/common/add-auth-header.js new file mode 100644 index 00000000..e599ae4d --- /dev/null +++ b/packages/backend/src/apps/freescout/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-FreeScout-API-Key'] = $.auth.data.apiKey; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/freescout/common/set-base-url.js b/packages/backend/src/apps/freescout/common/set-base-url.js new file mode 100644 index 00000000..8df6a36f --- /dev/null +++ b/packages/backend/src/apps/freescout/common/set-base-url.js @@ -0,0 +1,6 @@ +const setBaseUrl = ($, requestConfig) => { + requestConfig.baseURL = $.auth.data.instanceUrl; + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/freescout/common/webhook-filters.js b/packages/backend/src/apps/freescout/common/webhook-filters.js new file mode 100644 index 00000000..00602d9f --- /dev/null +++ b/packages/backend/src/apps/freescout/common/webhook-filters.js @@ -0,0 +1,52 @@ +const webhookFilters = [ + { + value: 'convo.assigned', + label: 'Conversation assigned', + }, + { + value: 'convo.created', + label: 'Conversation created', + }, + { + value: 'convo.deleted', + label: 'Conversation deleted', + }, + { + value: 'convo.deleted_forever', + label: 'Conversation deleted forever', + }, + { + value: 'convo.restored', + label: 'Conversation restored from Deleted folder', + }, + { + value: 'convo.moved', + label: 'Conversation moved', + }, + { + value: 'convo.status', + label: 'Conversation status updated', + }, + { + value: 'convo.customer.reply.created', + label: 'Customer replied', + }, + { + value: 'convo.agent.reply.created', + label: 'Agent replied', + }, + { + value: 'convo.note.created', + label: 'Note added', + }, + { + value: 'customer.created', + label: 'Customer create', + }, + { + value: 'customer.updated', + label: 'Customer update', + }, +]; + +export default webhookFilters; diff --git a/packages/backend/src/apps/freescout/index.js b/packages/backend/src/apps/freescout/index.js new file mode 100644 index 00000000..4044522a --- /dev/null +++ b/packages/backend/src/apps/freescout/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import setBaseUrl from './common/set-base-url.js'; + +export default defineApp({ + name: 'FreeScout', + key: 'freescout', + iconUrl: '{BASE_URL}/apps/freescout/assets/favicon.svg', + supportsConnections: true, + baseUrl: 'https://freescout.net', + primaryColor: '#F5D05E', + authDocUrl: '{DOCS_URL}/apps/freescout/connection', + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/freescout/triggers/index.js b/packages/backend/src/apps/freescout/triggers/index.js new file mode 100644 index 00000000..0a362fd4 --- /dev/null +++ b/packages/backend/src/apps/freescout/triggers/index.js @@ -0,0 +1,3 @@ +import newEvent from './new-event/index.js'; + +export default [newEvent]; diff --git a/packages/backend/src/apps/freescout/triggers/new-event/index.js b/packages/backend/src/apps/freescout/triggers/new-event/index.js new file mode 100644 index 00000000..caae12e9 --- /dev/null +++ b/packages/backend/src/apps/freescout/triggers/new-event/index.js @@ -0,0 +1,61 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; +import webhookFilters from '../../common/webhook-filters.js'; + +export default defineTrigger({ + name: 'New event', + key: 'newEvent', + type: 'webhook', + description: 'Triggers when a new event occurs.', + arguments: [ + { + label: 'Event type', + key: 'eventType', + type: 'dropdown', + required: true, + description: 'Pick an event type to receive events for.', + variables: false, + options: webhookFilters, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const payload = { + url: $.webhookUrl, + events: [$.step.parameters.eventType], + }; + + const response = await $.http.post('/api/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data?.id?.toString()); + }, + + async unregisterHook($) { + await $.http.delete(`/api/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js b/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js new file mode 100644 index 00000000..8e983103 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Creates a chat completion.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: + 'The prompt(s) to generate completions for, encoded as a list of dict with role and content.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: `The maximum number of tokens to generate in the completion. The token count of your prompt plus max_tokens cannot exceed the model's context length.`, + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: 'Stop generation if one of these tokens is detected', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'Nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Frequency_penalty penalizes the repetition of words based on their frequency in the generated text. A higher frequency penalty discourages the model from repeating words that have already appeared frequently in the output, promoting diversity and reducing repetition.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Presence penalty determines how much the model penalizes the repetition of words or phrases. A higher presence penalty encourages the model to use a wider variety of words and phrases, making the output more diverse and creative.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + stop: nonEmptyStopSequences, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/mistral-ai/actions/index.js b/packages/backend/src/apps/mistral-ai/actions/index.js new file mode 100644 index 00000000..cc0e0532 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/actions/index.js @@ -0,0 +1,3 @@ +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion]; diff --git a/packages/backend/src/apps/mistral-ai/assets/favicon.svg b/packages/backend/src/apps/mistral-ai/assets/favicon.svg new file mode 100644 index 00000000..3f583306 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/assets/favicon.svg @@ -0,0 +1,32 @@ + + + Mistral AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/mistral-ai/auth/index.js b/packages/backend/src/apps/mistral-ai/auth/index.js new file mode 100644 index 00000000..429699eb --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Mistral AI API key of your account.', + docUrl: 'https://automatisch.io/docs/mistral-ai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js b/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js new file mode 100644 index 00000000..3e6c9095 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js b/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js new file mode 100644 index 00000000..7f43f884 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mistral-ai/common/add-auth-header.js b/packages/backend/src/apps/mistral-ai/common/add-auth-header.js new file mode 100644 index 00000000..f9f5acba --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mistral-ai/dynamic-data/index.js b/packages/backend/src/apps/mistral-ai/dynamic-data/index.js new file mode 100644 index 00000000..6db48046 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js b/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js new file mode 100644 index 00000000..a8e81538 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/mistral-ai/index.js b/packages/backend/src/apps/mistral-ai/index.js new file mode 100644 index 00000000..08f9e4b5 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Mistral AI', + key: 'mistral-ai', + baseUrl: 'https://mistral.ai', + apiBaseUrl: 'https://api.mistral.ai', + iconUrl: '{BASE_URL}/apps/mistral-ai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/mistral-ai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js b/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js new file mode 100644 index 00000000..8e983103 --- /dev/null +++ b/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Creates a chat completion.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: + 'The prompt(s) to generate completions for, encoded as a list of dict with role and content.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: `The maximum number of tokens to generate in the completion. The token count of your prompt plus max_tokens cannot exceed the model's context length.`, + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: 'Stop generation if one of these tokens is detected', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'Nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Frequency_penalty penalizes the repetition of words based on their frequency in the generated text. A higher frequency penalty discourages the model from repeating words that have already appeared frequently in the output, promoting diversity and reducing repetition.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Presence penalty determines how much the model penalizes the repetition of words or phrases. A higher presence penalty encourages the model to use a wider variety of words and phrases, making the output more diverse and creative.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + stop: nonEmptyStopSequences, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/openrouter/actions/index.js b/packages/backend/src/apps/openrouter/actions/index.js new file mode 100644 index 00000000..cc0e0532 --- /dev/null +++ b/packages/backend/src/apps/openrouter/actions/index.js @@ -0,0 +1,3 @@ +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion]; diff --git a/packages/backend/src/apps/openrouter/assets/favicon.svg b/packages/backend/src/apps/openrouter/assets/favicon.svg new file mode 100644 index 00000000..e88f91bd --- /dev/null +++ b/packages/backend/src/apps/openrouter/assets/favicon.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/packages/backend/src/apps/openrouter/auth/index.js b/packages/backend/src/apps/openrouter/auth/index.js new file mode 100644 index 00000000..8d260a3c --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'OpenRouter API key of your account.', + docUrl: 'https://automatisch.io/docs/openrouter#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/openrouter/auth/is-still-verified.js b/packages/backend/src/apps/openrouter/auth/is-still-verified.js new file mode 100644 index 00000000..3e6c9095 --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/openrouter/auth/verify-credentials.js b/packages/backend/src/apps/openrouter/auth/verify-credentials.js new file mode 100644 index 00000000..7f43f884 --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/openrouter/common/add-auth-header.js b/packages/backend/src/apps/openrouter/common/add-auth-header.js new file mode 100644 index 00000000..f9f5acba --- /dev/null +++ b/packages/backend/src/apps/openrouter/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/openrouter/dynamic-data/index.js b/packages/backend/src/apps/openrouter/dynamic-data/index.js new file mode 100644 index 00000000..6db48046 --- /dev/null +++ b/packages/backend/src/apps/openrouter/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js b/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js new file mode 100644 index 00000000..a8e81538 --- /dev/null +++ b/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/openrouter/index.js b/packages/backend/src/apps/openrouter/index.js new file mode 100644 index 00000000..12a50672 --- /dev/null +++ b/packages/backend/src/apps/openrouter/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'OpenRouter', + key: 'openrouter', + baseUrl: 'https://openrouter.ai', + apiBaseUrl: 'https://openrouter.ai/api', + iconUrl: '{BASE_URL}/apps/openrouter/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/openrouter/connection', + primaryColor: '#71717a', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/perplexity/actions/index.js b/packages/backend/src/apps/perplexity/actions/index.js new file mode 100644 index 00000000..c95d62f1 --- /dev/null +++ b/packages/backend/src/apps/perplexity/actions/index.js @@ -0,0 +1,3 @@ +import sendChatPrompt from './send-chat-prompt/index.js'; + +export default [sendChatPrompt]; diff --git a/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js b/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js new file mode 100644 index 00000000..bde28cf1 --- /dev/null +++ b/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js @@ -0,0 +1,185 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send chat prompt', + key: 'sendChatPrompt', + description: `Generates a model's response for the given chat conversation.`, + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + options: [ + { + label: 'Sonar Pro', + value: 'sonar-pro', + }, + { + label: 'Sonar', + value: 'sonar', + }, + ], + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + description: + 'The role of the speaker in this turn of conversation. After the (optional) system message, user and assistant roles should alternate with user then assistant, ending in user.', + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + description: + 'The contents of the message in this turn of conversation.', + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'The amount of randomness in the response, valued between 0 inclusive and 2 exclusive. Higher values are more random, and lower values are more deterministic.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'The nucleus sampling threshold, valued between 0 and 1 inclusive. For each subsequent token, the model considers the results of the tokens with top_p probability mass. We recommend either altering top_k or top_p, but not both.', + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: + 'The number of tokens to keep for highest top-k filtering, specified as an integer between 0 and 2048 inclusive. If set to 0, top-k filtering is disabled. We recommend either altering top_k or top_p, but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A multiplicative penalty greater than 0. Values greater than 1.0 penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. A value of 1.0 means no penalty. Incompatible with presence_penalty.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A value between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. Incompatible with frequency_penalty.`, + }, + { + label: 'Return images', + key: 'returnImages', + type: 'dropdown', + required: false, + variables: true, + value: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + label: 'Return related questions', + key: 'returnRelatedQuestions', + type: 'dropdown', + required: false, + variables: true, + value: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + return_images: $.step.parameters.returnImages, + return_related_questions: $.step.parameters.returnRelatedQuestons, + }; + + const { data } = await $.http.post('/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/perplexity/assets/favicon.svg b/packages/backend/src/apps/perplexity/assets/favicon.svg new file mode 100644 index 00000000..b27ffc98 --- /dev/null +++ b/packages/backend/src/apps/perplexity/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/perplexity/auth/index.js b/packages/backend/src/apps/perplexity/auth/index.js new file mode 100644 index 00000000..67b870bf --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Perplexity API key of your account.', + docUrl: 'https://automatisch.io/docs/perplexity#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/perplexity/auth/is-still-verified.js b/packages/backend/src/apps/perplexity/auth/is-still-verified.js new file mode 100644 index 00000000..3f853952 --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/is-still-verified.js @@ -0,0 +1,5 @@ +const isStillVerified = async () => { + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/perplexity/auth/verify-credentials.js b/packages/backend/src/apps/perplexity/auth/verify-credentials.js new file mode 100644 index 00000000..07e4f027 --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async () => { + return true; +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/perplexity/common/add-auth-header.js b/packages/backend/src/apps/perplexity/common/add-auth-header.js new file mode 100644 index 00000000..f9f5acba --- /dev/null +++ b/packages/backend/src/apps/perplexity/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/perplexity/index.js b/packages/backend/src/apps/perplexity/index.js new file mode 100644 index 00000000..ed401025 --- /dev/null +++ b/packages/backend/src/apps/perplexity/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Perplexity', + key: 'perplexity', + baseUrl: 'https://perplexity.ai', + apiBaseUrl: 'https://api.perplexity.ai', + iconUrl: '{BASE_URL}/apps/perplexity/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/perplexity/connection', + primaryColor: '#091717', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/scheduler/common/cron-times.js b/packages/backend/src/apps/scheduler/common/cron-times.js index 8682a65c..333c0a25 100644 --- a/packages/backend/src/apps/scheduler/common/cron-times.js +++ b/packages/backend/src/apps/scheduler/common/cron-times.js @@ -1,4 +1,6 @@ const cronTimes = { + everyNMinutes: (n) => `*/${n} * * * *`, + everyNMinutesExcludingWeekends: (n) => `*/${n} * * * 1-5`, everyHour: '0 * * * *', everyHourExcludingWeekends: '0 * * * 1-5', everyDayAt: (hour) => `0 ${hour} * * *`, diff --git a/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js b/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js new file mode 100644 index 00000000..9896f7b9 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js @@ -0,0 +1,131 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every N minutes', + key: 'everyNMinutes', + description: 'Triggers every N minutes.', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + label: 'Interval', + key: 'interval', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { label: 'Every 1 minute', value: 1 }, + { label: 'Every 2 minutes', value: 2 }, + { label: 'Every 3 minutes', value: 3 }, + { label: 'Every 4 minutes', value: 4 }, + { label: 'Every 5 minutes', value: 5 }, + { label: 'Every 6 minutes', value: 6 }, + { label: 'Every 7 minutes', value: 7 }, + { label: 'Every 8 minutes', value: 8 }, + { label: 'Every 9 minutes', value: 9 }, + { label: 'Every 10 minutes', value: 10 }, + { label: 'Every 11 minutes', value: 11 }, + { label: 'Every 12 minutes', value: 12 }, + { label: 'Every 13 minutes', value: 13 }, + { label: 'Every 14 minutes', value: 14 }, + { label: 'Every 15 minutes', value: 15 }, + { label: 'Every 16 minutes', value: 16 }, + { label: 'Every 17 minutes', value: 17 }, + { label: 'Every 18 minutes', value: 18 }, + { label: 'Every 19 minutes', value: 19 }, + { label: 'Every 20 minutes', value: 20 }, + { label: 'Every 21 minutes', value: 21 }, + { label: 'Every 22 minutes', value: 22 }, + { label: 'Every 23 minutes', value: 23 }, + { label: 'Every 24 minutes', value: 24 }, + { label: 'Every 25 minutes', value: 25 }, + { label: 'Every 26 minutes', value: 26 }, + { label: 'Every 27 minutes', value: 27 }, + { label: 'Every 28 minutes', value: 28 }, + { label: 'Every 29 minutes', value: 29 }, + { label: 'Every 30 minutes', value: 30 }, + { label: 'Every 31 minutes', value: 31 }, + { label: 'Every 32 minutes', value: 32 }, + { label: 'Every 33 minutes', value: 33 }, + { label: 'Every 34 minutes', value: 34 }, + { label: 'Every 35 minutes', value: 35 }, + { label: 'Every 36 minutes', value: 36 }, + { label: 'Every 37 minutes', value: 37 }, + { label: 'Every 38 minutes', value: 38 }, + { label: 'Every 39 minutes', value: 39 }, + { label: 'Every 40 minutes', value: 40 }, + { label: 'Every 41 minutes', value: 41 }, + { label: 'Every 42 minutes', value: 42 }, + { label: 'Every 43 minutes', value: 43 }, + { label: 'Every 44 minutes', value: 44 }, + { label: 'Every 45 minutes', value: 45 }, + { label: 'Every 46 minutes', value: 46 }, + { label: 'Every 47 minutes', value: 47 }, + { label: 'Every 48 minutes', value: 48 }, + { label: 'Every 49 minutes', value: 49 }, + { label: 'Every 50 minutes', value: 50 }, + { label: 'Every 51 minutes', value: 51 }, + { label: 'Every 52 minutes', value: 52 }, + { label: 'Every 53 minutes', value: 53 }, + { label: 'Every 54 minutes', value: 54 }, + { label: 'Every 55 minutes', value: 55 }, + { label: 'Every 56 minutes', value: 56 }, + { label: 'Every 57 minutes', value: 57 }, + { label: 'Every 58 minutes', value: 58 }, + { label: 'Every 59 minutes', value: 59 }, + ], + }, + ], + + getInterval(parameters) { + if (parameters.triggersOnWeekend) { + return cronTimes.everyNMinutes(parameters.interval); + } + + return cronTimes.everyNMinutesExcludingWeekends(parameters.interval); + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + + const dateTime = DateTime.now(); + + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/index.js b/packages/backend/src/apps/scheduler/triggers/index.js index 3239a453..cfdfac4f 100644 --- a/packages/backend/src/apps/scheduler/triggers/index.js +++ b/packages/backend/src/apps/scheduler/triggers/index.js @@ -1,6 +1,7 @@ +import everyNMinutes from './every-n-minutes/index.js'; import everyHour from './every-hour/index.js'; import everyDay from './every-day/index.js'; import everyWeek from './every-week/index.js'; import everyMonth from './every-month/index.js'; -export default [everyHour, everyDay, everyWeek, everyMonth]; +export default [everyNMinutes, everyHour, everyDay, everyWeek, everyMonth]; diff --git a/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js b/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js new file mode 100644 index 00000000..5055a647 --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js @@ -0,0 +1,169 @@ +import { XMLBuilder } from 'fast-xml-parser'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Add voice XML node', + key: 'addVoiceXmlNode', + description: 'Add a voice XML node in the XML document', + supportsConnections: false, + arguments: [ + { + label: 'Node name', + key: 'nodeName', + type: 'dropdown', + required: true, + description: 'The name of the node to be added.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodes', + }, + ], + }, + }, + { + label: 'Node value', + key: 'nodeValue', + type: 'string', + required: false, + description: 'The value of the node to be added.', + variables: true, + }, + { + label: 'Attributes', + key: 'attributes', + type: 'dynamic', + required: false, + description: 'Add or remove attributes for the node as needed', + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Attribute name', + key: 'key', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributes', + }, + { + name: 'parameters.nodeName', + value: '{parameters.nodeName}', + }, + ], + }, + }, + { + label: 'Attribute value', + key: 'value', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributeValues', + }, + { + name: 'parameters.nodeName', + value: '{parameters.nodeName}', + }, + { + name: 'parameters.attributeKey', + value: '{fieldsScope.key}', + }, + ], + }, + }, + ], + }, + { + label: 'Add children node', + key: 'hasChildrenNodes', + type: 'dropdown', + required: true, + description: 'Add a nested node to the main node', + value: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listNodeFields', + }, + { + name: 'parameters.hasChildrenNodes', + value: '{parameters.hasChildrenNodes}', + }, + ], + }, + }, + ], + + async run($) { + const nodeName = $.step.parameters.nodeName; + const nodeValue = $.step.parameters.nodeValue; + const attributes = $.step.parameters.attributes; + const childrenNodes = $.step.parameters.childrenNodes; + const hasChildrenNodes = $.step.parameters.hasChildrenNodes; + + const builder = new XMLBuilder({ + ignoreAttributes: false, + suppressEmptyNode: true, + preserveOrder: true, + }); + + const computeAttributes = (attributes) => + attributes + .filter((attribute) => attribute.key || attribute.value) + .reduce( + (result, attribute) => ({ + ...result, + [`@_${attribute.key}`]: attribute.value, + }), + {} + ); + + const computeTextNode = (nodeValue) => ({ + '#text': nodeValue, + }); + + const computedChildrenNodes = hasChildrenNodes + ? childrenNodes.map((childNode) => ({ + [childNode.nodeName]: [computeTextNode(childNode.nodeValue)], + ':@': computeAttributes(childNode.attributes), + })) + : []; + + const xmlObject = { + [nodeName]: [computeTextNode(nodeValue), ...computedChildrenNodes], + ':@': computeAttributes(attributes), + }; + + const xmlString = builder.build([xmlObject]); + + $.setActionItem({ raw: { stringNode: xmlString } }); + }, +}); diff --git a/packages/backend/src/apps/signalwire/actions/index.js b/packages/backend/src/apps/signalwire/actions/index.js index 18a261f9..dc2fe64e 100644 --- a/packages/backend/src/apps/signalwire/actions/index.js +++ b/packages/backend/src/apps/signalwire/actions/index.js @@ -1,3 +1,5 @@ import sendSms from './send-sms/index.js'; +import addVoiceXmlNode from './add-voice-xml-node/index.js'; +import respondWithVoiceXml from './respond-with-voice-xml/index.js'; -export default [sendSms]; +export default [addVoiceXmlNode, respondWithVoiceXml, sendSms]; diff --git a/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js b/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js new file mode 100644 index 00000000..d8bb76df --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js @@ -0,0 +1,66 @@ +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Respond with voice XML', + key: 'respondWithVoiceXml', + description: 'Respond with defined voice XML document', + supportsConnections: false, + arguments: [ + { + label: 'Nodes', + key: 'nodes', + type: 'dynamic', + required: false, + description: 'Add or remove nodes for the XML document as needed', + value: [ + { + nodeString: '', + }, + ], + fields: [ + { + label: 'Node', + key: 'nodeString', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const builder = new XMLBuilder({ + ignoreAttributes: false, + suppressEmptyNode: true, + preserveOrder: true, + }); + + const parser = new XMLParser({ + ignoreAttributes: false, + preserveOrder: true, + parseTagValue: false, + }); + + const nodes = $.step.parameters.nodes; + const computedNodes = nodes.map((node) => node.nodeString); + const parsedNodes = computedNodes.flatMap((computedNode) => + parser.parse(computedNode) + ); + + const xmlString = builder.build([ + { + Response: parsedNodes, + }, + ]); + + $.setActionItem({ + raw: { + body: xmlString, + statusCode: 200, + headers: { 'content-type': 'text/xml' }, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/signalwire/actions/send-sms/index.js b/packages/backend/src/apps/signalwire/actions/send-sms/index.js index 152ab8d2..3c7487af 100644 --- a/packages/backend/src/apps/signalwire/actions/send-sms/index.js +++ b/packages/backend/src/apps/signalwire/actions/send-sms/index.js @@ -19,7 +19,7 @@ export default defineAction({ arguments: [ { name: 'key', - value: 'listIncomingPhoneNumbers', + value: 'listIncomingSmsPhoneNumbers', }, ], }, diff --git a/packages/backend/src/apps/signalwire/dynamic-data/index.js b/packages/backend/src/apps/signalwire/dynamic-data/index.js index 758d4abe..fc077d2b 100644 --- a/packages/backend/src/apps/signalwire/dynamic-data/index.js +++ b/packages/backend/src/apps/signalwire/dynamic-data/index.js @@ -1,3 +1,15 @@ -import listIncomingPhoneNumbers from './list-incoming-phone-numbers/index.js'; +import listIncomingCallPhoneNumbers from './list-incoming-call-phone-numbers/index.js'; +import listIncomingSmsPhoneNumbers from './list-incoming-sms-phone-numbers/index.js'; +import listVoiceXmlNodeAttributes from './list-voice-xml-node-attributes/index.js'; +import listVoiceXmlNodeAttributeValues from './list-voice-xml-node-attribute-values/index.js'; +import listVoiceXmlChildrenNodes from './list-voice-xml-children-nodes/index.js'; +import listVoiceXmlNodes from './list-voice-xml-nodes/index.js'; -export default [listIncomingPhoneNumbers]; +export default [ + listIncomingCallPhoneNumbers, + listIncomingSmsPhoneNumbers, + listVoiceXmlNodeAttributes, + listVoiceXmlNodeAttributeValues, + listVoiceXmlNodes, + listVoiceXmlChildrenNodes, +]; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js new file mode 100644 index 00000000..93f3ded9 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List incoming call phone numbers', + key: 'listIncomingCallPhoneNumbers', + + async run($) { + let requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers`; + + const aggregatedResponse = { + data: [], + }; + + do { + const { data } = await $.http.get(requestPath); + + const voiceCapableIncomingPhoneNumbers = data.incoming_phone_numbers + .filter((incomingPhoneNumber) => { + return incomingPhoneNumber.capabilities.voice; + }) + .map((incomingPhoneNumber) => { + const friendlyName = incomingPhoneNumber.friendly_name; + const phoneNumber = incomingPhoneNumber.phone_number; + const name = [friendlyName, phoneNumber].filter(Boolean).join(' - '); + + return { + value: incomingPhoneNumber.sid, + name, + }; + }); + + aggregatedResponse.data.push(...voiceCapableIncomingPhoneNumbers); + + requestPath = data.next_page_uri; + } while (requestPath); + + return aggregatedResponse; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-phone-numbers/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js similarity index 92% rename from packages/backend/src/apps/signalwire/dynamic-data/list-incoming-phone-numbers/index.js rename to packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js index 72fa66a2..9a8129a7 100644 --- a/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-phone-numbers/index.js +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js @@ -1,6 +1,6 @@ export default { - name: 'List incoming phone numbers', - key: 'listIncomingPhoneNumbers', + name: 'List incoming SMS phone numbers', + key: 'listIncomingSmsPhoneNumbers', async run($) { let requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers`; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js new file mode 100644 index 00000000..a05947a2 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List voice XML children nodes', + key: 'listVoiceXmlChildrenNodes', + + async run($) { + const parentNodeName = $.step.parameters.parentNodeName; + + const parentChildrenNodeMap = { + Dial: [ + { name: 'Number', value: 'Number' }, + { name: 'Conference', value: 'Conference' }, + { name: 'Queue', value: 'Queue' }, + { name: 'Sip', value: 'Sip' }, + { name: 'Verto', value: 'Verto' }, + ], + Gather: [ + { name: 'Say', value: 'Say' }, + { name: 'Play', value: 'Play' }, + { name: 'Pause', value: 'Pause' }, + ], + Refer: [{ name: 'Sip', value: 'Sip' }], + Connect: [ + { name: 'Room', value: 'Room' }, + { name: 'Stream', value: 'Stream' }, + { name: 'VirtualAgent', value: 'VirtualAgent' }, + ], + }; + + const childrenNodes = parentChildrenNodeMap[parentNodeName] || []; + + const nodes = { + data: childrenNodes, + }; + + return nodes; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js new file mode 100644 index 00000000..cfbf3143 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js @@ -0,0 +1,516 @@ +export default { + name: 'List voice XML node attribute values', + key: 'listVoiceXmlNodeAttributeValues', + + async run($) { + const nodeName = $.step.parameters.nodeName; + const attributeKey = $.step.parameters.attributeKey; + + // Node: Conference + const conferenceMutedAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceBeepAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + { + name: 'On Enter Only', + value: 'onEnter', + }, + { + name: 'On Exit Only', + value: 'onExit', + }, + ]; + + const conferenceStartConferenceOnEnterAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceEndConferenceOnExitAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceWaitMethodAttributeValues = [ + { + name: 'POST', + value: 'POST', + }, + { + name: 'GET', + value: 'GET', + }, + ]; + + const conferenceRecordAttributeValues = [ + { + name: 'Record From Start', + value: 'record-from-start', + }, + { + name: 'Do Not Record', + value: 'do-not-record', + }, + ]; + + const conferenceTrimAttributeValues = [ + { + name: 'Trim Silence', + value: 'trim-silence', + }, + { + name: 'Do Not Trim', + value: 'do-not-trim', + }, + ]; + + const conferenceJitterBufferAttributeValues = [ + { + name: 'Off', + value: 'off', + }, + { + name: 'Fixed', + value: 'fixed', + }, + { + name: 'Adaptive', + value: 'adaptive', + }, + ]; + + const conference = { + muted: conferenceMutedAttributeValues, + beep: conferenceBeepAttributeValues, + startConferenceOnEnter: conferenceStartConferenceOnEnterAttributeValues, + endConferenceOnExit: conferenceEndConferenceOnExitAttributeValues, + waitMethod: conferenceWaitMethodAttributeValues, + record: conferenceRecordAttributeValues, + trim: conferenceTrimAttributeValues, + jitterBuffer: conferenceJitterBufferAttributeValues, + }; + + // NODE: Say + const sayVoiceAttributeValues = [ + { name: 'Man', value: 'man' }, + { name: 'Woman', value: 'woman' }, + { name: 'Polly Man', value: 'Polly.man' }, + { name: 'Polly Woman', value: 'Polly.woman' }, + { name: 'Polly Man Neural', value: 'Polly.man-Neural' }, + { name: 'Polly Woman Neural', value: 'Polly.woman-Neural' }, + { name: 'Google Cloud Man', value: 'gcloud.man' }, + { name: 'Google Cloud Woman', value: 'gcloud.woman' }, + ]; + + const sayLoopAttributeValues = [ + { name: 'Infinite', value: 0 }, + { name: 'One Time', value: 1 }, + { name: 'Two Times', value: 2 }, + { name: 'Three Times', value: 3 }, + { name: 'Four Times', value: 4 }, + { name: 'Five Times', value: 5 }, + ]; + + const sayLanguageAttributeValues = [ + { name: 'English (US)', value: 'en-US' }, + { name: 'English (UK)', value: 'en-GB' }, + { name: 'Spanish (Spain)', value: 'es-ES' }, + { name: 'French (France)', value: 'fr-FR' }, + { name: 'German (Germany)', value: 'de-DE' }, + ]; + + const say = { + voice: sayVoiceAttributeValues, + loop: sayLoopAttributeValues, + language: sayLanguageAttributeValues, + }; + + // Node: Sip + + const sipCodecsAttributeValues = [ + { name: 'PCMU', value: 'PCMU' }, + { name: 'PCMA', value: 'PCMA' }, + { name: 'G722', value: 'G722' }, + { name: 'G729', value: 'G729' }, + { name: 'OPUS', value: 'OPUS' }, + ]; + + const sipMethodAttributeValues = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + ]; + + const sipStatusCallbackMethodAttributeValues = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + ]; + + const sipStatusCallbackEventValues = [ + { name: 'Initiated', value: 'initiated' }, + { name: 'Ringing', value: 'ringing' }, + { name: 'Answered', value: 'answered' }, + { name: 'Completed', value: 'completed' }, + ]; + + const sip = { + codecs: sipCodecsAttributeValues, + method: sipMethodAttributeValues, + statusCallbackMethod: sipStatusCallbackMethodAttributeValues, + statusCallbackEvent: sipStatusCallbackEventValues, + }; + + // Node: Stream + const streamTrackAttributeValues = [ + { + name: 'Inbound Track', + value: 'inbound_track', + }, + { + name: 'Outbound Track', + value: 'outbound_track', + }, + { + name: 'Both Tracks', + value: 'both_tracks', + }, + ]; + + const streamStatusCallbackMethodAttributeValues = [ + { + name: 'GET', + value: 'GET', + }, + { + name: 'POST', + value: 'POST', + }, + ]; + + const stream = { + track: streamTrackAttributeValues, + statusCallbackMethod: streamStatusCallbackMethodAttributeValues, + }; + + // Node: Dial + const dialAnswerOnBridgeAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const dialHangupOnStarAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const dialMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const dialRecordAttributeValues = [ + { name: 'Do Not Record', value: 'do-not-record' }, + { name: 'Record from Answer', value: 'record-from-answer' }, + { name: 'Record from Ringing', value: 'record-from-ringing' }, + { name: 'Dual Channel from Answer', value: 'record-from-answer-dual' }, + { name: 'Dual Channel from Ringing', value: 'record-from-ringing-dual' }, + ]; + + const dialRecordingStatusCallbackEventAttributeValues = [ + { name: 'Completed', value: 'completed' }, + { name: 'In Progress', value: 'in-progress' }, + { name: 'Absent', value: 'absent' }, + ]; + + const dialRecordingStatusCallbackMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const dialRecordingTrackAttributeValues = [ + { name: 'Inbound', value: 'inbound' }, + { name: 'Outbound', value: 'outbound' }, + { name: 'Both', value: 'both' }, + ]; + + const dialRingToneAttributeValues = [ + { name: 'Austria', value: 'at' }, + { name: 'Australia', value: 'au' }, + { name: 'Belgium', value: 'be' }, + { name: 'Brazil', value: 'br' }, + { name: 'Canada', value: 'ca' }, + { name: 'China', value: 'cn' }, + { name: 'Denmark', value: 'dk' }, + { name: 'France', value: 'fr' }, + { name: 'Germany', value: 'de' }, + { name: 'United States', value: 'us' }, + { name: 'United Kingdom', value: 'uk' }, + { name: 'Japan', value: 'jp' }, + // Add more ISO 3166-1 alpha-2 codes as needed + ]; + + const dialTrimAttributeValues = [ + { name: 'Trim Silence', value: 'trim-silence' }, + { name: 'Do Not Trim', value: 'do-not-trim' }, + ]; + + const dial = { + answerOnBridge: dialAnswerOnBridgeAttributeValues, + hangupOnStar: dialHangupOnStarAttributeValues, + method: dialMethodAttributeValues, + record: dialRecordAttributeValues, + recordingStatusCallbackEvent: + dialRecordingStatusCallbackEventAttributeValues, + recordingStatusCallbackMethod: + dialRecordingStatusCallbackMethodAttributeValues, + recordingTrack: dialRecordingTrackAttributeValues, + ringTone: dialRingToneAttributeValues, + trim: dialTrimAttributeValues, + }; + + // Node: Enqueue + const enqueueMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const enqueueWaitUrlMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const enqueue = { + method: enqueueMethodAttributeValues, + waitUrlMethod: enqueueWaitUrlMethodAttributeValues, + }; + + // Node: Gather + const gatherActionOnEmptyResultAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherEnhancedAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherInputAttributeValues = [ + { name: 'DTMF', value: 'dtmf' }, + { name: 'Speech', value: 'speech' }, + { name: 'DTMF and Speech', value: 'dtmf speech' }, + ]; + + const gatherLanguageAttributeValues = [ + { name: 'English (US)', value: 'en-US' }, + { name: 'English (UK)', value: 'en-GB' }, + { name: 'Spanish (Spain)', value: 'es-ES' }, + { name: 'French (France)', value: 'fr-FR' }, + { name: 'German (Germany)', value: 'de-DE' }, + ]; + + const gatherMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const gatherProfanityFilterAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherSpeechModelAttributeValues = [ + { name: 'Phone Call', value: 'phone_call' }, + { name: 'Video', value: 'video' }, + { name: 'Default', value: 'default' }, + ]; + + const gatherSpeechTimeoutAttributeValues = [ + { name: 'Auto', value: 'auto' }, + ]; + + const gather = { + actionOnEmptyResult: gatherActionOnEmptyResultAttributeValues, + enhanced: gatherEnhancedAttributeValues, + input: gatherInputAttributeValues, + language: gatherLanguageAttributeValues, + method: gatherMethodAttributeValues, + profanityFilter: gatherProfanityFilterAttributeValues, + speechModel: gatherSpeechModelAttributeValues, + speechTimeout: gatherSpeechTimeoutAttributeValues, + }; + + // Node: Number + const numberMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const numberStatusCallbackEventAttributeValues = [ + { name: 'Initiated', value: 'initiated' }, + { name: 'Ringing', value: 'ringing' }, + { name: 'Answered', value: 'answered' }, + { name: 'Completed', value: 'completed' }, + ]; + + const number = { + method: numberMethodAttributeValues, + statusCallbackEvent: numberStatusCallbackEventAttributeValues, + }; + + // Node: Queue + const queueMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const queue = { + method: queueMethodAttributeValues, + }; + + // Node: Record + const recordMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const recordPlayBeepAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const recordTrimAttributeValues = [ + { name: 'Trim Silence', value: 'trim-silence' }, + { name: 'Do Not Trim', value: 'do-not-trim' }, + ]; + + const recordRecordingStatusCallbackEventAttributeValues = [ + { name: 'Completed', value: 'completed' }, + { name: 'In Progress', value: 'in-progress' }, + { name: 'Absent', value: 'absent' }, + ]; + + const recordRecordingStatusCallbackMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const recordStorageUrlMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'PUT', value: 'PUT' }, + ]; + + const recordTranscribeAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const record = { + method: recordMethodAttributeValues, + playBeep: recordPlayBeepAttributeValues, + trim: recordTrimAttributeValues, + recordingStatusCallbackEvent: + recordRecordingStatusCallbackEventAttributeValues, + recordingStatusCallbackMethod: + recordRecordingStatusCallbackMethodAttributeValues, + storageUrlMethod: recordStorageUrlMethodAttributeValues, + transcribe: recordTranscribeAttributeValues, + }; + + // Node: Redirect + const redirectMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const redirect = { + method: redirectMethodAttributeValues, + }; + + // Node: Refer + const referMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const refer = { + method: referMethodAttributeValues, + }; + + // Node: Reject + const rejectReasonAttributeValues = [ + { name: 'Busy', value: 'busy' }, + { name: 'Rejected', value: 'rejected' }, + ]; + + const reject = { + reason: rejectReasonAttributeValues, + }; + + // Node: Sms + const smsMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const sms = { + method: smsMethodAttributeValues, + }; + + const allNodeAttributeValues = { + Conference: conference, + Dial: dial, + Enqueue: enqueue, + Gather: gather, + Number: number, + Queue: queue, + Record: record, + Redirect: redirect, + Refer: refer, + Reject: reject, + Say: say, + Sip: sip, + Sms: sms, + Stream: stream, + }; + + if (!nodeName) return { data: [] }; + + const selectedNodeAttributes = allNodeAttributeValues[nodeName]; + + if (!selectedNodeAttributes) return { data: [] }; + + const selectedNodeAttributeValues = selectedNodeAttributes[attributeKey]; + + if (!selectedNodeAttributeValues) return { data: [] }; + + return { data: selectedNodeAttributeValues }; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js new file mode 100644 index 00000000..1a646e89 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js @@ -0,0 +1,205 @@ +export default { + name: 'List voice XML node attributes', + key: 'listVoiceXmlNodeAttributes', + + async run($) { + const nodeName = $.step.parameters.nodeName; + + const conferenceAttributes = [ + { name: 'Beep', value: 'beep' }, + { name: 'Coach', value: 'coach' }, + { name: 'End Conference On Exit', value: 'endConferenceOnExit' }, + { name: 'Event Callback URL', value: 'eventCallbackUrl' }, + { name: 'Max Participants', value: 'maxParticipants' }, + { name: 'Muted', value: 'muted' }, + { name: 'Record', value: 'record' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { name: 'Start Conference On Enter', value: 'startConferenceOnEnter' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'Trim', value: 'trim' }, + { name: 'Wait Method', value: 'waitMethod' }, + { name: 'Wait URL', value: 'waitUrl' }, + ]; + + const dialAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Answer On Bridge', value: 'answerOnBridge' }, + { name: 'Caller ID', value: 'callerId' }, + { name: 'Caller Name', value: 'callerName' }, + { name: 'Hangup On Star', value: 'hangupOnStar' }, + { name: 'Method', value: 'method' }, + { name: 'Record', value: 'record' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { + name: 'Recording Storage URL Method', + value: 'recordingStorageUrlMethod', + }, + { name: 'Recording Storage URL', value: 'recordingStorageUrl' }, + { name: 'Recording Track', value: 'recordingTrack' }, + { name: 'Ring Tone', value: 'ringTone' }, + { name: 'Time Limit', value: 'timeLimit' }, + { name: 'Timeout', value: 'timeout' }, + { name: 'Trim', value: 'trim' }, + ]; + + const echoAttributes = [{ name: 'Timeout', value: 'timeout' }]; + + const enqueueAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Method', value: 'method' }, + { name: 'Wait URL', value: 'waitUrl' }, + { name: 'Wait URL Method', value: 'waitUrlMethod' }, + ]; + + const gatherAttributes = [ + { name: 'Action On Empty Result', value: 'actionOnEmptyResult' }, + { name: 'Action', value: 'action' }, + { name: 'Enhanced', value: 'enhanced' }, + { name: 'Finish On Key', value: 'finishOnKey' }, + { name: 'Hints', value: 'hints' }, + { name: 'Input', value: 'input' }, + { name: 'Language', value: 'language' }, + { name: 'Method', value: 'method' }, + { name: 'Num Digits', value: 'numDigits' }, + { + name: 'Partial Result Callback Method', + value: 'partialResultCallbackMethod', + }, + { name: 'Partial Result Callback', value: 'partialResultCallback' }, + { name: 'Profanity Filter', value: 'profanityFilter' }, + { name: 'Speech Model', value: 'speechModel' }, + { name: 'Speech Timeout', value: 'speechTimeout' }, + { name: 'Timeout', value: 'timeout' }, + ]; + + const numberAttributes = [ + { name: 'Method', value: 'method' }, + { name: 'Send Digits', value: 'sendDigits' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'URL', value: 'url' }, + ]; + + const pauseAttributes = [{ name: 'Length', value: 'length' }]; + + const playAttributes = [ + { name: 'Digits', value: 'digits' }, + { name: 'Loop', value: 'loop' }, + ]; + + const queueAttributes = [ + { name: 'Method', value: 'method' }, + { name: 'URL', value: 'url' }, + ]; + + const recordAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Finish On Key', value: 'finishOnKey' }, + { name: 'Max Length', value: 'maxLength' }, + { name: 'Method', value: 'method' }, + { name: 'Play Beep', value: 'playBeep' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { name: 'Storage URL Method', value: 'storageUrlMethod' }, + { name: 'Storage URL', value: 'storageUrl' }, + { name: 'Timeout', value: 'timeout' }, + { name: 'Transcribe Callback', value: 'transcribeCallback' }, + { name: 'Transcribe', value: 'transcribe' }, + { name: 'Trim', value: 'trim' }, + ]; + + const redirectAttributes = [{ name: 'Method', value: 'method' }]; + + const referAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Method', value: 'method' }, + ]; + + const rejectAttributes = [{ name: 'Reason', value: 'reason' }]; + + const sayAttributes = [ + { name: 'Language', value: 'language' }, + { name: 'Loop', value: 'loop' }, + { name: 'Voice', value: 'voice' }, + ]; + + const sipAttributes = [ + { name: 'Codecs', value: 'codecs' }, + { name: 'Method', value: 'method' }, + { name: 'Password', value: 'password' }, + { name: 'Session Timeout', value: 'sessionTimeout' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'URL', value: 'url' }, + { name: 'Username', value: 'username' }, + ]; + + const smsAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'From', value: 'from' }, + { name: 'Method', value: 'method' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'To', value: 'to' }, + ]; + + const virtualAgentAttributes = [ + { name: 'Connector Name', value: 'connectorName' }, + ]; + + const streamAttributes = [ + { name: 'URL', value: 'url' }, + { name: 'Name', value: 'name' }, + { name: 'Track', value: 'track' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + ]; + + if (nodeName === 'Conference') return { data: conferenceAttributes }; + if (nodeName === 'Dial') return { data: dialAttributes }; + if (nodeName === 'Echo') return { data: echoAttributes }; + if (nodeName === 'Enqueue') return { data: enqueueAttributes }; + if (nodeName === 'Gather') return { data: gatherAttributes }; + if (nodeName === 'Number') return { data: numberAttributes }; + if (nodeName === 'Pause') return { data: pauseAttributes }; + if (nodeName === 'Play') return { data: playAttributes }; + if (nodeName === 'Queue') return { data: queueAttributes }; + if (nodeName === 'Record') return { data: recordAttributes }; + if (nodeName === 'Redirect') return { data: redirectAttributes }; + if (nodeName === 'Refer') return { data: referAttributes }; + if (nodeName === 'Reject') return { data: rejectAttributes }; + if (nodeName === 'Say') return { data: sayAttributes }; + if (nodeName === 'Sip') return { data: sipAttributes }; + if (nodeName === 'Sms') return { data: smsAttributes }; + if (nodeName === 'Stream') return { data: streamAttributes }; + if (nodeName === 'VirtualAgent') return { data: virtualAgentAttributes }; + + return { data: [] }; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js new file mode 100644 index 00000000..2ade44fd --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List voice XML nodes', + key: 'listVoiceXmlNodes', + + async run() { + const nodes = { + data: [ + { name: 'Conference', value: 'Conference' }, + { name: 'Connect', value: 'Connect' }, + { name: 'Denoise', value: 'Denoise' }, + { name: 'Dial', value: 'Dial' }, + { name: 'Echo', value: 'Echo' }, + { name: 'Enqueue', value: 'Enqueue' }, + { name: 'Gather', value: 'Gather' }, + { name: 'Hangup', value: 'Hangup' }, + { name: 'Leave', value: 'Leave' }, + { name: 'Number', value: 'Number' }, + { name: 'Pause', value: 'Pause' }, + { name: 'Play', value: 'Play' }, + { name: 'Queue', value: 'Queue' }, + { name: 'Record', value: 'Record' }, + { name: 'Redirect', value: 'Redirect' }, + { name: 'Refer', value: 'Refer' }, + { name: 'Reject', value: 'Reject' }, + { name: 'Room', value: 'Room' }, + { name: 'Say', value: 'Say' }, + { name: 'Sip', value: 'Sip' }, + { name: 'Sms', value: 'Sms' }, + { name: 'Stream', value: 'Stream' }, + { name: 'Verto', value: 'Verto' }, + { name: 'VirtualAgent', value: 'VirtualAgent' }, + ], + }; + + return nodes; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-fields/index.js b/packages/backend/src/apps/signalwire/dynamic-fields/index.js new file mode 100644 index 00000000..d04a966b --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listNodeFields from './list-node-fields/index.js'; + +export default [listNodeFields]; diff --git a/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js b/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js new file mode 100644 index 00000000..ecb102dd --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js @@ -0,0 +1,121 @@ +export default { + name: 'List node fields', + key: 'listNodeFields', + + async run($) { + const hasChildrenNodes = $.step.parameters.hasChildrenNodes; + + if (!hasChildrenNodes) { + return []; + } + + return [ + { + label: 'Children nodes', + key: 'childrenNodes', + type: 'dynamic', + required: false, + description: 'Add or remove nested node as needed', + value: [ + { + key: 'Content-Type', + value: 'application/json', + }, + ], + fields: [ + { + label: 'Node name', + key: 'nodeName', + type: 'dropdown', + required: false, + description: 'The name of the node to be added.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlChildrenNodes', + }, + { + name: 'parameters.parentNodeName', + value: '{parameters.nodeName}', + }, + ], + }, + }, + { + label: 'Node value', + key: 'nodeValue', + type: 'string', + required: false, + description: 'The value of the node to be added.', + variables: true, + }, + { + label: 'Attributes', + key: 'attributes', + type: 'dynamic', + required: false, + description: 'Add or remove attributes for the node as needed', + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Attribute name', + key: 'key', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributes', + }, + { + name: 'parameters.nodeName', + value: '{outerScope.nodeName}', + }, + ], + }, + }, + { + label: 'Attribute value', + key: 'value', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributeValues', + }, + { + name: 'parameters.nodeName', + value: '{outerScope.nodeName}', + }, + { + name: 'parameters.attributeKey', + value: '{fieldsScope.key}', + }, + ], + }, + }, + ], + }, + ], + }, + ]; + }, +}; diff --git a/packages/backend/src/apps/signalwire/index.js b/packages/backend/src/apps/signalwire/index.js index e69761f8..35b30761 100644 --- a/packages/backend/src/apps/signalwire/index.js +++ b/packages/backend/src/apps/signalwire/index.js @@ -4,6 +4,7 @@ import auth from './auth/index.js'; import triggers from './triggers/index.js'; import actions from './actions/index.js'; import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; export default defineApp({ name: 'SignalWire', @@ -19,4 +20,5 @@ export default defineApp({ triggers, actions, dynamicData, + dynamicFields, }); diff --git a/packages/backend/src/apps/signalwire/triggers/index.js b/packages/backend/src/apps/signalwire/triggers/index.js index c7219e50..9bdc78ba 100644 --- a/packages/backend/src/apps/signalwire/triggers/index.js +++ b/packages/backend/src/apps/signalwire/triggers/index.js @@ -1,3 +1,4 @@ +import receiveCall from './receive-call/index.js'; import receiveSms from './receive-sms/index.js'; -export default [receiveSms]; +export default [receiveCall, receiveSms]; diff --git a/packages/backend/src/apps/signalwire/triggers/receive-call/index.js b/packages/backend/src/apps/signalwire/triggers/receive-call/index.js new file mode 100644 index 00000000..5ec5e0f2 --- /dev/null +++ b/packages/backend/src/apps/signalwire/triggers/receive-call/index.js @@ -0,0 +1,83 @@ +import { URLSearchParams } from 'node:url'; +import Crypto from 'node:crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Receive Call', + key: 'receiveCall', + workSynchronously: true, + type: 'webhook', + description: 'Triggers when a new call is received.', + arguments: [ + { + label: 'To Number', + key: 'phoneNumberSid', + type: 'dropdown', + required: true, + description: + 'The number to receive the call on. It should be a SignalWire number in your project.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingCallPhoneNumbers', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + + const payload = new URLSearchParams({ + VoiceUrl: $.webhookUrl, + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, + + async unregisterHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + + const payload = new URLSearchParams({ + VoiceUrl: '', + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, +}); diff --git a/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js b/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js index d71ab762..ee8465e2 100644 --- a/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js +++ b/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js @@ -20,7 +20,7 @@ export default defineTrigger({ arguments: [ { name: 'key', - value: 'listIncomingPhoneNumbers', + value: 'listIncomingSmsPhoneNumbers', }, ], }, diff --git a/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js b/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js new file mode 100644 index 00000000..30195690 --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js @@ -0,0 +1,169 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Queries a chat model.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'A list of messages comprising the conversation so far.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + description: + 'The role of the messages author. Choice between: system, user, or assistant.', + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + description: + 'The content of the message, which can either be a simple string or a structured format.', + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'A decimal number from 0-1 that determines the degree of randomness in the response. A temperature less than 1 favors more correctness and is appropriate for question answering or summarization. A value closer to 1 introduces more randomness in the output.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: 'The maximum number of tokens to generate.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'A list of string sequences that will truncate (stop) inference text output. For example, "" will stop generation as soon as the model generates the given token.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: `A percentage (also called the nucleus parameter) that's used to dynamically adjust the number of choices for each predicted token based on the cumulative probabilities. It specifies a probability threshold below which all less likely tokens are filtered out. This technique helps maintain diversity and generate more fluent and natural-sounding text.`, + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: `An integer that's used to limit the number of choices for the next predicted word or token. It specifies the maximum number of tokens to consider at each step, based on their probability of occurrence. This technique helps to speed up the generation process and can improve the quality of the generated text by focusing on the most likely options.`, + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value decreases the likelihood of repeating tokens that have already been mentioned.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value increases the likelihood of a model talking about new topics.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: nonEmptyStopSequences, + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/together-ai/actions/create-completion/index.js b/packages/backend/src/apps/together-ai/actions/create-completion/index.js new file mode 100644 index 00000000..702e5a0a --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/create-completion/index.js @@ -0,0 +1,131 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create completion', + key: 'createCompletion', + description: 'Queries a language, code, or image model.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Prompt', + key: 'prompt', + type: 'string', + required: true, + variables: true, + description: 'A string providing context for the model to complete.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'A decimal number from 0-1 that determines the degree of randomness in the response. A temperature less than 1 favors more correctness and is appropriate for question answering or summarization. A value closer to 1 introduces more randomness in the output.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: 'The maximum number of tokens to generate.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'A list of string sequences that will truncate (stop) inference text output. For example, "" will stop generation as soon as the model generates the given token.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: `A percentage (also called the nucleus parameter) that's used to dynamically adjust the number of choices for each predicted token based on the cumulative probabilities. It specifies a probability threshold below which all less likely tokens are filtered out. This technique helps maintain diversity and generate more fluent and natural-sounding text.`, + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: `An integer that's used to limit the number of choices for the next predicted word or token. It specifies the maximum number of tokens to consider at each step, based on their probability of occurrence. This technique helps to speed up the generation process and can improve the quality of the generated text by focusing on the most likely options.`, + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value decreases the likelihood of repeating tokens that have already been mentioned.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value increases the likelihood of a model talking about new topics.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const payload = { + model: $.step.parameters.model, + prompt: $.step.parameters.prompt, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: nonEmptyStopSequences, + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + }; + + const { data } = await $.http.post('/v1/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/together-ai/actions/index.js b/packages/backend/src/apps/together-ai/actions/index.js new file mode 100644 index 00000000..a8c2e257 --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/index.js @@ -0,0 +1,4 @@ +import createCompletion from './create-completion/index.js'; +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion, createCompletion]; diff --git a/packages/backend/src/apps/together-ai/assets/favicon.svg b/packages/backend/src/apps/together-ai/assets/favicon.svg new file mode 100644 index 00000000..620ac88a --- /dev/null +++ b/packages/backend/src/apps/together-ai/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/together-ai/auth/index.js b/packages/backend/src/apps/together-ai/auth/index.js new file mode 100644 index 00000000..4765e6ef --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Together AI API key of your account.', + docUrl: 'https://automatisch.io/docs/together-ai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/together-ai/auth/is-still-verified.js b/packages/backend/src/apps/together-ai/auth/is-still-verified.js new file mode 100644 index 00000000..3e6c9095 --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/together-ai/auth/verify-credentials.js b/packages/backend/src/apps/together-ai/auth/verify-credentials.js new file mode 100644 index 00000000..7f43f884 --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/together-ai/common/add-auth-header.js b/packages/backend/src/apps/together-ai/common/add-auth-header.js new file mode 100644 index 00000000..f9f5acba --- /dev/null +++ b/packages/backend/src/apps/together-ai/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/together-ai/dynamic-data/index.js b/packages/backend/src/apps/together-ai/dynamic-data/index.js new file mode 100644 index 00000000..6db48046 --- /dev/null +++ b/packages/backend/src/apps/together-ai/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js b/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js new file mode 100644 index 00000000..28532967 --- /dev/null +++ b/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const { data } = await $.http.get('/v1/models'); + + const models = data.map((model) => { + return { + value: model.id, + name: model.display_name, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/together-ai/index.js b/packages/backend/src/apps/together-ai/index.js new file mode 100644 index 00000000..efddacc2 --- /dev/null +++ b/packages/backend/src/apps/together-ai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Together AI', + key: 'together-ai', + baseUrl: 'https://together.ai', + apiBaseUrl: 'https://api.together.xyz', + iconUrl: '{BASE_URL}/apps/together-ai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/together-ai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js index d5224ca1..eac75b1a 100644 --- a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js +++ b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js @@ -35,9 +35,6 @@ export default defineTrigger({ }, ], - useSingletonWebhook: true, - singletonWebhookRefValueParameter: 'phoneNumberSid', - async run($) { const dataItem = { raw: $.request.body, diff --git a/packages/backend/src/apps/virtualq/actions/create-waiter/index.js b/packages/backend/src/apps/virtualq/actions/create-waiter/index.js new file mode 100644 index 00000000..af22d2a5 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/create-waiter/index.js @@ -0,0 +1,134 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create waiter', + key: 'createWaiter', + description: 'Enqueues a waiter to the line with the selected line.', + arguments: [ + { + label: 'Line', + key: 'lineId', + type: 'dropdown', + required: true, + variables: true, + description: 'The line to join', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLines', + }, + ], + }, + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: true, + variables: true, + description: + "The caller's phone number including country code (for example +4017111112233)", + }, + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + description: + 'Option describing if the waiter expects a callback or will receive a text message', + required: true, + variables: true, + options: [ + { label: 'Call back', value: 'CallBack' }, + { label: 'Call in', value: 'CallIn' }, + ], + }, + { + label: 'Source', + key: 'source', + type: 'dropdown', + description: 'Option describing the source where the caller came from', + required: true, + variables: true, + options: [ + { label: 'Widget', value: 'Widget' }, + { label: 'Phone', value: 'Phone' }, + { label: 'Mobile', value: 'Mobile' }, + { label: 'App', value: 'App' }, + { label: 'Other', value: 'Other' }, + ], + }, + { + label: 'Appointment', + key: 'appointment', + type: 'dropdown', + required: true, + variables: true, + value: false, + description: + 'If set to true, then this marks this as an appointment. If appointment_time is set, this is automatically set to true.', + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listAppointmentFields', + }, + { + name: 'parameters.appointment', + value: '{parameters.appointment}', + }, + ], + }, + }, + { + label: 'Service phone to call', + key: 'servicePhoneToCall', + type: 'string', + description: + "If set, callback uses this number instead of the line's service phone number", + required: false, + variables: true, + }, + ], + async run($) { + const { + lineId, + phone, + channel, + source, + appointment, + appointmentTime, + servicePhoneToCall, + } = $.step.parameters; + + const body = { + data: { + type: 'waiters', + attributes: { + line_id: lineId, + phone, + channel, + source, + appointment, + servicePhoneToCall, + }, + }, + }; + + if (appointment) { + body.data.attributes.appointmentTime = appointmentTime; + } + + const { data } = await $.http.post('/v2/waiters', body); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js b/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js new file mode 100644 index 00000000..3922046d --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js @@ -0,0 +1,34 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delete waiter', + key: 'deleteWaiter', + description: + 'Cancels waiting. The provided waiter will be removed from the queue.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + ], + async run($) { + const waiterId = $.step.parameters.waiterId; + + const { data } = await $.http.delete(`/v2/waiters/${waiterId}`); + + $.setActionItem({ raw: { output: data } }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/index.js b/packages/backend/src/apps/virtualq/actions/index.js new file mode 100644 index 00000000..96dd545e --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/index.js @@ -0,0 +1,6 @@ +import createWaiter from './create-waiter/index.js'; +import deleteWaiter from './delete-waiter/index.js'; +import showWaiter from './show-waiter/index.js'; +import updateWaiter from './update-waiter/index.js'; + +export default [createWaiter, deleteWaiter, showWaiter, updateWaiter]; diff --git a/packages/backend/src/apps/virtualq/actions/show-waiter/index.js b/packages/backend/src/apps/virtualq/actions/show-waiter/index.js new file mode 100644 index 00000000..1166a183 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/show-waiter/index.js @@ -0,0 +1,33 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Show waiter', + key: 'showWaiter', + description: 'Returns the complete waiter information.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + ], + async run($) { + const waiterId = $.step.parameters.waiterId; + + const { data } = await $.http.get(`/v2/waiters/${waiterId}`); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/update-waiter/index.js b/packages/backend/src/apps/virtualq/actions/update-waiter/index.js new file mode 100644 index 00000000..51f9d8b4 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/update-waiter/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Update waiter', + key: 'updateWaiter', + description: 'Updates a waiter to the line with the selected line.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + { + label: 'Line', + key: 'lineId', + type: 'dropdown', + required: false, + variables: true, + description: 'Used to find caller if 0 is used for waiter field', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLines', + }, + ], + }, + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: false, + variables: true, + description: 'Used to find caller if 0 is used for waiter field', + }, + { + label: 'EWT', + key: 'serviceWaiterEwt', + type: 'string', + description: 'EWT as calculated by the service', + required: false, + variables: true, + }, + { + label: 'State', + key: 'serviceWaiterState', + type: 'dropdown', + description: 'State of caller in the call center', + required: false, + variables: true, + options: [ + { label: 'Waiting', value: 'Waiting' }, + { label: 'Connected', value: 'Connected' }, + { label: 'Transferred', value: 'Transferred' }, + { label: 'Timeout', value: 'Timeout' }, + { label: 'Canceled', value: 'Canceled' }, + ], + }, + { + label: 'Wait time', + key: 'waitTimeWhenUp', + type: 'string', + description: 'Wait time in seconds before being transferred to agent', + required: false, + variables: true, + }, + { + label: 'Talk time', + key: 'talkTime', + type: 'string', + description: 'Time in seconds spent talking with Agent', + required: false, + variables: true, + }, + { + label: 'Agent', + key: 'agentId', + type: 'string', + description: 'Agent where call was transferred to', + required: false, + variables: true, + }, + { + label: 'Service phone to call', + key: 'servicePhoneToCall', + type: 'string', + description: + "If set, callback uses this number instead of the line's service phone number", + required: false, + variables: true, + }, + ], + + async run($) { + const { + waiterId, + lineId, + phone, + serviceWaiterEwt, + serviceWaiterState, + waitTimeWhenUp, + talkTime, + agentId, + servicePhoneToCall, + } = $.step.parameters; + + const body = { + data: { + type: 'waiters', + attributes: { + line_id: lineId, + phone, + service_phone_to_call: servicePhoneToCall, + }, + }, + }; + + if (serviceWaiterEwt) { + body.data.attributes.service_waiter_ewt = serviceWaiterEwt; + } + + if (serviceWaiterState) { + body.data.attributes.service_waiter_state = serviceWaiterState; + } + + if (talkTime) { + body.data.attributes.talk_time = talkTime; + } + + if (agentId) { + body.data.attributes.agent_id = agentId; + } + + if (waitTimeWhenUp) { + body.data.attributes.wait_time_when_up = waitTimeWhenUp; + } + + const { data } = await $.http.put(`/v2/waiters/${waiterId}`, body); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/assets/favicon.svg b/packages/backend/src/apps/virtualq/assets/favicon.svg new file mode 100644 index 00000000..41162b4d --- /dev/null +++ b/packages/backend/src/apps/virtualq/assets/favicon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/virtualq/auth/index.js b/packages/backend/src/apps/virtualq/auth/index.js new file mode 100644 index 00000000..41659238 --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/index.js @@ -0,0 +1,21 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API key of the VirtualQ API service.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/virtualq/auth/is-still-verified.js b/packages/backend/src/apps/virtualq/auth/is-still-verified.js new file mode 100644 index 00000000..6663679a --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/virtualq/auth/verify-credentials.js b/packages/backend/src/apps/virtualq/auth/verify-credentials.js new file mode 100644 index 00000000..1899f9e9 --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/verify-credentials.js @@ -0,0 +1,14 @@ +const verifyCredentials = async ($) => { + const response = await $.http.get('/v2/call_centers'); + + const callCenterNames = response.data.data + .map((callCenter) => callCenter.attributes.name) + .join(' - '); + + await $.auth.set({ + screenName: callCenterNames, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/virtualq/common/add-auth-header.js b/packages/backend/src/apps/virtualq/common/add-auth-header.js new file mode 100644 index 00000000..91d34fe8 --- /dev/null +++ b/packages/backend/src/apps/virtualq/common/add-auth-header.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-API-Key'] = $.auth.data.apiKey; + } + + if (requestConfig.method === 'post' || requestConfig.method === 'put') { + requestConfig.headers['Content-Type'] = 'application/vnd.api+json'; + } + + requestConfig.headers['X-Keys-Format'] = 'underscore'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/index.js b/packages/backend/src/apps/virtualq/dynamic-data/index.js new file mode 100644 index 00000000..e8aed5d3 --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listLines from './list-lines/index.js'; +import listWaiters from './list-waiters/index.js'; + +export default [listLines, listWaiters]; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js b/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js new file mode 100644 index 00000000..de2cf230 --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js @@ -0,0 +1,15 @@ +export default { + name: 'List lines', + key: 'listLines', + + async run($) { + const response = await $.http.get('/v2/lines'); + + const lines = response.data.data.map((line) => ({ + value: line.id, + name: line.attributes.name, + })); + + return { data: lines }; + }, +}; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js b/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js new file mode 100644 index 00000000..03aa1eb5 --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js @@ -0,0 +1,15 @@ +export default { + name: 'List waiters', + key: 'listWaiters', + + async run($) { + const response = await $.http.get('/v2/waiters'); + + const waiters = response.data.data.map((waiter) => ({ + value: waiter.id, + name: `${waiter.attributes.phone} @ ${waiter.attributes.line.name}`, + })); + + return { data: waiters }; + }, +}; diff --git a/packages/backend/src/apps/virtualq/dynamic-fields/index.js b/packages/backend/src/apps/virtualq/dynamic-fields/index.js new file mode 100644 index 00000000..9473451b --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listAppointmentFields from './list-appointment-fields/index.js'; + +export default [listAppointmentFields]; diff --git a/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js b/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js new file mode 100644 index 00000000..8ce2a3ca --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js @@ -0,0 +1,20 @@ +export default { + name: 'List appointment fields', + key: 'listAppointmentFields', + + async run($) { + if ($.step.parameters.appointment) { + return [ + { + label: 'Appointment Time', + key: 'appointmentTime', + type: 'string', + required: true, + variables: true, + description: + 'Overrides the estimated up time with this time. Specify number of seconds since 1970 UTC.', + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/virtualq/index.js b/packages/backend/src/apps/virtualq/index.js new file mode 100644 index 00000000..09ae740a --- /dev/null +++ b/packages/backend/src/apps/virtualq/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'VirtualQ', + key: 'virtualq', + iconUrl: '{BASE_URL}/apps/virtualq/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/virtualq/connection', + supportsConnections: true, + baseUrl: 'https://www.virtualq.io', + apiBaseUrl: 'https://api.virtualq.io/api/', + primaryColor: '#2E3D59', + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js index ffba9257..0120e2af 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js @@ -6,9 +6,9 @@ export default async (request, response) => { .findOne({ key: request.params.appKey }) .throwIfNotFound(); - const oauthClient = await appConfig - .$relatedQuery('oauthClients') - .insert(oauthClientParams(request)); + const oauthClient = await appConfig.createOAuthClient( + oauthClientParams(request) + ); renderObject(response, oauthClient, { status: 201 }); }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js index 4746a881..8f1eedde 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js @@ -48,6 +48,34 @@ describe('POST /api/v1/admin/apps/:appKey/oauth-clients', () => { expect(response.body).toMatchObject(expectedPayload); }); + it('should throw validation error for app that does not support oauth connections', async () => { + await createAppConfig({ + key: 'deepl', + }); + + const oauthClient = { + active: true, + appKey: 'deepl', + name: 'First auth client', + formattedAuthDefaults: { + clientid: 'sample client ID', + clientSecret: 'sample client secret', + instanceUrl: 'https://deepl.com', + oAuthRedirectUrl: 'http://localhost:3001/app/deepl/connection/add', + }, + }; + + const response = await request(app) + .post('/api/v1/admin/apps/deepl/oauth-clients') + .set('Authorization', token) + .send(oauthClient) + .expect(422); + + expect(response.body.errors).toMatchObject({ + app: ['This app does not support OAuth clients!'], + }); + }); + it('should return not found response for not existing app config', async () => { const oauthClient = { active: true, diff --git a/packages/backend/src/controllers/api/v1/apps/get-flows.js b/packages/backend/src/controllers/api/v1/apps/get-flows.js index 6554365e..3fa79a7c 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-flows.js +++ b/packages/backend/src/controllers/api/v1/apps/get-flows.js @@ -7,6 +7,7 @@ export default async (request, response) => { const flowsQuery = request.currentUser.authorizedFlows .clone() + .distinct('flows.*') .joinRelated({ steps: true, }) diff --git a/packages/backend/src/controllers/api/v1/connections/get-flows.js b/packages/backend/src/controllers/api/v1/connections/get-flows.js index ade34b23..b48a80af 100644 --- a/packages/backend/src/controllers/api/v1/connections/get-flows.js +++ b/packages/backend/src/controllers/api/v1/connections/get-flows.js @@ -4,6 +4,7 @@ import paginateRest from '../../../../helpers/pagination-rest.js'; export default async (request, response) => { const flowsQuery = request.currentUser.authorizedFlows .clone() + .distinct('flows.*') .joinRelated({ steps: true, }) diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 00000000..5a1faac9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const exportedFlow = await flow.export(); + + return renderObject(response, exportedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 00000000..add5ae12 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js'; + +describe('POST /api/v1/flows/:flowId/export', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should export the flow data of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should export the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.js b/packages/backend/src/controllers/api/v1/flows/import-flow.js new file mode 100644 index 00000000..c64d0f9e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.js @@ -0,0 +1,29 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import importFlow from '../../../../helpers/import-flow.js'; + +export default async function importFlowController(request, response) { + const flow = await importFlow( + request.currentUser, + flowParams(request), + response + ); + + return renderObject(response, flow, { status: 201 }); +} + +const flowParams = (request) => { + return { + id: request.body.id, + name: request.body.name, + steps: request.body.steps.map((step) => ({ + id: step.id, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: step.parameters, + position: step.position, + webhookPath: step.webhookPath, + })), + }; +}; diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.test.js b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js new file mode 100644 index 00000000..2915c485 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js @@ -0,0 +1,355 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import importFlowMock from '../../../../../test/mocks/rest/api/v1/flows/import-flow.js'; + +describe('POST /api/v1/flows/import', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should import the flow data', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const expectedPayload = await importFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should have correct parameters of the steps', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerParameters = response.body.data.steps[0].parameters; + const newActionParameters = response.body.data.steps[1].parameters; + const newTriggerStepId = response.body.data.steps[0].id; + + expect(newTriggerParameters).toMatchObject({ + workSynchronously: true, + }); + + expect(newActionParameters).toMatchObject({ + input: `hello {{step.${newTriggerStepId}.query.sample}} world`, + transform: 'capitalize', + }); + }); + + it('should have the new flow id in the new webhook url', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newWebhookUrl = response.body.data.steps[0].webhookUrl; + + expect(newWebhookUrl).toContain(`/webhooks/flows/${response.body.data.id}`); + }); + + it('should have the first step id in the input parameter of the second step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerStepId = response.body.data.steps[0].id; + const newActionStepInputParameter = + response.body.data.steps[1].parameters.input; + + expect(newActionStepInputParameter).toContain( + `{{step.${newTriggerStepId}.query.sample}}` + ); + }); + + it('should throw an error in case there is no trigger step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(422); + + expect(response.body.errors.steps).toStrictEqual([ + 'The first step must be a trigger!', + ]); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js index 90cb4d8f..3bb2d8a0 100644 --- a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js @@ -5,7 +5,6 @@ export default async (request, response) => { .clone() .where('steps.id', request.params.stepId) .whereNotNull('steps.app_key') - .whereNotNull('steps.connection_id') .first() .throwIfNotFound(); diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.js b/packages/backend/src/controllers/api/v1/steps/update-step.js index c707726d..70f0b98f 100644 --- a/packages/backend/src/controllers/api/v1/steps/update-step.js +++ b/packages/backend/src/controllers/api/v1/steps/update-step.js @@ -11,12 +11,13 @@ export default async (request, response) => { }; const stepParams = (request) => { - const { connectionId, appKey, key, parameters } = request.body; + const { connectionId, appKey, key, name, parameters } = request.body; return { connectionId, appKey, key, + name, parameters, }; }; diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.test.js b/packages/backend/src/controllers/api/v1/steps/update-step.test.js index 1a5ae1b9..c219dee0 100644 --- a/packages/backend/src/controllers/api/v1/steps/update-step.test.js +++ b/packages/backend/src/controllers/api/v1/steps/update-step.test.js @@ -35,6 +35,7 @@ describe('PATCH /api/v1/steps/:stepId', () => { connectionId: currentUserConnection.id, appKey: 'deepl', key: 'translateText', + name: 'Translate text', }); await createPermission({ @@ -58,6 +59,7 @@ describe('PATCH /api/v1/steps/:stepId', () => { parameters: { text: 'Hello world!', targetLanguage: 'de', + name: 'Translate text - Updated step name', }, }) .expect(200); diff --git a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js b/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js deleted file mode 100644 index 2f5c611f..00000000 --- a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js +++ /dev/null @@ -1,38 +0,0 @@ -import path from 'node:path'; - -import Connection from '../../models/connection.js'; -import logger from '../../helpers/logger.js'; -import handler from '../../helpers/webhook-handler.js'; - -export default async (request, response) => { - const computedRequestPayload = { - headers: request.headers, - body: request.body, - query: request.query, - params: request.params, - }; - logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`); - logger.debug(JSON.stringify(computedRequestPayload, null, 2)); - - const { connectionId } = request.params; - - const connection = await Connection.query() - .findById(connectionId) - .throwIfNotFound(); - - if (!(await connection.verifyWebhook(request))) { - return response.sendStatus(401); - } - - const triggerSteps = await connection - .$relatedQuery('triggerSteps') - .where('webhook_path', path.join(request.baseUrl, request.path)); - - if (triggerSteps.length === 0) return response.sendStatus(404); - - for (const triggerStep of triggerSteps) { - await handler(triggerStep.flowId, request, response); - } - - response.sendStatus(204); -}; diff --git a/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js new file mode 100644 index 00000000..bde4d6c5 --- /dev/null +++ b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js @@ -0,0 +1,26 @@ +import toLower from 'lodash/toLower.js'; +import startCase from 'lodash/startCase.js'; +import upperFirst from 'lodash/upperFirst.js'; + +export async function up(knex) { + await knex.schema.table('steps', function (table) { + table.string('name'); + }); + + const rows = await knex('steps').select('id', 'key'); + + const updates = rows.map((row) => { + if (!row.key) return; + + const humanizedKey = upperFirst(toLower(startCase(row.key))); + return knex('steps').where({ id: row.id }).update({ name: humanizedKey }); + }); + + return await Promise.all(updates); +} + +export async function down(knex) { + return knex.schema.table('steps', function (table) { + table.dropColumn('name'); + }); +} diff --git a/packages/backend/src/db/migrations/20250124105728_create_folders.js b/packages/backend/src/db/migrations/20250124105728_create_folders.js new file mode 100644 index 00000000..60a9f784 --- /dev/null +++ b/packages/backend/src/db/migrations/20250124105728_create_folders.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('folders', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name'); + table.uuid('user_id').references('id').inTable('users'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('folders'); +} diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index c9f6329f..e9f6fb0e 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -113,6 +113,14 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/flows/import': { + action: 'create', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/define-trigger.js b/packages/backend/src/helpers/define-trigger.js index 22ff34ab..8fde89e6 100644 --- a/packages/backend/src/helpers/define-trigger.js +++ b/packages/backend/src/helpers/define-trigger.js @@ -5,6 +5,7 @@ export default function defineTrigger(triggerDefinition) { triggerDefinition.pollInterval || triggerDefinition.type === 'webhook'; const schedulerTriggers = [ + 'everyNMinutes', 'everyHour', 'everyDay', 'everyWeek', diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js new file mode 100644 index 00000000..05b238dc --- /dev/null +++ b/packages/backend/src/helpers/export-flow.js @@ -0,0 +1,45 @@ +import Crypto from 'crypto'; + +const exportFlow = async (flow) => { + const steps = await flow.$relatedQuery('steps'); + + const newFlowId = Crypto.randomUUID(); + const stepIdMap = Object.fromEntries( + steps.map((step) => [step.id, Crypto.randomUUID()]) + ); + + const exportedFlow = { + id: newFlowId, + name: flow.name, + steps: steps.map((step) => ({ + id: stepIdMap[step.id], + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: updateParameters(step.parameters, stepIdMap), + position: step.position, + webhookPath: step.webhookPath?.replace(flow.id, newFlowId), + })), + }; + + return exportedFlow; +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default exportFlow; diff --git a/packages/backend/src/helpers/get-app.js b/packages/backend/src/helpers/get-app.js index 395e761b..5e23fb18 100644 --- a/packages/backend/src/helpers/get-app.js +++ b/packages/backend/src/helpers/get-app.js @@ -74,7 +74,7 @@ const addStaticSubsteps = (stepType, appData, step) => { computedStep.substeps = []; - if (appData.supportsConnections) { + if (appData.supportsConnections && step.supportsConnections !== false) { computedStep.substeps.push(chooseConnectionStep); } diff --git a/packages/backend/src/helpers/import-flow.js b/packages/backend/src/helpers/import-flow.js new file mode 100644 index 00000000..fe60957b --- /dev/null +++ b/packages/backend/src/helpers/import-flow.js @@ -0,0 +1,75 @@ +import Crypto from 'crypto'; +import Step from '../models/step.js'; +import { renderObjectionError } from './renderer.js'; + +const importFlow = async (user, flowData, response) => { + const steps = flowData.steps || []; + + // Validation: the first step must be a trigger + if (!steps.length || steps[0].type !== 'trigger') { + return renderObjectionError(response, { + statusCode: 422, + type: 'ValidationError', + data: { + steps: [{ message: 'The first step must be a trigger!' }], + }, + }); + } + + const newFlowId = Crypto.randomUUID(); + + const newFlow = await user.$relatedQuery('flows').insertAndFetch({ + id: newFlowId, + name: flowData.name, + active: false, + }); + + const stepIdMap = {}; + + // Generate new step IDs and insert steps without parameters + for (const step of steps) { + const newStepId = Crypto.randomUUID(); + stepIdMap[step.id] = newStepId; + + await Step.query().insert({ + id: newStepId, + flowId: newFlowId, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: {}, + position: step.position, + webhookPath: step.webhookPath?.replace(flowData.id, newFlowId), + }); + } + + // Update steps with correct parameters + for (const step of steps) { + const newStepId = stepIdMap[step.id]; + + await Step.query().patchAndFetchById(newStepId, { + parameters: updateParameters(step.parameters, stepIdMap), + }); + } + + return await newFlow.$query().withGraphFetched('steps'); +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default importFlow; diff --git a/packages/backend/src/helpers/webhook-handler-sync.js b/packages/backend/src/helpers/webhook-handler-sync.js index 9f33f121..8b20990a 100644 --- a/packages/backend/src/helpers/webhook-handler-sync.js +++ b/packages/backend/src/helpers/webhook-handler-sync.js @@ -82,7 +82,11 @@ export default async (flowId, request, response) => { break; } - if (actionStep.key === 'respondWith' && !response.headersSent) { + if ( + (actionStep.key === 'respondWith' || + actionStep.key === 'respondWithVoiceXml') && + !response.headersSent + ) { const { headers, statusCode, body } = actionExecutionStep.dataOut; // we set the custom response headers diff --git a/packages/backend/src/models/__snapshots__/app.test.js.snap b/packages/backend/src/models/__snapshots__/app.test.js.snap index 0ada15b4..18b951e1 100644 --- a/packages/backend/src/models/__snapshots__/app.test.js.snap +++ b/packages/backend/src/models/__snapshots__/app.test.js.snap @@ -3,6 +3,7 @@ exports[`App model > list should have list of applications keys 1`] = ` [ "airtable", + "anthropic", "appwrite", "azure-openai", "carbone", @@ -19,6 +20,7 @@ exports[`App model > list should have list of applications keys 1`] = ` "flickr", "flowers-software", "formatter", + "freescout", "ghost", "github", "gitlab", @@ -36,10 +38,13 @@ exports[`App model > list should have list of applications keys 1`] = ` "mailerlite", "mattermost", "miro", + "mistral-ai", "notion", "ntfy", "odoo", "openai", + "openrouter", + "perplexity", "pipedrive", "placetel", "postgresql", @@ -58,10 +63,12 @@ exports[`App model > list should have list of applications keys 1`] = ` "stripe", "telegram-bot", "todoist", + "together-ai", "trello", "twilio", "twitter", "typeform", + "virtualq", "vtiger-crm", "webhook", "wordpress", diff --git a/packages/backend/src/models/__snapshots__/folder.test.js.snap b/packages/backend/src/models/__snapshots__/folder.test.js.snap new file mode 100644 index 00000000..ede97dfe --- /dev/null +++ b/packages/backend/src/models/__snapshots__/folder.test.js.snap @@ -0,0 +1,27 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Folder model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/step.test.js.snap b/packages/backend/src/models/__snapshots__/step.test.js.snap index aa78645e..d9a45102 100644 --- a/packages/backend/src/models/__snapshots__/step.test.js.snap +++ b/packages/backend/src/models/__snapshots__/step.test.js.snap @@ -38,6 +38,14 @@ exports[`Step model > jsonSchema should have correct validations 1`] = ` "null", ], }, + "name": { + "maxLength": 255, + "minLength": 1, + "type": [ + "string", + "null", + ], + }, "parameters": { "type": "object", }, diff --git a/packages/backend/src/models/app-config.js b/packages/backend/src/models/app-config.js index fe7e2d44..c34a0ac4 100644 --- a/packages/backend/src/models/app-config.js +++ b/packages/backend/src/models/app-config.js @@ -1,6 +1,7 @@ import App from './app.js'; import OAuthClient from './oauth-client.js'; import Base from './base.js'; +import { ValidationError } from 'objection'; class AppConfig extends Base { static tableName = 'app_configs'; @@ -39,6 +40,27 @@ class AppConfig extends Base { return await App.findOneByKey(this.key); } + + async createOAuthClient(params) { + const supportsOauthClients = (await this.getApp())?.auth?.generateAuthUrl + ? true + : false; + + if (!supportsOauthClients) { + throw new ValidationError({ + data: { + app: [ + { + message: 'This app does not support OAuth clients!', + }, + ], + }, + type: 'ModelValidation', + }); + } + + return await this.$relatedQuery('oauthClients').insert(params); + } } export default AppConfig; diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 56744396..22be9030 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -7,6 +7,7 @@ import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; import flowQueue from '../queues/flow.js'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, @@ -426,6 +427,10 @@ class Flow extends Base { } } + async export() { + return await exportFlow(this); + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 7faefa17..cbaae474 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; import { createExecutionStep } from '../../test/factories/execution-step.js'; +import * as exportFlow from '../helpers/export-flow.js'; describe('Flow model', () => { it('tableName should return correct name', () => { @@ -506,6 +507,22 @@ describe('Flow model', () => { }); }); + describe('export', () => { + it('should return exportedFlow', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + + expect(await flow.export()).toStrictEqual({ + name: 'My Flow Name', + }); + }); + }); + describe('throwIfHavingLessThanTwoSteps', () => { it('should throw validation error with less than two steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/models/folder.js b/packages/backend/src/models/folder.js new file mode 100644 index 00000000..96eec5ef --- /dev/null +++ b/packages/backend/src/models/folder.js @@ -0,0 +1,30 @@ +import Base from './base.js'; +import User from './user.js'; + +class Folder extends Base { + static tableName = 'folders'; + + static jsonSchema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + userId: { type: 'string', format: 'uuid' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'folders.user_id', + to: 'users.id', + }, + }, + }); +} + +export default Folder; diff --git a/packages/backend/src/models/folder.test.js b/packages/backend/src/models/folder.test.js new file mode 100644 index 00000000..3fada770 --- /dev/null +++ b/packages/backend/src/models/folder.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import Folder from './folder'; +import User from './user'; +import Base from './base'; + +describe('Folder model', () => { + it('tableName should return correct name', () => { + expect(Folder.tableName).toBe('folders'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Folder.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = Folder.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'folders.user_id', + to: 'users.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); +}); diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js index 9f5f3f70..3fe35dbb 100644 --- a/packages/backend/src/models/step.js +++ b/packages/backend/src/models/step.js @@ -1,5 +1,4 @@ import { URL } from 'node:url'; -import get from 'lodash.get'; import Base from './base.js'; import App from './app.js'; import Flow from './flow.js'; @@ -22,6 +21,7 @@ class Step extends Base { id: { type: 'string', format: 'uuid' }, flowId: { type: 'string', format: 'uuid' }, key: { type: ['string', 'null'] }, + name: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, appKey: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, type: { type: 'string', enum: ['action', 'trigger'] }, connectionId: { type: ['string', 'null'], format: 'uuid' }, @@ -108,29 +108,18 @@ class Step extends Base { if (!triggerCommand) return null; - const { useSingletonWebhook, singletonWebhookRefValueParameter, type } = - triggerCommand; - - const isWebhook = type === 'webhook'; + const isWebhook = triggerCommand.type === 'webhook'; if (!isWebhook) return null; - if (singletonWebhookRefValueParameter) { - const parameterValue = get( - this.parameters, - singletonWebhookRefValueParameter - ); - return `/webhooks/connections/${this.connectionId}/${parameterValue}`; - } - - if (useSingletonWebhook) { - return `/webhooks/connections/${this.connectionId}`; - } - if (this.parameters.workSynchronously) { return `/webhooks/flows/${this.flowId}/sync`; } + if (triggerCommand.workSynchronously) { + return `/webhooks/flows/${this.flowId}/sync`; + } + return `/webhooks/flows/${this.flowId}`; } @@ -314,7 +303,13 @@ class Step extends Base { } async updateFor(user, newStepData) { - const { appKey = this.appKey, connectionId, key, parameters } = newStepData; + const { + appKey = this.appKey, + name, + connectionId, + key, + parameters, + } = newStepData; if (connectionId && appKey) { await user.authorizedConnections @@ -335,6 +330,7 @@ class Step extends Base { const updatedStep = await this.$query().patchAndFetch({ key, + name, appKey, connectionId: connectionId, parameters: parameters, diff --git a/packages/backend/src/models/step.test.js b/packages/backend/src/models/step.test.js index f8ece82e..56607201 100644 --- a/packages/backend/src/models/step.test.js +++ b/packages/backend/src/models/step.test.js @@ -151,7 +151,62 @@ describe('Step model', () => { expect(step.isAction).toBe(true); }); - describe.todo('computeWebhookPath'); + describe('computeWebhookPath', () => { + it('should return null if step type is action', async () => { + const step = new Step(); + step.type = 'action'; + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return null if triggerCommand is not found', async () => { + const step = new Step(); + step.type = 'trigger'; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue(null); + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return null if triggerCommand type is not webhook', async () => { + const step = new Step(); + step.type = 'trigger'; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'not-webhook', + }); + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return synchronous webhook path if workSynchronously is true', async () => { + const step = new Step(); + step.type = 'trigger'; + step.flowId = 'flow-id'; + step.parameters = { workSynchronously: true }; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + }); + + expect(await step.computeWebhookPath()).toBe( + '/webhooks/flows/flow-id/sync' + ); + }); + + it('should return asynchronous webhook path if workSynchronously is false', async () => { + const step = new Step(); + step.type = 'trigger'; + step.flowId = 'flow-id'; + step.parameters = { workSynchronously: false }; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + }); + + expect(await step.computeWebhookPath()).toBe('/webhooks/flows/flow-id'); + }); + }); describe('getWebhookUrl', () => { it('should return absolute webhook URL when step type is trigger', async () => { @@ -314,7 +369,46 @@ describe('Step model', () => { it.todo('getSetupAndDynamicFields'); it.todo('createDynamicFields'); it.todo('createDynamicData'); - it.todo('updateWebhookUrl'); + + describe('updateWebhookUrl', () => { + it('should do nothing if step is an action', async () => { + const step = new Step(); + step.type = 'action'; + + await step.updateWebhookUrl(); + + expect(step.webhookUrl).toBeNull(); + }); + + it('should set webhookPath if step is a trigger', async () => { + const step = await createStep({ + type: 'trigger', + }); + + vi.spyOn(Step.prototype, 'computeWebhookPath').mockResolvedValue( + '/webhooks/flows/flow-id' + ); + + const newStep = await step.updateWebhookUrl(); + + expect(step.webhookPath).toBe('/webhooks/flows/flow-id'); + expect(newStep).toBe(step); + }); + + it('should return step itself after the update of webhook path', async () => { + const step = await createStep({ + type: 'trigger', + }); + + vi.spyOn(Step.prototype, 'computeWebhookPath').mockResolvedValue( + '/webhooks/flows/flow-id' + ); + + const updatedStep = await step.updateWebhookUrl(); + + expect(updatedStep).toStrictEqual(step); + }); + }); describe('delete', () => { it('should delete the step and align the positions', async () => { diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 8b507b82..3b7b5159 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -9,6 +9,8 @@ import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; +import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; +import importFlowAction from '../../../controllers/api/v1/flows/import-flow.js'; const router = Router(); @@ -17,6 +19,15 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.post( + '/:flowId/export', + authenticateUser, + authorizeUser, + exportFlowAction +); + +router.post('/import', authenticateUser, authorizeUser, importFlowAction); + router.patch( '/:flowId/status', authenticateUser, diff --git a/packages/backend/src/routes/webhooks.js b/packages/backend/src/routes/webhooks.js index 98cadef0..cd2f359b 100644 --- a/packages/backend/src/routes/webhooks.js +++ b/packages/backend/src/routes/webhooks.js @@ -4,7 +4,6 @@ import multer from 'multer'; import appConfig from '../config/app.js'; import webhookHandlerByFlowId from '../controllers/webhooks/handler-by-flow-id.js'; import webhookHandlerSyncByFlowId from '../controllers/webhooks/handler-sync-by-flow-id.js'; -import webhookHandlerByConnectionIdAndRefValue from '../controllers/webhooks/handler-by-connection-id-and-ref-value.js'; const router = Router(); const upload = multer(); @@ -39,14 +38,6 @@ function createRouteHandler(path, handler) { .post(wrappedHandler); } -createRouteHandler( - '/connections/:connectionId/:refValue', - webhookHandlerByConnectionIdAndRefValue -); -createRouteHandler( - '/connections/:connectionId', - webhookHandlerByConnectionIdAndRefValue -); createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId); createRouteHandler('/flows/:flowId', webhookHandlerByFlowId); createRouteHandler('/:flowId', webhookHandlerByFlowId); diff --git a/packages/backend/src/serializers/step.js b/packages/backend/src/serializers/step.js index f5ae1c26..5c1e0d38 100644 --- a/packages/backend/src/serializers/step.js +++ b/packages/backend/src/serializers/step.js @@ -5,6 +5,7 @@ const stepSerializer = (step) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/src/serializers/step.test.js b/packages/backend/src/serializers/step.test.js index dfeffbeb..2b26cfcf 100644 --- a/packages/backend/src/serializers/step.test.js +++ b/packages/backend/src/serializers/step.test.js @@ -16,6 +16,7 @@ describe('stepSerializer', () => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/src/workers/trigger.js b/packages/backend/src/workers/trigger.js index e25915fc..50061172 100644 --- a/packages/backend/src/workers/trigger.js +++ b/packages/backend/src/workers/trigger.js @@ -1,6 +1,6 @@ import { generateWorker } from './worker.js'; import { executeTriggerJob } from '../jobs/execute-trigger.js'; -const triggerWorker = generateWorker('flow', executeTriggerJob); +const triggerWorker = generateWorker('trigger', executeTriggerJob); export default triggerWorker; diff --git a/packages/backend/test/factories/step.js b/packages/backend/test/factories/step.js index 15573ae2..a52c9d49 100644 --- a/packages/backend/test/factories/step.js +++ b/packages/backend/test/factories/step.js @@ -16,8 +16,11 @@ export const createStep = async (params = {}) => { params?.position || (lastStep?.position ? lastStep.position + 1 : 1); params.status = params?.status || 'completed'; - params.appKey = - params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook'); + + if (params.appKey !== null) { + params.appKey = + params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook'); + } params.parameters = params?.parameters || {}; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js index f7b50194..f694f70a 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js @@ -14,6 +14,7 @@ const getExecutionStepsMock = async (executionSteps, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js index 3957e9d8..61feddd8 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js @@ -15,6 +15,7 @@ const getExecutionMock = async (execution, flow, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js index 21d36376..b194bee2 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js @@ -16,6 +16,7 @@ const getExecutionsMock = async (executions, flow, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js index 67684191..36426774 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js @@ -14,6 +14,7 @@ const duplicateFlowMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js new file mode 100644 index 00000000..c7a1ef6e --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,41 @@ +import { expect } from 'vitest'; + +const exportFlowMock = async (flow, steps = []) => { + const data = { + id: expect.any(String), + name: flow.name, + }; + + if (steps.length) { + data.steps = steps.map((step) => { + const computedStep = { + id: expect.any(String), + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: expect.any(Object), + position: step.position, + }; + + if (step.type === 'trigger') { + computedStep.webhookPath = expect.stringContaining('/webhooks/flows/'); + } + + return computedStep; + }); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default exportFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js index db1e4a47..49efe83c 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js @@ -14,6 +14,7 @@ const getFlowMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js index 0509aec3..6012a6f6 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js @@ -14,6 +14,7 @@ const getFlowsMock = async (flows, steps) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js new file mode 100644 index 00000000..abceb584 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js @@ -0,0 +1,35 @@ +import { expect } from 'vitest'; + +const importFlowMock = async (flow, steps = []) => { + const data = { + name: flow.name, + status: flow.active ? 'published' : 'draft', + active: flow.active, + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + key: step.key, + name: step.name, + parameters: expect.any(Object), + position: step.position, + status: 'incomplete', + type: step.type, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default importFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js index f303f295..f7c32b3b 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js @@ -14,6 +14,7 @@ const updateFlowStatusMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js index 7b5515ed..4ae477d6 100644 --- a/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js +++ b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js @@ -8,6 +8,7 @@ const getPreviousStepsMock = async (steps, executionSteps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js index 87514ef9..a7ad0dea 100644 --- a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js +++ b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js @@ -3,6 +3,7 @@ const updateStepMock = (step) => { id: step.id, type: step.type || 'action', key: step.key || null, + name: step.name || null, appKey: step.appKey || null, iconUrl: step.iconUrl || null, webhookUrl: step.webhookUrl || null, diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index e75ea51d..9c2ef104 100644 --- a/packages/backend/vitest.config.js +++ b/packages/backend/vitest.config.js @@ -13,17 +13,25 @@ export default defineConfig({ reportsDirectory: './coverage', reporter: ['text', 'lcov'], all: true, - include: ['**/src/models/**', '**/src/controllers/**'], + include: [ + '**/src/controllers/**', + '**/src/helpers/authentication.test.js', + '**/src/helpers/axios-with-proxy.test.js', + '**/src/helpers/compute-parameters.test.js', + '**/src/helpers/user-ability.test.js', + '**/src/models/**', + '**/src/serializers/**', + ], exclude: [ '**/src/controllers/webhooks/**', '**/src/controllers/paddle/**', ], thresholds: { autoUpdate: true, - statements: 99.44, - branches: 97.78, - functions: 99.1, - lines: 99.44, + statements: 99.4, + branches: 97.77, + functions: 99.16, + lines: 99.4, }, }, }, diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index bf899f12..718eff58 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4261,16 +4261,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4302,14 +4293,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== diff --git a/packages/docs/pages/.vitepress/config.js b/packages/docs/pages/.vitepress/config.js index 8011303f..63a996a1 100644 --- a/packages/docs/pages/.vitepress/config.js +++ b/packages/docs/pages/.vitepress/config.js @@ -41,6 +41,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/airtable/connection' }, ], }, + { + text: 'Anthropic', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/anthropic/actions' }, + { text: 'Connection', link: '/apps/anthropic/connection' }, + ], + }, { text: 'Appwrite', collapsible: true, @@ -159,6 +168,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/formatter/connection' }, ], }, + { + text: 'Freescout', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/freescout/triggers' }, + { text: 'Connection', link: '/apps/freescout/connection' }, + ], + }, { text: 'Ghost', collapsible: true, @@ -304,6 +322,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/miro/connection' }, ], }, + { + text: 'Mistral AI', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/mistral-ai/actions' }, + { text: 'Connection', link: '/apps/mistral-ai/connection' }, + ], + }, { text: 'Notion', collapsible: true, @@ -341,6 +368,24 @@ export default defineConfig({ { text: 'Connection', link: '/apps/openai/connection' }, ], }, + { + text: 'OpenRouter', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/openrouter/actions' }, + { text: 'Connection', link: '/apps/openrouter/connection' }, + ], + }, + { + text: 'Perplexity', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/perplexity/actions' }, + { text: 'Connection', link: '/apps/perplexity/connection' }, + ], + }, { text: 'Pipedrive', collapsible: true, @@ -499,6 +544,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/todoist/connection' }, ], }, + { + text: 'Together AI', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/together-ai/actions' }, + { text: 'Connection', link: '/apps/together-ai/connection' }, + ], + }, { text: 'Trello', collapsible: true, @@ -537,6 +591,15 @@ export default defineConfig({ { text: 'Connection', link: '/apps/typeform/connection' }, ], }, + { + text: 'VirtualQ', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/virtualq/actions' }, + { text: 'Connection', link: '/apps/virtualq/connection' }, + ], + }, { text: 'Vtiger CRM', collapsible: true, diff --git a/packages/docs/pages/advanced/configuration.md b/packages/docs/pages/advanced/configuration.md index a6635034..aa461568 100644 --- a/packages/docs/pages/advanced/configuration.md +++ b/packages/docs/pages/advanced/configuration.md @@ -35,6 +35,7 @@ Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment | `APP_SECRET_KEY` | string | | Secret Key to authenticate the user | | `REDIS_HOST` | string | `redis` | Redis Host | | `REDIS_PORT` | number | `6379` | Redis Port | +| `REDIS_DB` | number | | Redis Database | | `REDIS_USERNAME` | string | | Redis Username | | `REDIS_PASSWORD` | string | | Redis Password | | `REDIS_TLS` | boolean | `false` | Redis TLS | diff --git a/packages/docs/pages/apps/anthropic/actions.md b/packages/docs/pages/apps/anthropic/actions.md new file mode 100644 index 00000000..6e8d4cf7 --- /dev/null +++ b/packages/docs/pages/apps/anthropic/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/anthropic.svg +items: + - name: Send message + desc: Sends a structured list of input messages with text content, and the model will generate the next message in the conversation. +--- + + + + diff --git a/packages/docs/pages/apps/anthropic/connection.md b/packages/docs/pages/apps/anthropic/connection.md new file mode 100644 index 00000000..92330b8b --- /dev/null +++ b/packages/docs/pages/apps/anthropic/connection.md @@ -0,0 +1,8 @@ +# Anthropic + +1. Go to [API Keys page](https://console.anthropic.com/settings/keys) on Anthropic. +2. Create a new key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Anthropic integration with Automatisch! diff --git a/packages/docs/pages/apps/freescout/connection.md b/packages/docs/pages/apps/freescout/connection.md new file mode 100644 index 00000000..b0ae8867 --- /dev/null +++ b/packages/docs/pages/apps/freescout/connection.md @@ -0,0 +1,13 @@ +# FreeScout + +:::info +This page explains the steps you need to follow to set up the FreeScout +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to your FreeScout instance. +2. Go to the Manage > Settings > API & Webhooks page. +3. Generate **API Key**. +4. Copy the **API Key** value to the `API Key` field on Automatisch. +5. Click **Submit** button on Automatisch. +6. Congrats! Start using your new FreeScout connection within the flows. diff --git a/packages/docs/pages/apps/freescout/triggers.md b/packages/docs/pages/apps/freescout/triggers.md new file mode 100644 index 00000000..9bdeda5e --- /dev/null +++ b/packages/docs/pages/apps/freescout/triggers.md @@ -0,0 +1,13 @@ +--- +favicon: /favicons/freescout.svg +items: + - name: New event + desc: Triggers when a new event is created. The supported events are conversation created, conversation assigned, conversation status updated, conversation moved, conversation deleted, conversation deleted forever, conversation restored from deleted folder, customer replied, agent replied, note added, customer created, customer updated. + +--- + + + + diff --git a/packages/docs/pages/apps/mistral-ai/actions.md b/packages/docs/pages/apps/mistral-ai/actions.md new file mode 100644 index 00000000..82f3207e --- /dev/null +++ b/packages/docs/pages/apps/mistral-ai/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/mistral-ai.svg +items: + - name: Create chat completion + desc: Creates a chat completion. +--- + + + + diff --git a/packages/docs/pages/apps/mistral-ai/connection.md b/packages/docs/pages/apps/mistral-ai/connection.md new file mode 100644 index 00000000..41201303 --- /dev/null +++ b/packages/docs/pages/apps/mistral-ai/connection.md @@ -0,0 +1,8 @@ +# Mistral AI + +1. Go to [Your API keys page](https://console.mistral.ai/api-keys/) on Mistral AI. +2. Create a new API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Mistral AI integration with Automatisch! diff --git a/packages/docs/pages/apps/openrouter/actions.md b/packages/docs/pages/apps/openrouter/actions.md new file mode 100644 index 00000000..394a43a0 --- /dev/null +++ b/packages/docs/pages/apps/openrouter/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/openrouter.svg +items: + - name: Create chat completion + desc: Creates a chat completion. +--- + + + + diff --git a/packages/docs/pages/apps/openrouter/connection.md b/packages/docs/pages/apps/openrouter/connection.md new file mode 100644 index 00000000..8abc070b --- /dev/null +++ b/packages/docs/pages/apps/openrouter/connection.md @@ -0,0 +1,8 @@ +# OpenRouter + +1. Go to [API Keys page](https://openrouter.ai/settings/keys) on OpenRouter. +2. Create a new key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using OpenRouter integration with Automatisch! diff --git a/packages/docs/pages/apps/perplexity/actions.md b/packages/docs/pages/apps/perplexity/actions.md new file mode 100644 index 00000000..114be044 --- /dev/null +++ b/packages/docs/pages/apps/perplexity/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/perplexity.svg +items: + - name: Send chat prompt + desc: Generates a model's response for the given chat conversation. +--- + + + + diff --git a/packages/docs/pages/apps/perplexity/connection.md b/packages/docs/pages/apps/perplexity/connection.md new file mode 100644 index 00000000..5962f51f --- /dev/null +++ b/packages/docs/pages/apps/perplexity/connection.md @@ -0,0 +1,8 @@ +# Perplexity + +1. Go to [API page](https://www.perplexity.ai/settings/api) on Perplexity. +2. Generate a new API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Perplexity integration with Automatisch! diff --git a/packages/docs/pages/apps/scheduler/triggers.md b/packages/docs/pages/apps/scheduler/triggers.md index d4a0f90c..e1ea6874 100644 --- a/packages/docs/pages/apps/scheduler/triggers.md +++ b/packages/docs/pages/apps/scheduler/triggers.md @@ -1,6 +1,8 @@ --- favicon: /favicons/scheduler.svg items: + - name: Every N minutes + desc: Triggers every N minutes. - name: Every hour desc: Triggers every hour. - name: Every day diff --git a/packages/docs/pages/apps/signalwire/triggers.md b/packages/docs/pages/apps/signalwire/triggers.md index 42083cc8..8d412be5 100644 --- a/packages/docs/pages/apps/signalwire/triggers.md +++ b/packages/docs/pages/apps/signalwire/triggers.md @@ -1,6 +1,8 @@ --- favicon: /favicons/signalwire.svg items: + - name: Receive Call + desc: Triggers when a new call is received. - name: Receive SMS desc: Triggers when a new SMS is received. --- diff --git a/packages/docs/pages/apps/together-ai/actions.md b/packages/docs/pages/apps/together-ai/actions.md new file mode 100644 index 00000000..4152212d --- /dev/null +++ b/packages/docs/pages/apps/together-ai/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/together-ai.svg +items: + - name: Create chat completion + desc: Queries a chat model. + - name: Create completion + desc: Queries a language, code, or image model. +--- + + + + diff --git a/packages/docs/pages/apps/together-ai/connection.md b/packages/docs/pages/apps/together-ai/connection.md new file mode 100644 index 00000000..e40983a5 --- /dev/null +++ b/packages/docs/pages/apps/together-ai/connection.md @@ -0,0 +1,8 @@ +# Together AI + +1. Go to [API Keys page](https://api.together.ai/settings/api-keys) on Together AI. +2. Copy your API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Together AI integration with Automatisch! diff --git a/packages/docs/pages/apps/virtualq/actions.md b/packages/docs/pages/apps/virtualq/actions.md new file mode 100644 index 00000000..ee3be67b --- /dev/null +++ b/packages/docs/pages/apps/virtualq/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/virtualq.svg +items: + - name: Create waiter + desc: Creates a waiter. + - name: Delete waiter + desc: Deletes a waiter. + - name: Show waiter + desc: Shows a waiter. + - name: Update waiter + desc: Updates a waiter. +--- + + + + diff --git a/packages/docs/pages/apps/virtualq/connection.md b/packages/docs/pages/apps/virtualq/connection.md new file mode 100644 index 00000000..59b3c5b8 --- /dev/null +++ b/packages/docs/pages/apps/virtualq/connection.md @@ -0,0 +1,13 @@ +# VirtualQ + +:::info +This page explains the steps you need to follow to set up a VirtualQ connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the VirtualQ dashboard and open "Contact Center" page (https://dashboard.virtualq.tech/). +2. Open **API Tokens** tab. +3. Click the **New Token** button. +4. Provide a description for your token in the **Optional description** field and submit the form. +5. Copy the shown token and paste it to the **API Key** field on the Automatisch connection creation page. +6. Click **Submit** button on Automatisch. +7. Now you can start using the new VirtualQ connection! diff --git a/packages/docs/pages/guide/available-apps.md b/packages/docs/pages/guide/available-apps.md index 4be6447b..0eb08bea 100644 --- a/packages/docs/pages/guide/available-apps.md +++ b/packages/docs/pages/guide/available-apps.md @@ -3,6 +3,7 @@ The following integrations are currently supported by Automatisch. - [Airtable](/apps/airtable/actions) +- [Anthropic](/apps/anthropic/actions) - [Appwrite](/apps/appwrite/triggers) - [Carbone](/apps/carbone/actions) - [ClickUp](/apps/clickup/triggers) @@ -31,10 +32,13 @@ The following integrations are currently supported by Automatisch. - [MailerLite](/apps/mailerlite/triggers) - [Mattermost](/apps/mattermost/actions) - [Miro](/apps/miro/actions) +- [Mistral AI](/apps/mistral-ai/actions) - [Notion](/apps/notion/triggers) - [Ntfy](/apps/ntfy/actions) - [Odoo](/apps/odoo/actions) - [OpenAI](/apps/openai/actions) +- [OpenRouter](/apps/openrouter/actions) +- [Perplexity](/apps/perplexity/actions) - [Pipedrive](/apps/pipedrive/triggers) - [Placetel](/apps/placetel/triggers) - [PostgreSQL](/apps/postgresql/actions) @@ -52,6 +56,7 @@ The following integrations are currently supported by Automatisch. - [Stripe](/apps/stripe/triggers) - [Telegram](/apps/telegram-bot/actions) - [Todoist](/apps/todoist/triggers) +- [Together AI](/apps/together-ai/actions) - [Trello](/apps/trello/actions) - [Twilio](/apps/twilio/triggers) - [Twitter](/apps/twitter/triggers) diff --git a/packages/docs/pages/public/favicons/anthropic.svg b/packages/docs/pages/public/favicons/anthropic.svg new file mode 100644 index 00000000..affdadef --- /dev/null +++ b/packages/docs/pages/public/favicons/anthropic.svg @@ -0,0 +1,8 @@ + + + Anthropic + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/freescout.svg b/packages/docs/pages/public/favicons/freescout.svg new file mode 100644 index 00000000..b2fe6412 --- /dev/null +++ b/packages/docs/pages/public/favicons/freescout.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/packages/docs/pages/public/favicons/mistral-ai.svg b/packages/docs/pages/public/favicons/mistral-ai.svg new file mode 100644 index 00000000..3f583306 --- /dev/null +++ b/packages/docs/pages/public/favicons/mistral-ai.svg @@ -0,0 +1,32 @@ + + + Mistral AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/openrouter.svg b/packages/docs/pages/public/favicons/openrouter.svg new file mode 100644 index 00000000..e88f91bd --- /dev/null +++ b/packages/docs/pages/public/favicons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/perplexity.svg b/packages/docs/pages/public/favicons/perplexity.svg new file mode 100644 index 00000000..b27ffc98 --- /dev/null +++ b/packages/docs/pages/public/favicons/perplexity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/together-ai.svg b/packages/docs/pages/public/favicons/together-ai.svg new file mode 100644 index 00000000..620ac88a --- /dev/null +++ b/packages/docs/pages/public/favicons/together-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/virtualq.svg b/packages/docs/pages/public/favicons/virtualq.svg new file mode 100644 index 00000000..41162b4d --- /dev/null +++ b/packages/docs/pages/public/favicons/virtualq.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/e2e-tests/fixtures/admin-setup-page.js b/packages/e2e-tests/fixtures/admin-setup-page.js index 704a9caf..6d5b85c2 100644 --- a/packages/e2e-tests/fixtures/admin-setup-page.js +++ b/packages/e2e-tests/fixtures/admin-setup-page.js @@ -1,4 +1,4 @@ -import { BasePage } from "./base-page"; +import { BasePage } from './base-page'; const { faker } = require('@faker-js/faker'); const { expect } = require('@playwright/test'); @@ -6,16 +6,18 @@ export class AdminSetupPage extends BasePage { path = '/installation'; /** - * @param {import('@playwright/test').Page} page - */ + * @param {import('@playwright/test').Page} page + */ constructor(page) { super(page); this.fullNameTextField = this.page.getByTestId('fullName-text-field'); this.emailTextField = this.page.getByTestId('email-text-field'); this.passwordTextField = this.page.getByTestId('password-text-field'); - this.repeatPasswordTextField = this.page.getByTestId('repeat-password-text-field'); - this.createAdminButton = this.page.getByTestId('signUp-button'); + this.repeatPasswordTextField = this.page.getByTestId( + 'repeat-password-text-field' + ); + this.createAdminButton = this.page.getByTestId('installation-button'); this.invalidFields = this.page.locator('p.Mui-error'); this.successAlert = this.page.getByTestId('success-alert'); } @@ -46,7 +48,7 @@ export class AdminSetupPage extends BasePage { await this.repeatPasswordTextField.fill(testUser.wronglyRepeatedPassword); } - async submitAdminForm() { + async submitAdminForm() { await this.createAdminButton.click(); } @@ -59,7 +61,10 @@ export class AdminSetupPage extends BasePage { } async expectSuccessMessageToContainLoginLink() { - await expect(await this.successAlert.locator('a')).toHaveAttribute('href', '/login'); + await expect(await this.successAlert.locator('a')).toHaveAttribute( + 'href', + '/login' + ); } generateUser() { @@ -69,7 +74,7 @@ export class AdminSetupPage extends BasePage { fullName: faker.person.fullName(), email: faker.internet.email(), password: faker.internet.password(), - wronglyRepeatedPassword: faker.internet.password() + wronglyRepeatedPassword: faker.internet.password(), }; } -}; +} diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js index e59ba2c5..ddf0f6e6 100644 --- a/packages/e2e-tests/fixtures/admin/create-user-page.js +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -1,3 +1,5 @@ +const { expect } = require('@playwright/test'); + const { faker } = require('@faker-js/faker'); const { AuthenticatedPage } = require('../authenticated-page'); @@ -11,7 +13,7 @@ export class AdminCreateUserPage extends AuthenticatedPage { super(page); this.fullNameInput = page.getByTestId('full-name-input'); this.emailInput = page.getByTestId('email-input'); - this.roleInput = page.getByTestId('role.id-autocomplete'); + this.roleInput = page.getByTestId('roleId-autocomplete'); this.createButton = page.getByTestId('create-button'); this.pageTitle = page.getByTestId('create-user-title'); this.invitationEmailInfoAlert = page.getByTestId( @@ -20,6 +22,8 @@ export class AdminCreateUserPage extends AuthenticatedPage { this.acceptInvitationLink = page .getByTestId('invitation-email-info-alert') .getByRole('link'); + this.createUserSuccessAlert = page.getByTestId('create-user-success-alert'); + this.fieldError = page.locator('p[id$="-helper-text"]'); } seed(seed) { @@ -32,4 +36,8 @@ export class AdminCreateUserPage extends AuthenticatedPage { email: faker.internet.email().toLowerCase(), }; } + + async expectCreateUserSuccessAlertToBeVisible() { + await expect(this.createUserSuccessAlert).toBeVisible(); + } } diff --git a/packages/e2e-tests/fixtures/admin/delete-role-modal.js b/packages/e2e-tests/fixtures/admin/delete-role-modal.js index e456f6bd..0593c635 100644 --- a/packages/e2e-tests/fixtures/admin/delete-role-modal.js +++ b/packages/e2e-tests/fixtures/admin/delete-role-modal.js @@ -9,6 +9,7 @@ export class DeleteRoleModal { this.modal = page.getByTestId('delete-role-modal'); this.cancelButton = this.modal.getByTestId('confirmation-cancel-button'); this.deleteButton = this.modal.getByTestId('confirmation-confirm-button'); + this.deleteAlert = this.modal.getByTestId('confirmation-dialog-error-alert'); } async close () { diff --git a/packages/e2e-tests/fixtures/admin/edit-user-page.js b/packages/e2e-tests/fixtures/admin/edit-user-page.js index 4bc3a1b6..d0ee043f 100644 --- a/packages/e2e-tests/fixtures/admin/edit-user-page.js +++ b/packages/e2e-tests/fixtures/admin/edit-user-page.js @@ -13,9 +13,10 @@ export class AdminEditUserPage extends AuthenticatedPage { super(page); this.fullNameInput = page.getByTestId('full-name-input'); this.emailInput = page.getByTestId('email-input'); - this.roleInput = page.getByTestId('role.id-autocomplete'); + this.roleInput = page.getByTestId('roleId-autocomplete'); this.updateButton = page.getByTestId('update-button'); this.pageTitle = page.getByTestId('edit-user-title'); + this.fieldError = page.locator('p[id$="-helper-text"]'); } /** diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js index fd6b9385..b51426eb 100644 --- a/packages/e2e-tests/tests/admin/manage-roles.spec.js +++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js @@ -166,6 +166,7 @@ test.describe('Role management page', () => { ); await expect(snackbar.variant).toBe('success'); }); + await test.step('Create a new user with the "Delete Role" role', async () => { await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); @@ -181,22 +182,18 @@ test.describe('Role management page', () => { await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); + await test.step('Try to delete "Delete Role" role when new user has it', async () => { await adminRolesPage.navigateTo(); const row = await adminRolesPage.getRoleRowByName('Delete Role'); const modal = await adminRolesPage.clickDeleteRole(row); await modal.deleteButton.click(); - const snackbar = await adminRolesPage.getSnackbarData( - 'snackbar-delete-role-error' - ); - await expect(snackbar.variant).toBe('error'); + await expect(modal.deleteAlert).toHaveCount(1); await modal.close(); }); + await test.step('Change the role the user has', async () => { await adminUsersPage.navigateTo(); await adminUsersPage.usersLoader.waitFor({ @@ -263,11 +260,9 @@ test.describe('Role management page', () => { await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - const snackbar = await adminCreateUserPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); + await test.step('Delete this user', async () => { await adminUsersPage.navigateTo(); const row = await adminUsersPage.findUserPageWithEmail( @@ -285,16 +280,8 @@ test.describe('Role management page', () => { const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role'); const modal = await adminRolesPage.clickDeleteRole(row); await modal.deleteButton.click(); - const snackbar = await adminRolesPage.getSnackbarData( - 'snackbar-delete-role-error' - ); - await expect(snackbar.variant).toBe('error'); - /* - * TODO: await snackbar - make assertions based on product - * decisions - const snackbar = await adminRolesPage.getSnackbarData(); - await expect(snackbar.variant).toBe('...'); - */ + await expect(modal.deleteAlert).toHaveCount(1); + await adminRolesPage.closeSnackbar(); }); }); }); @@ -335,10 +322,7 @@ test('Accessibility of role management page', async ({ await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - const snackbar = await adminCreateUserPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Logout and login to the basic role user', async () => { diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js index 7c3ab62d..7132f732 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -36,11 +36,7 @@ test.describe('User management page', () => { await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); await adminUsersPage.navigateTo(); }); await test.step('Check the user exists with the expected properties', async () => { @@ -65,6 +61,7 @@ test.describe('User management page', () => { 'snackbar-edit-user-success' ); await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); await adminUsersPage.findUserPageWithEmail(user.email); userRow = await adminUsersPage.getUserRowByEmail(user.email); @@ -82,6 +79,7 @@ test.describe('User management page', () => { 'snackbar-delete-user-success' ); await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); await expect(userRow).not.toBeVisible(false); }); }); @@ -92,7 +90,6 @@ test.describe('User management page', () => { }) => { adminCreateUserPage.seed(9100); const testUser = adminCreateUserPage.generateUser(); - await test.step('Create the test user', async () => { await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); @@ -103,10 +100,7 @@ test.describe('User management page', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Delete the created user', async () => { @@ -121,6 +115,7 @@ test.describe('User management page', () => { ); await expect(snackbar).not.toBeNull(); await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); await expect(userRow).not.toBeVisible(false); }); @@ -133,8 +128,7 @@ test.describe('User management page', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); - await expect(snackbar.variant).toBe('error'); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); }); }); @@ -155,10 +149,7 @@ test.describe('User management page', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Create the user again', async () => { @@ -174,8 +165,7 @@ test.describe('User management page', () => { await adminCreateUserPage.createButton.click(); await expect(page.url()).toBe(createUserPageUrl); - const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); - await expect(snackbar.variant).toBe('error'); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); }); }); @@ -198,11 +188,7 @@ test.describe('User management page', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeAllSnackbars(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Create the second user', async () => { @@ -215,10 +201,7 @@ test.describe('User management page', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Try editing the second user to have the email of the first user', async () => { @@ -231,8 +214,7 @@ test.describe('User management page', () => { const editPageUrl = page.url(); await adminEditUserPage.updateButton.click(); - const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); - await expect(snackbar.variant).toBe('error'); + await expect(adminEditUserPage.fieldError).toHaveCount(1); await expect(page.url()).toBe(editPageUrl); }); }); diff --git a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js index 98114c27..6f46454a 100644 --- a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js +++ b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js @@ -7,198 +7,191 @@ test('Ensure creating a new flow works', async ({ page }) => { ); }); -test( - 'Create a new flow with a Scheduler step then an Ntfy step', - async ({ flowEditorPage, page }) => { - await test.step('create flow', async () => { - await test.step('navigate to new flow page', async () => { - await page.getByTestId('create-flow-button').click(); - await page.waitForURL( - /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ - ); +test('Create a new flow with a Scheduler step then an Ntfy step', async ({ + flowEditorPage, + page, +}) => { + await test.step('create flow', async () => { + await test.step('navigate to new flow page', async () => { + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + }); + + await test.step('has two steps by default', async () => { + await expect(page.getByTestId('flow-step')).toHaveCount(2); + }); + }); + + await test.step('setup Scheduler trigger', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Scheduler' }).click(); }); - - await test.step('has two steps by default', async () => { - await expect(page.getByTestId('flow-step')).toHaveCount(2); + + await test.step('choose and event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Every hour' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); }); }); - await test.step('setup Scheduler trigger', async () => { - await test.step('choose app and event substep', async () => { - await test.step('choose application', async () => { - await flowEditorPage.appAutocomplete.click(); - await page - .getByRole('option', { name: 'Scheduler' }) - .click(); - }); - - await test.step('choose and event', async () => { - await expect(flowEditorPage.eventAutocomplete).toBeVisible(); - await flowEditorPage.eventAutocomplete.click(); - await page - .getByRole('option', { name: 'Every hour' }) - .click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); - await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); - }); + await test.step('set up a trigger', async () => { + await test.step('choose "yes" in "trigger on weekends?"', async () => { + await expect(flowEditorPage.trigger).toBeVisible(); + await flowEditorPage.trigger.click(); + await page.getByRole('option', { name: 'Yes' }).click(); }); - await test.step('set up a trigger', async () => { - await test.step('choose "yes" in "trigger on weekends?"', async () => { - await expect(flowEditorPage.trigger).toBeVisible(); - await flowEditorPage.trigger.click(); - await page.getByRole('option', { name: 'Yes' }).click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.trigger).not.toBeVisible(); - }); + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); }); - await test.step('test trigger', async () => { - await test.step('show sample output', async () => { - await expect(flowEditorPage.testOutput).not.toBeVisible(); - await flowEditorPage.continueButton.click(); - await expect(flowEditorPage.testOutput).toBeVisible(); - await flowEditorPage.screenshot({ - path: 'Scheduler trigger test output.png', - }); - await flowEditorPage.continueButton.click(); - }); + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.trigger).not.toBeVisible(); }); }); - await test.step('arrange Ntfy action', async () => { - await test.step('choose app and event substep', async () => { - await test.step('choose application', async () => { - await flowEditorPage.appAutocomplete.click(); - await page.getByRole('option', { name: 'Ntfy' }).click(); - }); - - await test.step('choose an event', async () => { - await expect(flowEditorPage.eventAutocomplete).toBeVisible(); - await flowEditorPage.eventAutocomplete.click(); - await page - .getByRole('option', { name: 'Send message' }) - .click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); - await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('choose connection substep', async () => { - await test.step('choose connection list item', async () => { - await flowEditorPage.connectionAutocomplete.click(); - await page.getByRole('option').first().click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('set up action substep', async () => { - await test.step('fill topic and message body', async () => { - await page - .getByTestId('parameters.topic-power-input') - .locator('[contenteditable]') - .fill('Topic'); - await page - .getByTestId('parameters.message-power-input') - .locator('[contenteditable]') - .fill('Message body'); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('test trigger substep', async () => { - await test.step('show sample output', async () => { - await expect(flowEditorPage.testOutput).not.toBeVisible(); - await page - .getByTestId('flow-substep-continue-button') - .first() - .click(); - await expect(flowEditorPage.testOutput).toBeVisible(); - await flowEditorPage.screenshot({ - path: 'Ntfy action test output.png', - }); - await flowEditorPage.continueButton.click(); - }); - }); - }); - - await test.step('publish and unpublish', async () => { - await test.step('publish flow', async () => { - await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); - await expect(flowEditorPage.publishFlowButton).toBeVisible(); - await flowEditorPage.publishFlowButton.click(); - await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); - }); - - await test.step('shows read-only sticky snackbar', async () => { - await expect(flowEditorPage.infoSnackbar).toBeVisible(); + await test.step('test trigger', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.testOutput).toBeVisible(); await flowEditorPage.screenshot({ - path: 'Published flow.png', + path: 'Scheduler trigger test output.png', }); + await flowEditorPage.continueButton.click(); }); - - await test.step('unpublish from snackbar', async () => { + }); + }); + + await test.step('arrange Ntfy action', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Ntfy' }).click(); + }); + + await test.step('choose an event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Send message' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('choose connection substep', async () => { + await test.step('choose connection list item', async () => { + await flowEditorPage.connectionAutocomplete.click(); await page - .getByTestId('unpublish-flow-from-snackbar') + .getByRole('option') + .filter({ hasText: 'Add new connection' }) .click(); - await expect(flowEditorPage.infoSnackbar).not.toBeVisible(); }); - - await test.step('publish once again', async () => { - await expect(flowEditorPage.publishFlowButton).toBeVisible(); - await flowEditorPage.publishFlowButton.click(); - await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + + await test.step('continue to next step', async () => { + await page.getByTestId('create-connection-button').click(); }); - - await test.step('unpublish from layout top bar', async () => { - await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); - await flowEditorPage.unpublishFlowButton.click(); - await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + + await test.step('collapses the substep', async () => { + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('set up action substep', async () => { + await test.step('fill topic and message body', async () => { + await page + .getByTestId('parameters.topic-power-input') + .locator('[contenteditable]') + .fill('Topic'); + await page + .getByTestId('parameters.message-power-input') + .locator('[contenteditable]') + .fill('Message body'); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('test trigger substep', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await page.getByTestId('flow-substep-continue-button').first().click(); + await expect(flowEditorPage.testOutput).toBeVisible(); await flowEditorPage.screenshot({ - path: 'Unpublished flow.png', + path: 'Ntfy action test output.png', }); + await flowEditorPage.continueButton.click(); }); }); - - await test.step('in layout', async () => { - await test.step('can go back to flows page', async () => { - await page.getByTestId('editor-go-back-button').click(); - await expect(page).toHaveURL('/flows'); + }); + + await test.step('publish and unpublish', async () => { + await test.step('publish flow', async () => { + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('shows read-only sticky snackbar', async () => { + await expect(flowEditorPage.infoSnackbar).toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Published flow.png', }); }); - } -); \ No newline at end of file + + await test.step('unpublish from snackbar', async () => { + await page.getByTestId('unpublish-flow-from-snackbar').click(); + await expect(flowEditorPage.infoSnackbar).not.toBeVisible(); + }); + + await test.step('publish once again', async () => { + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('unpublish from layout top bar', async () => { + await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); + await flowEditorPage.unpublishFlowButton.click(); + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Unpublished flow.png', + }); + }); + }); + + await test.step('in layout', async () => { + await test.step('can go back to flows page', async () => { + await page.getByTestId('editor-go-back-button').click(); + await expect(page).toHaveURL('/flows'); + }); + }); +}); diff --git a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js index d53c3a08..06841738 100644 --- a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js +++ b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js @@ -8,40 +8,41 @@ const { getToken } = require('../../helpers/auth-api-helper'); publicTest.describe('My Profile', () => { let testUser; - publicTest.beforeEach( - async ({ adminCreateUserPage, loginPage, page }) => { - let addUserResponse; - const apiRequest = await request.newContext(); + publicTest.beforeEach(async ({ adminCreateUserPage, loginPage, page }) => { + let addUserResponse; + const apiRequest = await request.newContext(); - adminCreateUserPage.seed( - Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER) + adminCreateUserPage.seed( + Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER) + ); + testUser = adminCreateUserPage.generateUser(); + + await publicTest.step('create new user', async () => { + const tokenJsonResponse = await getToken(apiRequest); + addUserResponse = await addUser( + apiRequest, + tokenJsonResponse.data.token, + { + fullName: testUser.fullName, + email: testUser.email, + } ); - testUser = adminCreateUserPage.generateUser(); + }); - await publicTest.step('create new user', async () => { - const tokenJsonResponse = await getToken(apiRequest); - addUserResponse = await addUser( - apiRequest, - tokenJsonResponse.data.token, - { - fullName: testUser.fullName, - email: testUser.email, - } - ); + await publicTest.step('accept invitation', async () => { + let acceptToken = addUserResponse.data.acceptInvitationUrl.split('=')[1]; + await acceptInvitation(apiRequest, { + token: acceptToken, + password: LoginPage.defaultPassword, }); + }); - await publicTest.step('accept invitation', async () => { - let acceptToken = addUserResponse.data.acceptInvitationUrl.split('=')[1]; - await acceptInvitation(apiRequest, {token:acceptToken, password:LoginPage.defaultPassword}); - }); - - await publicTest.step('login as new Admin', async () => { - await loginPage.login(testUser.email, LoginPage.defaultPassword); - await expect(loginPage.loginButton).not.toBeVisible(); - await expect(page).toHaveURL('/flows'); - }); - } - ); + await publicTest.step('login as new Admin', async () => { + await loginPage.login(testUser.email, LoginPage.defaultPassword); + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + }); + }); publicTest('user should be able to change own data', async ({ page }) => { const myProfilePage = new MyProfilePage(page); diff --git a/packages/web/package.json b/packages/web/package.json index cf1eb72c..cd34f29c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -37,6 +37,7 @@ "slate": "^0.94.1", "slate-history": "^0.93.0", "slate-react": "^0.94.2", + "slugify": "^1.6.6", "uuid": "^9.0.0", "web-vitals": "^1.0.1", "yup": "^0.32.11" @@ -83,7 +84,6 @@ "access": "public" }, "devDependencies": { - "@simbathesailor/use-what-changed": "^2.0.0", "@tanstack/eslint-plugin-query": "^5.20.1", "@tanstack/react-query-devtools": "^5.24.1", "eslint-config-prettier": "^9.1.0", diff --git a/packages/web/src/components/AddAppConnection/index.jsx b/packages/web/src/components/AddAppConnection/index.jsx index 9fef4c77..0744dd8a 100644 --- a/packages/web/src/components/AddAppConnection/index.jsx +++ b/packages/web/src/components/AddAppConnection/index.jsx @@ -14,6 +14,7 @@ import InputCreator from 'components/InputCreator'; import * as URLS from 'config/urls'; import useAuthenticateApp from 'hooks/useAuthenticateApp.ee'; import useFormatMessage from 'hooks/useFormatMessage'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import { generateExternalLink } from 'helpers/translationValues'; import { Form } from './style'; import useAppAuth from 'hooks/useAppAuth'; @@ -39,6 +40,7 @@ function AddAppConnection(props) { useShared: !!oauthClientId, }); const queryClient = useQueryClient(); + const enqueueSnackbar = useEnqueueSnackbar(); React.useEffect(function relayProviderData() { if (window.opener) { @@ -58,8 +60,14 @@ function AddAppConnection(props) { if (!authenticate) return; const asyncAuthenticate = async () => { - await authenticate(); - navigate(URLS.APP_CONNECTIONS(key)); + try { + await authenticate(); + navigate(URLS.APP_CONNECTIONS(key)); + } catch (error) { + enqueueSnackbar(error?.message || formatMessage('genericError'), { + variant: 'error', + }); + } }; asyncAuthenticate(); diff --git a/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx b/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx index 17712178..40936244 100644 --- a/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx +++ b/packages/web/src/components/ChooseAppAndEventSubstep/index.jsx @@ -91,15 +91,15 @@ function ChooseAppAndEventSubstep(props) { const onEventChange = React.useCallback( (event, selectedOption) => { if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption; - const option = typedSelectedOption; - const eventKey = option?.value; + const eventKey = selectedOption?.value; + const eventLabel = selectedOption?.label; + if (step.key !== eventKey) { onChange({ step: { ...step, key: eventKey, + keyLabel: eventLabel, }, }); } @@ -111,10 +111,8 @@ function ChooseAppAndEventSubstep(props) { const onAppChange = React.useCallback( (event, selectedOption) => { if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption; - const option = typedSelectedOption; - const appKey = option?.value; + const appKey = selectedOption?.value; + if (step.appKey !== appKey) { onChange({ step: { diff --git a/packages/web/src/components/ChooseConnectionSubstep/index.jsx b/packages/web/src/components/ChooseConnectionSubstep/index.jsx index 2f5df9ee..3c8a2bd6 100644 --- a/packages/web/src/components/ChooseConnectionSubstep/index.jsx +++ b/packages/web/src/components/ChooseConnectionSubstep/index.jsx @@ -23,6 +23,7 @@ import { useQueryClient } from '@tanstack/react-query'; import useAppConnections from 'hooks/useAppConnections'; import useTestConnection from 'hooks/useTestConnection'; import useOAuthClients from 'hooks/useOAuthClients'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; const ADD_CONNECTION_VALUE = 'ADD_CONNECTION'; const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION'; @@ -55,6 +56,7 @@ function ChooseConnectionSubstep(props) { React.useState(false); const queryClient = useQueryClient(); const { data: appOAuthClients } = useOAuthClients(application.key); + const enqueueSnackbar = useEnqueueSnackbar(); const { authenticate } = useAuthenticateApp({ appKey: application.key, @@ -156,8 +158,10 @@ function ChooseConnectionSubstep(props) { }, }); } - } catch (err) { - // void + } catch (error) { + enqueueSnackbar(error?.message || formatMessage('genericError'), { + variant: 'error', + }); } finally { setShowAddSharedConnectionDialog(false); } diff --git a/packages/web/src/components/ConditionalIconButton/index.jsx b/packages/web/src/components/ConditionalIconButton/index.jsx index 50125c32..1f859eb6 100644 --- a/packages/web/src/components/ConditionalIconButton/index.jsx +++ b/packages/web/src/components/ConditionalIconButton/index.jsx @@ -9,6 +9,7 @@ function ConditionalIconButton(props) { const { icon, ...buttonProps } = props; const theme = useTheme(); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); + if (matchSmallScreens) { return ( ); } - return )} + {errorMessage && ( + + {errorMessage} + + )} ); } @@ -57,6 +64,7 @@ ConfirmationDialog.propTypes = { confirmButtonChildren: PropTypes.node.isRequired, open: PropTypes.bool, 'data-test': PropTypes.string, + errorMessage: PropTypes.string, }; export default ConfirmationDialog; diff --git a/packages/web/src/components/Container/index.jsx b/packages/web/src/components/Container/index.jsx index ffafaa14..ef75335b 100644 --- a/packages/web/src/components/Container/index.jsx +++ b/packages/web/src/components/Container/index.jsx @@ -1,10 +1,19 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import MuiContainer from '@mui/material/Container'; -export default function Container(props) { - return ; +export default function Container({ maxWidth = 'lg', ...props }) { + return ; } -Container.defaultProps = { - maxWidth: 'lg', +Container.propTypes = { + maxWidth: PropTypes.oneOf([ + 'xs', + 'sm', + 'md', + 'lg', + 'xl', + false, + PropTypes.string, + ]), }; diff --git a/packages/web/src/components/DeleteRoleButton/index.ee.jsx b/packages/web/src/components/DeleteRoleButton/index.ee.jsx index 5a40c1f9..4e70c34d 100644 --- a/packages/web/src/components/DeleteRoleButton/index.ee.jsx +++ b/packages/web/src/components/DeleteRoleButton/index.ee.jsx @@ -4,6 +4,7 @@ import IconButton from '@mui/material/IconButton'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; +import { getGeneralErrorMessage, getFieldErrorMessage } from 'helpers/errors'; import Can from 'components/Can'; import ConfirmationDialog from 'components/ConfirmationDialog'; import useFormatMessage from 'hooks/useFormatMessage'; @@ -15,7 +16,21 @@ function DeleteRoleButton(props) { const formatMessage = useFormatMessage(); const enqueueSnackbar = useEnqueueSnackbar(); - const { mutateAsync: deleteRole } = useAdminDeleteRole(roleId); + const { + mutateAsync: deleteRole, + error: deleteRoleError, + reset: resetDeleteRole, + } = useAdminDeleteRole(roleId); + + const roleErrorMessage = getFieldErrorMessage({ + fieldName: 'role', + error: deleteRoleError, + }); + + const generalErrorMessage = getGeneralErrorMessage({ + error: deleteRoleError, + fallbackMessage: formatMessage('deleteRoleButton.generalError'), + }); const handleConfirm = React.useCallback(async () => { try { @@ -28,24 +43,14 @@ function DeleteRoleButton(props) { 'data-test': 'snackbar-delete-role-success', }, }); - } catch (error) { - const errors = Object.values( - error.response.data.errors || [['Failed while deleting!']], - ); - - for (const [error] of errors) { - enqueueSnackbar(error, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-delete-role-error', - }, - }); - } - - throw new Error('Failed while deleting!'); - } + } catch {} }, [deleteRole, enqueueSnackbar, formatMessage]); + const handleClose = () => { + setShowConfirmation(false); + resetDeleteRole(); + }; + return ( <> @@ -65,11 +70,12 @@ function DeleteRoleButton(props) { open={showConfirmation} title={formatMessage('deleteRoleButton.title')} description={formatMessage('deleteRoleButton.description')} - onClose={() => setShowConfirmation(false)} + onClose={handleClose} onConfirm={handleConfirm} cancelButtonChildren={formatMessage('deleteRoleButton.cancel')} confirmButtonChildren={formatMessage('deleteRoleButton.confirm')} data-test="delete-role-modal" + errorMessage={roleErrorMessage || generalErrorMessage} /> ); diff --git a/packages/web/src/components/DeleteUserButton/index.ee.jsx b/packages/web/src/components/DeleteUserButton/index.ee.jsx index de4b918f..96ea717f 100644 --- a/packages/web/src/components/DeleteUserButton/index.ee.jsx +++ b/packages/web/src/components/DeleteUserButton/index.ee.jsx @@ -3,6 +3,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import IconButton from '@mui/material/IconButton'; import { useQueryClient } from '@tanstack/react-query'; +import { getGeneralErrorMessage } from 'helpers/errors'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import ConfirmationDialog from 'components/ConfirmationDialog'; @@ -12,12 +13,21 @@ import useAdminUserDelete from 'hooks/useAdminUserDelete'; function DeleteUserButton(props) { const { userId } = props; const [showConfirmation, setShowConfirmation] = React.useState(false); - const { mutateAsync: deleteUser } = useAdminUserDelete(userId); + const { + mutateAsync: deleteUser, + error: deleteUserError, + reset: resetDeleteUser, + } = useAdminUserDelete(userId); const formatMessage = useFormatMessage(); const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); + const generalErrorMessage = getGeneralErrorMessage({ + error: deleteUserError, + fallbackMessage: formatMessage('deleteUserButton.deleteError'), + }); + const handleConfirm = React.useCallback(async () => { try { await deleteUser(); @@ -29,16 +39,14 @@ function DeleteUserButton(props) { 'data-test': 'snackbar-delete-user-success', }, }); - } catch (error) { - enqueueSnackbar( - error?.message || formatMessage('deleteUserButton.deleteError'), - { - variant: 'error', - }, - ); - } + } catch {} }, [deleteUser]); + const handleClose = () => { + setShowConfirmation(false); + resetDeleteUser(); + }; + return ( <> setShowConfirmation(false)} + onClose={handleClose} onConfirm={handleConfirm} cancelButtonChildren={formatMessage('deleteUserButton.cancel')} confirmButtonChildren={formatMessage('deleteUserButton.confirm')} data-test="delete-user-modal" + errorMessage={generalErrorMessage} /> ); diff --git a/packages/web/src/components/DynamicField/DynamicFieldEntry.jsx b/packages/web/src/components/DynamicField/DynamicFieldEntry.jsx new file mode 100644 index 00000000..9269cf12 --- /dev/null +++ b/packages/web/src/components/DynamicField/DynamicFieldEntry.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import * as React from 'react'; +import Stack from '@mui/material/Stack'; + +import InputCreator from 'components/InputCreator'; +import { EditorContext } from 'contexts/Editor'; +import { FieldsPropType } from 'propTypes/propTypes'; +import { FieldEntryProvider } from 'contexts/FieldEntry'; +import useFieldEntryContext from 'hooks/useFieldEntryContext'; + +function DynamicFieldEntry(props) { + const { fields, stepId, namePrefix } = props; + const editorContext = React.useContext(EditorContext); + const fieldEntryContext = useFieldEntryContext(); + + const newFieldEntryPaths = [ + ...(fieldEntryContext?.fieldEntryPaths || []), + namePrefix, + ]; + + return ( + + {fields.map((fieldSchema, fieldSchemaIndex) => ( + + + + ))} + + ); +} + +DynamicFieldEntry.propTypes = { + stepId: PropTypes.string, + namePrefix: PropTypes.string, + index: PropTypes.number, + fields: FieldsPropType.isRequired, +}; + +export default DynamicFieldEntry; diff --git a/packages/web/src/components/DynamicField/index.jsx b/packages/web/src/components/DynamicField/index.jsx index 93e8f884..6822c1b2 100644 --- a/packages/web/src/components/DynamicField/index.jsx +++ b/packages/web/src/components/DynamicField/index.jsx @@ -4,19 +4,21 @@ import { v4 as uuidv4 } from 'uuid'; import { useFormContext, useWatch } from 'react-hook-form'; import Typography from '@mui/material/Typography'; import Stack from '@mui/material/Stack'; -import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import RemoveIcon from '@mui/icons-material/Remove'; import AddIcon from '@mui/icons-material/Add'; -import InputCreator from 'components/InputCreator'; -import { EditorContext } from 'contexts/Editor'; + import { FieldsPropType } from 'propTypes/propTypes'; +import DynamicFieldEntry from './DynamicFieldEntry'; +import { FieldEntryProvider } from 'contexts/FieldEntry'; +import useFieldEntryContext from 'hooks/useFieldEntryContext'; function DynamicField(props) { const { label, description, fields, name, defaultValue, stepId } = props; const { control, setValue, getValues } = useFormContext(); const fieldsValue = useWatch({ control, name }); - const editorContext = React.useContext(EditorContext); + const fieldEntryContext = useFieldEntryContext(); + const createEmptyItem = React.useCallback(() => { return fields.reduce((previousValue, field) => { return { @@ -26,6 +28,7 @@ function DynamicField(props) { }; }, {}); }, [fields]); + const addItem = React.useCallback(() => { const values = getValues(name); if (!values) { @@ -34,6 +37,7 @@ function DynamicField(props) { setValue(name, values.concat(createEmptyItem())); } }, [getValues, createEmptyItem]); + const removeItem = React.useCallback( (index) => { if (fieldsValue.length === 1) return; @@ -44,6 +48,7 @@ function DynamicField(props) { }, [fieldsValue], ); + React.useEffect( function addInitialGroupWhenEmpty() { const fieldValues = getValues(name); @@ -55,14 +60,17 @@ function DynamicField(props) { }, [createEmptyItem, defaultValue], ); - return ( - - {label} - {fieldsValue?.map((field, index) => ( + return ( + + {label} + {fieldsValue?.map?.((field, index) => ( 2 ? 'column' : 'row', + }} spacing={{ xs: 2 }} sx={{ display: 'flex', @@ -70,26 +78,12 @@ function DynamicField(props) { minWidth: 0, }} > - {fields.map((fieldSchema, fieldSchemaIndex) => ( - - - - ))} + - ))} - - - {description} - + ); } diff --git a/packages/web/src/components/EditableTypography/index.jsx b/packages/web/src/components/EditableTypography/index.jsx index bb234326..693bed1f 100644 --- a/packages/web/src/components/EditableTypography/index.jsx +++ b/packages/web/src/components/EditableTypography/index.jsx @@ -7,37 +7,68 @@ import { Box, TextField } from './style'; const noop = () => null; function EditableTypography(props) { - const { children, onConfirm = noop, sx, ...typographyProps } = props; + const { + children, + onConfirm = noop, + sx, + iconColor = 'inherit', + disabled = false, + prefixValue = '', + ...typographyProps + } = props; + const [editing, setEditing] = React.useState(false); + const handleClick = React.useCallback(() => { + if (disabled) return; + setEditing((editing) => !editing); - }, []); + }, [disabled]); + const handleTextFieldClick = React.useCallback((event) => { event.stopPropagation(); }, []); + const handleTextFieldKeyDown = React.useCallback( async (event) => { const target = event.target; - if (event.key === 'Enter') { + const eventKey = event.key; + + if (eventKey === 'Enter') { if (target.value !== children) { await onConfirm(target.value); } + + setEditing(false); + } + + if (eventKey === 'Escape') { setEditing(false); } }, - [children], + [children, onConfirm], ); + const handleTextFieldBlur = React.useCallback( async (event) => { const value = event.target.value; + if (value !== children) { await onConfirm(value); } + setEditing(false); }, [onConfirm, children], ); - let component = {children}; + + let component = ( + + {prefixValue} + {children} + + ); + if (editing) { component = ( ); } - return ( - - + return ( + {component} + + {!disabled && editing === false && ( + + )} ); } EditableTypography.propTypes = { children: PropTypes.string.isRequired, + disabled: PropTypes.bool, + iconColor: PropTypes.oneOf(['action', 'inherit']), onConfirm: PropTypes.func, + prefixValue: PropTypes.string, sx: PropTypes.object, }; diff --git a/packages/web/src/components/EditableTypography/style.js b/packages/web/src/components/EditableTypography/style.js index 8e11fd83..4ed685b8 100644 --- a/packages/web/src/components/EditableTypography/style.js +++ b/packages/web/src/components/EditableTypography/style.js @@ -2,17 +2,22 @@ import { styled } from '@mui/material/styles'; import MuiBox from '@mui/material/Box'; import MuiTextField from '@mui/material/TextField'; import { inputClasses } from '@mui/material/Input'; -const boxShouldForwardProp = (prop) => !['editing'].includes(prop); + +const boxShouldForwardProp = (prop) => !['editing', 'disabled'].includes(prop); + export const Box = styled(MuiBox, { shouldForwardProp: boxShouldForwardProp, })` display: flex; flex: 1; - width: 300px; + min-width: 300px; + max-width: 90%; height: 33px; align-items: center; + ${({ disabled }) => !disabled && 'cursor: pointer;'} ${({ editing }) => editing && 'border-bottom: 1px dashed #000;'} `; + export const TextField = styled(MuiTextField)({ width: '100%', [`.${inputClasses.root}:before, .${inputClasses.root}:after, .${inputClasses.root}:hover`]: diff --git a/packages/web/src/components/Editor/index.jsx b/packages/web/src/components/Editor/index.jsx index 96ca3258..9170d06d 100644 --- a/packages/web/src/components/Editor/index.jsx +++ b/packages/web/src/components/Editor/index.jsx @@ -27,6 +27,10 @@ function Editor(props) { connectionId: step.connection?.id, }; + if (step.name || step.keyLabel) { + payload.name = step.name || step.keyLabel; + } + if (step.appKey) { payload.appKey = step.appKey; } diff --git a/packages/web/src/components/EditorLayout/index.jsx b/packages/web/src/components/EditorLayout/index.jsx index c8878b5c..201b6407 100644 --- a/packages/web/src/components/EditorLayout/index.jsx +++ b/packages/web/src/components/EditorLayout/index.jsx @@ -6,29 +6,36 @@ import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import DownloadIcon from '@mui/icons-material/Download'; import Snackbar from '@mui/material/Snackbar'; import { ReactFlowProvider } from 'reactflow'; import { EditorProvider } from 'contexts/Editor'; -import EditableTypography from 'components/EditableTypography'; -import Container from 'components/Container'; -import Editor from 'components/Editor'; -import Can from 'components/Can'; -import useFormatMessage from 'hooks/useFormatMessage'; -import * as URLS from 'config/urls'; import { TopBar } from './style'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import Container from 'components/Container'; +import EditableTypography from 'components/EditableTypography'; +import Editor from 'components/Editor'; +import EditorNew from 'components/EditorNew/EditorNew'; import useFlow from 'hooks/useFlow'; +import useFormatMessage from 'hooks/useFormatMessage'; import useUpdateFlow from 'hooks/useUpdateFlow'; import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus'; -import EditorNew from 'components/EditorNew/EditorNew'; +import useExportFlow from 'hooks/useExportFlow'; +import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; export default function EditorLayout() { const { flowId } = useParams(); const formatMessage = useFormatMessage(); + const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: updateFlow } = useUpdateFlow(flowId); const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId); + const { mutateAsync: exportFlow } = useExportFlow(flowId); + const downloadJsonAsFile = useDownloadJsonAsFile(); const { data, isLoading: isFlowLoading } = useFlow(flowId); const flow = data?.data; @@ -38,6 +45,19 @@ export default function EditorLayout() { }); }; + const onExportFlow = async (name) => { + const flowExport = await exportFlow(); + + downloadJsonAsFile({ + contents: flowExport.data, + name: flowExport.data.name, + }); + + enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), { + variant: 'success', + }); + }; + return ( <> {flow?.name} @@ -79,7 +100,23 @@ export default function EditorLayout() { )} - + + + {(allowed) => ( + + )} + + {(allowed) => ( + ); +} + +FileUploadInput.propTypes = { + onChange: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/packages/web/src/components/FlowContextMenu/index.jsx b/packages/web/src/components/FlowContextMenu/index.jsx index 56d3e6af..2d4d8263 100644 --- a/packages/web/src/components/FlowContextMenu/index.jsx +++ b/packages/web/src/components/FlowContextMenu/index.jsx @@ -12,15 +12,18 @@ import * as URLS from 'config/urls'; import useFormatMessage from 'hooks/useFormatMessage'; import useDuplicateFlow from 'hooks/useDuplicateFlow'; import useDeleteFlow from 'hooks/useDeleteFlow'; +import useExportFlow from 'hooks/useExportFlow'; +import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile'; function ContextMenu(props) { - const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } = - props; + const { flowId, onClose, anchorEl, onDuplicateFlow, appKey } = props; const enqueueSnackbar = useEnqueueSnackbar(); const formatMessage = useFormatMessage(); const queryClient = useQueryClient(); const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId); - const { mutateAsync: deleteFlow } = useDeleteFlow(); + const { mutateAsync: deleteFlow } = useDeleteFlow(flowId); + const { mutateAsync: exportFlow } = useExportFlow(flowId); + const downloadJsonAsFile = useDownloadJsonAsFile(); const onFlowDuplicate = React.useCallback(async () => { await duplicateFlow(); @@ -51,7 +54,7 @@ function ContextMenu(props) { ]); const onFlowDelete = React.useCallback(async () => { - await deleteFlow(flowId); + await deleteFlow(); if (appKey) { await queryClient.invalidateQueries({ @@ -63,9 +66,30 @@ function ContextMenu(props) { variant: 'success', }); - onDeleteFlow?.(); onClose(); - }, [flowId, onClose, deleteFlow, queryClient, onDeleteFlow]); + }, [ + deleteFlow, + appKey, + enqueueSnackbar, + formatMessage, + onClose, + queryClient, + ]); + + const onFlowExport = React.useCallback(async () => { + const flowExport = await exportFlow(); + + downloadJsonAsFile({ + contents: flowExport.data, + name: flowExport.data.name, + }); + + enqueueSnackbar(formatMessage('flow.successfullyExported'), { + variant: 'success', + }); + + onClose(); + }, [exportFlow, downloadJsonAsFile, enqueueSnackbar, formatMessage, onClose]); return ( + + {(allowed) => ( + + {formatMessage('flow.export')} + + )} + + {(allowed) => ( @@ -108,7 +140,6 @@ ContextMenu.propTypes = { PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) }), ]).isRequired, - onDeleteFlow: PropTypes.func, onDuplicateFlow: PropTypes.func, appKey: PropTypes.string, }; diff --git a/packages/web/src/components/FlowRow/index.jsx b/packages/web/src/components/FlowRow/index.jsx index 3daae684..9b2e98f3 100644 --- a/packages/web/src/components/FlowRow/index.jsx +++ b/packages/web/src/components/FlowRow/index.jsx @@ -37,7 +37,7 @@ function FlowRow(props) { const formatMessage = useFormatMessage(); const contextButtonRef = React.useRef(null); const [anchorEl, setAnchorEl] = React.useState(null); - const { flow, onDuplicateFlow, onDeleteFlow, appKey } = props; + const { flow, onDuplicateFlow, appKey } = props; const handleClose = () => { setAnchorEl(null); @@ -118,7 +118,6 @@ function FlowRow(props) { flowId={flow.id} onClose={handleClose} anchorEl={anchorEl} - onDeleteFlow={onDeleteFlow} onDuplicateFlow={onDuplicateFlow} appKey={appKey} /> @@ -129,7 +128,6 @@ function FlowRow(props) { FlowRow.propTypes = { flow: FlowPropType.isRequired, - onDeleteFlow: PropTypes.func, onDuplicateFlow: PropTypes.func, appKey: PropTypes.string, }; diff --git a/packages/web/src/components/FlowStep/index.jsx b/packages/web/src/components/FlowStep/index.jsx index e9a4596c..fec9f2d8 100644 --- a/packages/web/src/components/FlowStep/index.jsx +++ b/packages/web/src/components/FlowStep/index.jsx @@ -3,6 +3,7 @@ import * as React from 'react'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; import Button from '@mui/material/Button'; import Collapse from '@mui/material/Collapse'; import List from '@mui/material/List'; @@ -18,6 +19,7 @@ import { isEqual } from 'lodash'; import { EditorContext } from 'contexts/Editor'; import { StepExecutionsProvider } from 'contexts/StepExecutions'; import TestSubstep from 'components/TestSubstep'; +import EditableTypography from 'components/EditableTypography'; import FlowSubstep from 'components/FlowSubstep'; import ChooseAppAndEventSubstep from 'components/ChooseAppAndEventSubstep'; import ChooseConnectionSubstep from 'components/ChooseConnectionSubstep'; @@ -106,10 +108,9 @@ function generateValidationSchema(substeps) { } function FlowStep(props) { - const { collapsed, onChange, onContinue, flowId } = props; + const { collapsed, onChange, onContinue, flowId, step } = props; const editorContext = React.useContext(EditorContext); const contextButtonRef = React.useRef(null); - const step = props.step; const [anchorEl, setAnchorEl] = React.useState(null); const isTrigger = step.type === 'trigger'; const isAction = step.type === 'action'; @@ -117,6 +118,10 @@ function FlowStep(props) { const [currentSubstep, setCurrentSubstep] = React.useState(0); const useAppsOptions = {}; + const stepTypeName = isTrigger + ? formatMessage('flowStep.triggerType') + : formatMessage('flowStep.actionType'); + if (isTrigger) { useAppsOptions.onlyWithTriggers = true; } @@ -183,6 +188,13 @@ function FlowStep(props) { } }; + const handleStepNameChange = async (name) => { + await onChange({ + ...step, + name, + }); + }; + const stepValidationSchema = React.useMemo( () => generateValidationSchema(substeps), [substeps], @@ -226,7 +238,7 @@ function FlowStep(props) { data-test="flow-step" >
- + -
- - {isTrigger - ? formatMessage('flowStep.triggerType') - : formatMessage('flowStep.actionType')} + + + + + {app?.name} - - {step.position}. {app?.name} - -
+ + {step.name} + +
{/* as there are no other actions besides "delete step", we hide the context menu. */} diff --git a/packages/web/src/components/FlowSubstepTitle/style.jsx b/packages/web/src/components/FlowSubstepTitle/style.jsx index c8c0c090..b885f6be 100644 --- a/packages/web/src/components/FlowSubstepTitle/style.jsx +++ b/packages/web/src/components/FlowSubstepTitle/style.jsx @@ -1,9 +1,11 @@ import { styled } from '@mui/material/styles'; import MuiListItemButton from '@mui/material/ListItemButton'; import MuiTypography from '@mui/material/Typography'; + export const ListItemButton = styled(MuiListItemButton)` justify-content: space-between; `; + export const Typography = styled(MuiTypography)` display: flex; align-items: center; diff --git a/packages/web/src/components/Form/index.jsx b/packages/web/src/components/Form/index.jsx index 614873f7..d501e5a8 100644 --- a/packages/web/src/components/Form/index.jsx +++ b/packages/web/src/components/Form/index.jsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; +import useFormatMessage from 'hooks/useFormatMessage'; const noop = () => null; @@ -18,6 +19,8 @@ function Form(props) { ...formProps } = props; + const formatMessage = useFormatMessage(); + const methods = useForm({ defaultValues, reValidateMode, @@ -25,6 +28,8 @@ function Form(props) { mode, }); + const { setError } = methods; + const form = useWatch({ control: methods.control }); const prevDefaultValues = React.useRef(defaultValues); @@ -44,9 +49,53 @@ function Form(props) { } }, [defaultValues]); + const handleErrors = React.useCallback( + function (errors) { + if (!errors) return; + + let shouldSetGenericGeneralError = true; + const fieldNames = Object.keys(defaultValues); + + Object.entries(errors).forEach(([fieldName, fieldErrors]) => { + if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) { + shouldSetGenericGeneralError = false; + setError(fieldName, { + type: 'fieldRequestError', + message: fieldErrors.join(', '), + }); + } + }); + + // in case of general errors + if (Array.isArray(errors.general)) { + for (const error of errors.general) { + shouldSetGenericGeneralError = false; + setError('root.general', { type: 'requestError', message: error }); + } + } + + if (shouldSetGenericGeneralError) { + setError('root.general', { + type: 'requestError', + message: formatMessage('form.genericError'), + }); + } + }, + [defaultValues, formatMessage, setError], + ); + return ( -
+ { + try { + return await onSubmit?.(data); + } catch (errors) { + handleErrors(errors); + } + })} + {...formProps} + > {render ? render(methods) : children}
diff --git a/packages/web/src/components/ImportFlowDialog/index.jsx b/packages/web/src/components/ImportFlowDialog/index.jsx new file mode 100644 index 00000000..4fd7f884 --- /dev/null +++ b/packages/web/src/components/ImportFlowDialog/index.jsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useNavigate, Link } from 'react-router-dom'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import UploadIcon from '@mui/icons-material/Upload'; + +import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; +import FileUploadInput from 'components/FileUploadInput'; +import useImportFlow from 'hooks/useImportFlow'; +import { getUnifiedErrorMessage } from 'helpers/errors'; + +function ImportFlowDialog(props) { + const { open = true, 'data-test': dataTest = 'import-flow-dialog' } = props; + + const [hasParsingError, setParsingError] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const navigate = useNavigate(); + const formatMessage = useFormatMessage(); + + const { + mutate: importFlow, + data: importedFlow, + error, + isError, + isSuccess, + reset, + } = useImportFlow(); + + const handleFileSelection = (event) => { + reset(); + setParsingError(false); + + const file = event.target.files[0]; + setSelectedFile(file); + }; + + const parseFlowFile = (fileContents) => { + try { + const flowData = JSON.parse(fileContents); + + return flowData; + } catch { + setParsingError(true); + } + }; + + const handleImportFlow = (event) => { + if (!selectedFile) return; + + const fileReader = new FileReader(); + + fileReader.onload = async function readFileLoaded(e) { + const flowData = parseFlowFile(e.target.result); + + if (flowData) { + importFlow(flowData); + } + }; + + fileReader.readAsText(selectedFile); + }; + + const onClose = () => { + navigate('..'); + }; + + return ( + + {formatMessage('importFlowDialog.title')} + + + + {formatMessage('importFlowDialog.description')} + + + + {formatMessage('importFlowDialog.selectFile')} + + + {selectedFile && ( + + {formatMessage('importFlowDialog.selectedFileInformation', { + fileName: selectedFile.name, + })} + + )} + + + + + + + + + + + {hasParsingError && ( + + {formatMessage('importFlowDialog.parsingError')} + + )} + + {isError && ( + + {getUnifiedErrorMessage(error.response.data.errors) || + formatMessage('genericError')} + + )} + + {isSuccess && ( + + {formatMessage('importFlowDialog.successfullyImportedFlow', { + link: (str) => ( + {str} + ), + })} + + )} + + ); +} + +ImportFlowDialog.propTypes = { + open: PropTypes.bool, + 'data-test': PropTypes.string, +}; + +export default ImportFlowDialog; diff --git a/packages/web/src/components/InputCreator/index.jsx b/packages/web/src/components/InputCreator/index.jsx index 70eeed70..aa04b09f 100644 --- a/packages/web/src/components/InputCreator/index.jsx +++ b/packages/web/src/components/InputCreator/index.jsx @@ -26,6 +26,7 @@ function InputCreator(props) { showOptionValue, shouldUnregister, } = props; + const { key: name, label, @@ -35,9 +36,11 @@ function InputCreator(props) { description, type, } = schema; + const { data, loading } = useDynamicData(stepId, schema); const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } = useDynamicFields(stepId, schema); + const additionalFields = additionalFieldsData?.data; const computedName = namePrefix ? `${namePrefix}.${name}` : name; @@ -224,6 +227,7 @@ function InputCreator(props) { ); } + return ; } diff --git a/packages/web/src/components/InstallationForm/index.jsx b/packages/web/src/components/InstallationForm/index.jsx index 80d66b6c..bba4d314 100644 --- a/packages/web/src/components/InstallationForm/index.jsx +++ b/packages/web/src/components/InstallationForm/index.jsx @@ -2,11 +2,10 @@ import * as React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { Alert } from '@mui/material'; +import Alert from '@mui/material/Alert'; import LoadingButton from '@mui/lab/LoadingButton'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; -import { enqueueSnackbar } from 'notistack'; import { useQueryClient } from '@tanstack/react-query'; import Link from '@mui/material/Link'; @@ -16,21 +15,41 @@ import * as URLS from 'config/urls'; import Form from 'components/Form'; import TextField from 'components/TextField'; -const validationSchema = yup.object().shape({ - fullName: yup.string().trim().required('installationForm.mandatoryInput'), - email: yup - .string() - .trim() - .email('installationForm.validateEmail') - .required('installationForm.mandatoryInput'), - password: yup.string().required('installationForm.mandatoryInput'), - confirmPassword: yup - .string() - .required('installationForm.mandatoryInput') - .oneOf([yup.ref('password')], 'installationForm.passwordsMustMatch'), -}); +const getValidationSchema = (formatMessage) => { + const getMandatoryInputMessage = (inputNameId) => + formatMessage('installationForm.mandatoryInput', { + inputName: formatMessage(inputNameId), + }); -const initialValues = { + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required( + getMandatoryInputMessage('installationForm.fullNameFieldLabel'), + ), + email: yup + .string() + .trim() + .required(getMandatoryInputMessage('installationForm.emailFieldLabel')) + .email(formatMessage('installationForm.validateEmail')), + password: yup + .string() + .required(getMandatoryInputMessage('installationForm.passwordFieldLabel')) + .min(6, formatMessage('installationForm.passwordMinLength')), + confirmPassword: yup + .string() + .required( + getMandatoryInputMessage('installationForm.confirmPasswordFieldLabel'), + ) + .oneOf( + [yup.ref('password')], + formatMessage('installationForm.passwordsMustMatch'), + ), + }); +}; + +const defaultValues = { fullName: '', email: '', password: '', @@ -39,7 +58,7 @@ const initialValues = { function InstallationForm() { const formatMessage = useFormatMessage(); - const install = useInstallation(); + const { mutateAsync: install, isSuccess, isPending } = useInstallation(); const queryClient = useQueryClient(); const handleOnRedirect = () => { @@ -48,21 +67,16 @@ function InstallationForm() { }); }; - const handleSubmit = async (values) => { - const { fullName, email, password } = values; + const handleSubmit = async ({ fullName, email, password }) => { try { - await install.mutateAsync({ + await install({ fullName, email, password, }); } catch (error) { - enqueueSnackbar( - error?.message || formatMessage('installationForm.error'), - { - variant: 'error', - }, - ); + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -82,11 +96,13 @@ function InstallationForm() { {formatMessage('installationForm.title')}
( + render={({ formState: { errors } }) => ( <> + + + + {errors?.root?.general && ( + + {errors.root.general.message} + + )} + + {isSuccess && ( + + {formatMessage('installationForm.success', { + link: (str) => ( + + {str} + + ), + })} + + )} {formatMessage('installationForm.submit')} )} /> - {install.isSuccess && ( - - {formatMessage('installationForm.success', { - link: (str) => ( - - {str} - - ), - })} - - )} ); } diff --git a/packages/web/src/components/SignUpForm/index.ee.jsx b/packages/web/src/components/SignUpForm/index.ee.jsx index e931d394..a7daa326 100644 --- a/packages/web/src/components/SignUpForm/index.ee.jsx +++ b/packages/web/src/components/SignUpForm/index.ee.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import LoadingButton from '@mui/lab/LoadingButton'; +import Alert from '@mui/material/Alert'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -12,24 +13,41 @@ import Form from 'components/Form'; import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; import useCreateAccessToken from 'hooks/useCreateAccessToken'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useRegisterUser from 'hooks/useRegisterUser'; -const validationSchema = yup.object().shape({ - fullName: yup.string().trim().required('signupForm.mandatoryInput'), - email: yup - .string() - .trim() - .email('signupForm.validateEmail') - .required('signupForm.mandatoryInput'), - password: yup.string().required('signupForm.mandatoryInput'), - confirmPassword: yup - .string() - .required('signupForm.mandatoryInput') - .oneOf([yup.ref('password')], 'signupForm.passwordsMustMatch'), -}); +const getValidationSchema = (formatMessage) => { + const getMandatoryInputMessage = (inputNameId) => + formatMessage('signupForm.mandatoryInput', { + inputName: formatMessage(inputNameId), + }); -const initialValues = { + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required(getMandatoryInputMessage('signupForm.fullNameFieldLabel')), + email: yup + .string() + .trim() + .required(getMandatoryInputMessage('signupForm.emailFieldLabel')) + .email(formatMessage('signupForm.validateEmail')), + password: yup + .string() + .required(getMandatoryInputMessage('signupForm.passwordFieldLabel')) + .min(6, formatMessage('signupForm.passwordMinLength')), + confirmPassword: yup + .string() + .required( + getMandatoryInputMessage('signupForm.confirmPasswordFieldLabel'), + ) + .oneOf( + [yup.ref('password')], + formatMessage('signupForm.passwordsMustMatch'), + ), + }); +}; + +const defaultValues = { fullName: '', email: '', password: '', @@ -40,7 +58,6 @@ function SignUpForm() { const navigate = useNavigate(); const authentication = useAuthentication(); const formatMessage = useFormatMessage(); - const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: registerUser, isPending: isRegisterUserPending } = useRegisterUser(); const { mutateAsync: createAccessToken, isPending: loginLoading } = @@ -67,27 +84,8 @@ function SignUpForm() { const { token } = data; authentication.updateToken(token); } catch (error) { - const errors = error?.response?.data?.errors - ? Object.values(error.response.data.errors) - : []; - - if (errors.length) { - for (const [error] of errors) { - enqueueSnackbar(error, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-sign-up-error', - }, - }); - } - } else { - enqueueSnackbar(error?.message || formatMessage('signupForm.error'), { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-sign-up-error', - }, - }); - } + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -108,11 +106,13 @@ function SignUpForm() { ( + render={({ formState: { errors } }) => ( <> + {errors?.root?.general && ( + + {errors.root.general.message} + + )} + `/app/${appKey}`; export const APP_PATTERN = '/app/:appKey'; export const APP_CONNECTIONS = (appKey) => `/app/${appKey}/connections`; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; + export const APP_ADD_CONNECTION = (appKey, shared = false) => `/app/${appKey}/connections/add?shared=${shared}`; + export const APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID = ( appKey, oauthClientId, ) => `/app/${appKey}/connections/add?oauthClientId=${oauthClientId}`; + export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; + export const APP_RECONNECT_CONNECTION = ( appKey, connectionId, oauthClientId, ) => { const path = `/app/${appKey}/connections/${connectionId}/reconnect`; + if (oauthClientId) { return `${path}?oauthClientId=${oauthClientId}`; } + return path; }; + export const APP_RECONNECT_CONNECTION_PATTERN = '/app/:appKey/connections/:connectionId/reconnect'; -export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`; + export const APP_FLOWS_FOR_CONNECTION = (appKey, connectionId) => `/app/${appKey}/flows?connectionId=${connectionId}`; + +export const APP_FLOWS = (appKey) => `/app/${appKey}/flows`; export const APP_FLOWS_PATTERN = '/app/:appKey/flows'; export const EDITOR = '/editor'; export const CREATE_FLOW = '/editor/create'; +export const IMPORT_FLOW = '/flows/import'; export const FLOW_EDITOR = (flowId) => `/editor/${flowId}`; export const FLOWS = '/flows'; // TODO: revert this back to /flows/:flowId once we have a proper single flow page export const FLOW = (flowId) => `/editor/${flowId}`; -export const FLOW_PATTERN = '/flows/:flowId'; +export const FLOWS_PATTERN = '/flows/:flowId'; export const SETTINGS = '/settings'; export const SETTINGS_DASHBOARD = SETTINGS; export const PROFILE = 'profile'; @@ -73,16 +83,22 @@ export const ADMIN_APP_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey`; export const ADMIN_APP_SETTINGS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/settings`; export const ADMIN_APP_AUTH_CLIENTS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/oauth-clients`; export const ADMIN_APP_CONNECTIONS_PATTERN = `${ADMIN_SETTINGS}/apps/:appKey/connections`; + export const ADMIN_APP_CONNECTIONS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/connections`; + export const ADMIN_APP_SETTINGS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/settings`; + export const ADMIN_APP_AUTH_CLIENTS = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients`; + export const ADMIN_APP_AUTH_CLIENT = (appKey, id) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/${id}`; + export const ADMIN_APP_AUTH_CLIENTS_CREATE = (appKey) => `${ADMIN_SETTINGS}/apps/${appKey}/oauth-clients/create`; + export const DASHBOARD = FLOWS; // External links and paths diff --git a/packages/web/src/contexts/Editor.jsx b/packages/web/src/contexts/Editor.jsx index bb554fb8..2835a83f 100644 --- a/packages/web/src/contexts/Editor.jsx +++ b/packages/web/src/contexts/Editor.jsx @@ -7,6 +7,7 @@ export const EditorContext = React.createContext({ export const EditorProvider = (props) => { const { children, value } = props; + return ( {children} ); @@ -14,5 +15,7 @@ export const EditorProvider = (props) => { EditorProvider.propTypes = { children: PropTypes.node.isRequired, - value: PropTypes.shape({ readOnly: PropTypes.bool.isRequired }).isRequired, + value: PropTypes.shape({ + readOnly: PropTypes.bool.isRequired, + }).isRequired, }; diff --git a/packages/web/src/contexts/FieldEntry.jsx b/packages/web/src/contexts/FieldEntry.jsx new file mode 100644 index 00000000..9961b06d --- /dev/null +++ b/packages/web/src/contexts/FieldEntry.jsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; + +export const FieldEntryContext = React.createContext({}); + +export const FieldEntryProvider = (props) => { + const { children, value } = props; + + return ( + + {children} + + ); +}; + +FieldEntryProvider.propTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.object, +}; diff --git a/packages/web/src/contexts/StepExecutions.jsx b/packages/web/src/contexts/StepExecutions.jsx index 2664c0ea..58ad62eb 100644 --- a/packages/web/src/contexts/StepExecutions.jsx +++ b/packages/web/src/contexts/StepExecutions.jsx @@ -6,6 +6,7 @@ export const StepExecutionsContext = React.createContext([]); export const StepExecutionsProvider = (props) => { const { children, value } = props; + return ( {children} diff --git a/packages/web/src/helpers/errors.js b/packages/web/src/helpers/errors.js new file mode 100644 index 00000000..856ee9d6 --- /dev/null +++ b/packages/web/src/helpers/errors.js @@ -0,0 +1,35 @@ +// Helpers to extract errors received from the API + +export const getGeneralErrorMessage = ({ error, fallbackMessage }) => { + if (!error) { + return; + } + + const errors = error?.response?.data?.errors; + const generalError = errors?.general; + + if (generalError && Array.isArray(generalError)) { + return generalError.join(' '); + } + + if (!errors) { + return error?.message || fallbackMessage; + } +}; + +export const getFieldErrorMessage = ({ fieldName, error }) => { + const errors = error?.response?.data?.errors; + const fieldErrors = errors?.[fieldName]; + + if (fieldErrors && Array.isArray(fieldErrors)) { + return fieldErrors.join(', '); + } + + return ''; +}; + +export const getUnifiedErrorMessage = (errors) => { + return Object.values(errors) + .flatMap((error) => error) + .join('\n\r'); +}; diff --git a/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js b/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js index 5cf8c6bc..33fa7e79 100644 --- a/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js +++ b/packages/web/src/hooks/useAdminCreateSamlAuthProvider.js @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from 'helpers/api'; -import { enqueueSnackbar } from 'notistack'; export default function useAdminCreateSamlAuthProvider() { const queryClient = useQueryClient(); @@ -16,20 +15,6 @@ export default function useAdminCreateSamlAuthProvider() { queryKey: ['admin', 'samlAuthProviders'], }); }, - onError: (error) => { - const errors = Object.entries( - error.response.data.errors || [['', 'Failed while saving!']], - ); - - for (const error of errors) { - enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-create-saml-auth-provider-error', - }, - }); - } - }, }); return query; diff --git a/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js b/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js index ab7dd2fa..9e6600f4 100644 --- a/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js +++ b/packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from 'helpers/api'; -import { enqueueSnackbar } from 'notistack'; export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) { const queryClient = useQueryClient(); @@ -19,20 +18,6 @@ export default function useAdminUpdateSamlAuthProvider(samlAuthProviderId) { queryKey: ['admin', 'samlAuthProviders'], }); }, - onError: (error) => { - const errors = Object.entries( - error.response.data.errors || [['', 'Failed while saving!']], - ); - - for (const error of errors) { - enqueueSnackbar(`${error[0] ? error[0] + ': ' : ''} ${error[1]}`, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-update-saml-auth-provider-error', - }, - }); - } - }, }); return query; diff --git a/packages/web/src/hooks/useAdminUpdateUser.js b/packages/web/src/hooks/useAdminUpdateUser.js index d971f252..21caa801 100644 --- a/packages/web/src/hooks/useAdminUpdateUser.js +++ b/packages/web/src/hooks/useAdminUpdateUser.js @@ -1,12 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from 'helpers/api'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; -import useFormatMessage from 'hooks/useFormatMessage'; export default function useAdminUpdateUser(userId) { const queryClient = useQueryClient(); - const enqueueSnackbar = useEnqueueSnackbar(); - const formatMessage = useFormatMessage(); const query = useMutation({ mutationFn: async (payload) => { @@ -19,15 +15,6 @@ export default function useAdminUpdateUser(userId) { queryKey: ['admin', 'users'], }); }, - onError: () => { - enqueueSnackbar(formatMessage('editUser.error'), { - variant: 'error', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-error', - }, - }); - }, }); return query; diff --git a/packages/web/src/hooks/useAuthenticateApp.ee.js b/packages/web/src/hooks/useAuthenticateApp.ee.js index 061b8e88..64ff578c 100644 --- a/packages/web/src/hooks/useAuthenticateApp.ee.js +++ b/packages/web/src/hooks/useAuthenticateApp.ee.js @@ -13,7 +13,6 @@ import useCreateConnectionAuthUrl from './useCreateConnectionAuthUrl'; import useUpdateConnection from './useUpdateConnection'; import useResetConnection from './useResetConnection'; import useVerifyConnection from './useVerifyConnection'; -import { useWhatChanged } from '@simbathesailor/use-what-changed'; function getSteps(auth, hasConnection, useShared) { if (hasConnection) { @@ -143,24 +142,6 @@ export default function useAuthenticateApp(payload) { verifyConnection, ]); - useWhatChanged( - [ - steps, - appKey, - oauthClientId, - connectionId, - queryClient, - createConnection, - createConnectionAuthUrl, - updateConnection, - resetConnection, - verifyConnection, - ], - 'steps, appKey, oauthClientId, connectionId, queryClient, createConnection, createConnectionAuthUrl, updateConnection, resetConnection, verifyConnection', - '', - 'useAuthenticate', - ); - return { authenticate, inProgress: authenticationInProgress, diff --git a/packages/web/src/hooks/useDeleteFlow.js b/packages/web/src/hooks/useDeleteFlow.js index 0dabec11..7e58c01e 100644 --- a/packages/web/src/hooks/useDeleteFlow.js +++ b/packages/web/src/hooks/useDeleteFlow.js @@ -2,18 +2,18 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import api from 'helpers/api'; -export default function useDeleteFlow() { +export default function useDeleteFlow(flowId) { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: async (flowId) => { + mutationFn: async () => { const { data } = await api.delete(`/v1/flows/${flowId}`); return data; }, - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['flows'], }); }, diff --git a/packages/web/src/hooks/useDownloadJsonAsFile.js b/packages/web/src/hooks/useDownloadJsonAsFile.js new file mode 100644 index 00000000..0dbbd268 --- /dev/null +++ b/packages/web/src/hooks/useDownloadJsonAsFile.js @@ -0,0 +1,31 @@ +import * as React from 'react'; +import slugify from 'slugify'; + +export default function useDownloadJsonAsFile() { + const handleDownloadJsonAsFile = React.useCallback( + function handleDownloadJsonAsFile({ contents, name }) { + const stringifiedContents = JSON.stringify(contents, null, 2); + + const slugifiedName = slugify(name, { + lower: true, + strict: true, + replacement: '-', + }); + + const fileBlob = new Blob([stringifiedContents], { + type: 'application/json', + }); + + const fileObjectUrl = URL.createObjectURL(fileBlob); + + const temporaryDownloadLink = document.createElement('a'); + temporaryDownloadLink.href = fileObjectUrl; + temporaryDownloadLink.download = slugifiedName; + + temporaryDownloadLink.click(); + }, + [], + ); + + return handleDownloadJsonAsFile; +} diff --git a/packages/web/src/hooks/useDuplicateFlow.js b/packages/web/src/hooks/useDuplicateFlow.js index c8df605f..1e5f3947 100644 --- a/packages/web/src/hooks/useDuplicateFlow.js +++ b/packages/web/src/hooks/useDuplicateFlow.js @@ -12,8 +12,8 @@ export default function useDuplicateFlow(flowId) { return data; }, - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['flows'], }); }, diff --git a/packages/web/src/hooks/useDynamicData.js b/packages/web/src/hooks/useDynamicData.js index 79b02f35..9ec06554 100644 --- a/packages/web/src/hooks/useDynamicData.js +++ b/packages/web/src/hooks/useDynamicData.js @@ -1,24 +1,38 @@ import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import set from 'lodash/set'; +import first from 'lodash/first'; +import last from 'lodash/last'; import { useMutation } from '@tanstack/react-query'; import isEqual from 'lodash/isEqual'; import api from 'helpers/api'; +import useFieldEntryContext from './useFieldEntryContext'; const variableRegExp = /({.*?})/; -function computeArguments(args, getValues) { +function computeArguments(args, getValues, fieldEntryPaths) { const initialValue = {}; return args.reduce((result, { name, value }) => { const isVariable = variableRegExp.test(value); + if (isVariable) { - const sanitizedFieldPath = value.replace(/{|}/g, ''); + const fieldsEntryPath = last(fieldEntryPaths); + const outerFieldsEntryPath = first(fieldEntryPaths); + + const sanitizedFieldPath = value + .replace(/{|}/g, '') + .replace('fieldsScope.', `${fieldsEntryPath}.`) + .replace('outerScope.', `${outerFieldsEntryPath}.`); + const computedValue = getValues(sanitizedFieldPath); + if (computedValue === undefined) throw new Error(`The ${sanitizedFieldPath} field is required.`); + set(result, name, computedValue); + return result; } @@ -52,7 +66,9 @@ function useDynamicData(stepId, schema) { }); const { getValues } = useFormContext(); + const { fieldEntryPaths } = useFieldEntryContext(); const formValues = getValues(); + /** * Return `null` when even a field is missing value. * @@ -62,23 +78,31 @@ function useDynamicData(stepId, schema) { const computedVariables = React.useMemo(() => { if (schema.type === 'dropdown' && schema.source) { try { - const variables = computeArguments(schema.source.arguments, getValues); + const variables = computeArguments( + schema.source.arguments, + getValues, + fieldEntryPaths, + ); + // if computed variables are the same, return the last computed variables. if (isEqual(variables, lastComputedVariables.current)) { return lastComputedVariables.current; } + lastComputedVariables.current = variables; + return variables; } catch (err) { return null; } } + return null; /** * `formValues` is to trigger recomputation when form is updated. * `getValues` is for convenience as it supports paths for fields like `getValues('foo.bar.baz')`. */ - }, [schema, formValues, getValues]); + }, [schema, formValues, getValues, fieldEntryPaths]); React.useEffect(() => { if ( diff --git a/packages/web/src/hooks/useExportFlow.js b/packages/web/src/hooks/useExportFlow.js new file mode 100644 index 00000000..47e75c43 --- /dev/null +++ b/packages/web/src/hooks/useExportFlow.js @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useExportFlow(flowId) { + const mutation = useMutation({ + mutationFn: async () => { + const { data } = await api.post(`/v1/flows/${flowId}/export`); + + return data; + }, + }); + + return mutation; +} diff --git a/packages/web/src/hooks/useFieldEntryContext.jsx b/packages/web/src/hooks/useFieldEntryContext.jsx new file mode 100644 index 00000000..612b3dfe --- /dev/null +++ b/packages/web/src/hooks/useFieldEntryContext.jsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { FieldEntryContext } from 'contexts/FieldEntry'; + +export default function useFieldEntryContext() { + const fieldEntryContext = React.useContext(FieldEntryContext); + + return fieldEntryContext; +} diff --git a/packages/web/src/hooks/useFlows.js b/packages/web/src/hooks/useFlows.js new file mode 100644 index 00000000..3b237cf6 --- /dev/null +++ b/packages/web/src/hooks/useFlows.js @@ -0,0 +1,18 @@ +import api from 'helpers/api'; +import { useQuery } from '@tanstack/react-query'; + +export default function useFlows({ flowName, page }) { + const query = useQuery({ + queryKey: ['flows', flowName, { page }], + queryFn: async ({ signal }) => { + const { data } = await api.get('/v1/flows', { + params: { name: flowName, page }, + signal, + }); + + return data; + }, + }); + + return query; +} diff --git a/packages/web/src/hooks/useFormatMessage.js b/packages/web/src/hooks/useFormatMessage.js index ef76fe0c..62e95e28 100644 --- a/packages/web/src/hooks/useFormatMessage.js +++ b/packages/web/src/hooks/useFormatMessage.js @@ -1,5 +1,13 @@ +import * as React from 'react'; import { useIntl } from 'react-intl'; + export default function useFormatMessage() { const { formatMessage } = useIntl(); - return (id, values = {}) => formatMessage({ id }, values); + + const customFormatMessage = React.useCallback( + (id, values = {}) => formatMessage({ id }, values), + [formatMessage], + ); + + return customFormatMessage; } diff --git a/packages/web/src/hooks/useImportFlow.js b/packages/web/src/hooks/useImportFlow.js new file mode 100644 index 00000000..8c577045 --- /dev/null +++ b/packages/web/src/hooks/useImportFlow.js @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useImportFlow() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async (flowData) => { + const { data } = await api.post('/v1/flows/import', flowData); + + return data; + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['flows'], + }); + }, + }); + + return mutation; +} diff --git a/packages/web/src/hooks/useLazyFlows.js b/packages/web/src/hooks/useLazyFlows.js deleted file mode 100644 index 66ca03e9..00000000 --- a/packages/web/src/hooks/useLazyFlows.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; - -import api from 'helpers/api'; -import { useMutation } from '@tanstack/react-query'; - -export default function useLazyFlows({ flowName, page }, { onSettled }) { - const abortControllerRef = React.useRef(new AbortController()); - - React.useEffect(() => { - abortControllerRef.current = new AbortController(); - - return () => { - abortControllerRef.current?.abort(); - }; - }, [flowName]); - - const query = useMutation({ - mutationFn: async () => { - const { data } = await api.get('/v1/flows', { - params: { name: flowName, page }, - signal: abortControllerRef.current.signal, - }); - - return data; - }, - onSettled, - }); - - return query; -} diff --git a/packages/web/src/hooks/useSamlAuthProvider.js b/packages/web/src/hooks/useSamlAuthProvider.js index c293eafd..ee575beb 100644 --- a/packages/web/src/hooks/useSamlAuthProvider.js +++ b/packages/web/src/hooks/useSamlAuthProvider.js @@ -4,7 +4,7 @@ import api from 'helpers/api'; export default function useSamlAuthProvider({ samlAuthProviderId } = {}) { const query = useQuery({ - queryKey: ['samlAuthProviders', samlAuthProviderId], + queryKey: ['admin', 'samlAuthProviders', samlAuthProviderId], queryFn: async ({ signal }) => { const { data } = await api.get( `/v1/admin/saml-auth-providers/${samlAuthProviderId}`, diff --git a/packages/web/src/hooks/useUpdateStep.js b/packages/web/src/hooks/useUpdateStep.js index 65e92170..03548b56 100644 --- a/packages/web/src/hooks/useUpdateStep.js +++ b/packages/web/src/hooks/useUpdateStep.js @@ -6,19 +6,20 @@ export default function useUpdateStep() { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: async ({ id, appKey, key, connectionId, parameters }) => { + mutationFn: async ({ id, appKey, key, connectionId, name, parameters }) => { const { data } = await api.patch(`/v1/steps/${id}`, { appKey, key, connectionId, + name, parameters, }); return data; }, - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['flows'], }); }, diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index a4c0dd2b..f918e9bc 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -1,6 +1,7 @@ { "brandText": "Automatisch", "searchPlaceholder": "Search", + "genericError": "Something went wrong. Please try again.", "accountDropdownMenu.settings": "Settings", "accountDropdownMenu.adminSettings": "Admin", "accountDropdownMenu.logout": "Logout", @@ -25,6 +26,7 @@ "app.addConnectionWithOAuthClient": "Add connection with OAuth client", "app.reconnectConnection": "Reconnect connection", "app.createFlow": "Create flow", + "app.importFlow": "Import flow", "app.settings": "Settings", "app.connections": "Connections", "app.noConnections": "You don't have any connections yet.", @@ -56,9 +58,11 @@ "flow.draft": "Draft", "flow.successfullyDeleted": "The flow and associated executions have been deleted.", "flow.successfullyDuplicated": "The flow has been successfully duplicated.", + "flow.successfullyExported": "The flow export has been successfully generated.", "flowEditor.publish": "PUBLISH", "flowEditor.unpublish": "UNPUBLISH", "flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.", + "flowEditor.export": "EXPORT", "flowEditor.noTestDataTitle": "We couldn't find matching data", "flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.", "flowEditor.testAndContinue": "Test & Continue", @@ -70,6 +74,7 @@ "flowEditor.triggerEvent": "Trigger event", "flowEditor.actionEvent": "Action event", "flowEditor.instantTriggerType": "Instant", + "flowEditor.flowSuccessfullyExported": "The flow export has been successfully generated.", "filterConditions.onlyContinueIf": "Only continue if…", "filterConditions.orContinueIf": "OR continue if…", "chooseConnectionSubstep.continue": "Continue", @@ -81,9 +86,11 @@ "flow.view": "View", "flow.duplicate": "Duplicate", "flow.delete": "Delete", + "flow.export": "Export", "flowStep.triggerType": "Trigger", "flowStep.actionType": "Action", "flows.create": "Create flow", + "flows.import": "Import flow", "flows.title": "Flows", "flows.noFlows": "You don't have any flows yet.", "flowEditor.goBack": "Go back to flows", @@ -130,6 +137,7 @@ "webhookUrlInfo.description": "You'll need to configure your application with this webhook URL.", "webhookUrlInfo.helperText": "We've generated a custom webhook URL for you to send requests to. Learn more about webhooks.", "webhookUrlInfo.copy": "Copy", + "form.genericError": "Something went wrong. Please try again.", "installationForm.title": "Installation", "installationForm.fullNameFieldLabel": "Full name", "installationForm.emailFieldLabel": "Email", @@ -138,9 +146,9 @@ "installationForm.submit": "Create admin", "installationForm.validateEmail": "Email must be valid.", "installationForm.passwordsMustMatch": "Passwords must match.", + "installationForm.passwordMinLength": "Password must be at least 6 characters long.", "installationForm.mandatoryInput": "{inputName} is required.", "installationForm.success": "The admin account has been created, and thus, the installation has been completed. You can now log in here.", - "installationForm.error": "Something went wrong. Please try again.", "signupForm.title": "Sign up", "signupForm.fullNameFieldLabel": "Full name", "signupForm.emailFieldLabel": "Email", @@ -149,8 +157,8 @@ "signupForm.submit": "Sign up", "signupForm.validateEmail": "Email must be valid.", "signupForm.passwordsMustMatch": "Passwords must match.", + "signupForm.passwordMinLength": "Password must be at least 6 characters long.", "signupForm.mandatoryInput": "{inputName} is required.", - "signupForm.error": "Something went wrong. Please try again.", "loginForm.title": "Login", "loginForm.emailFieldLabel": "Email", "loginForm.passwordFieldLabel": "Password", @@ -225,15 +233,15 @@ "userForm.email": "Email", "userForm.role": "Role", "userForm.password": "Password", + "userForm.mandatoryInput": "{inputName} is required.", + "userForm.validateEmail": "Email must be valid.", "createUser.submit": "Create", "createUser.successfullyCreated": "The user has been created.", "createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: ", - "createUser.error": "Error while creating the user.", "editUserPage.title": "Edit user", "editUser.status": "Status", "editUser.submit": "Update", "editUser.successfullyUpdated": "The user has been updated.", - "editUser.error": "Error while updating the user.", "userList.fullName": "Full name", "userList.email": "Email", "userList.role": "Role", @@ -245,12 +253,15 @@ "deleteRoleButton.cancel": "Cancel", "deleteRoleButton.confirm": "Delete", "deleteRoleButton.successfullyDeleted": "The role has been deleted.", + "deleteRoleButton.generalError": "Failed while deleting!", "editRolePage.title": "Edit role", "createRolePage.title": "Create role", "roleForm.name": "Name", "roleForm.description": "Description", + "roleForm.mandatoryInput": "{inputName} is required.", "createRole.submit": "Create", "createRole.successfullyCreated": "The role has been created.", + "createRole.permissionsError": "Permissions are invalid.", "editRole.submit": "Update", "editRole.successfullyUpdated": "The role has been updated.", "roleList.name": "Name", @@ -281,12 +292,13 @@ "authenticationForm.defaultRole": "Default role", "authenticationForm.successfullySaved": "The provider has been saved.", "authenticationForm.save": "Save", + "authenticationForm.mandatoryInput": "{inputName} is required.", "roleMappingsForm.title": "Role mappings", "roleMappingsForm.remoteRoleName": "Remote role name", "roleMappingsForm.role": "Role", "roleMappingsForm.appendRoleMapping": "Append", "roleMappingsForm.save": "Save", - "roleMappingsForm.notFound": "No role mappings have found.", + "roleMappingsForm.notFound": "No role mappings have been found.", "roleMappingsForm.successfullySaved": "Role mappings have been saved.", "adminApps.title": "Apps", "adminApps.connections": "Connections", @@ -307,5 +319,13 @@ "oauthClient.inputActive": "Active", "updateOAuthClient.title": "Update OAuth client", "notFoundPage.title": "We can't seem to find a page you're looking for.", - "notFoundPage.button": "Back to home page" + "notFoundPage.button": "Back to home page", + "importFlowDialog.title": "Import flow", + "importFlowDialog.description": "You can import a flow by uploading the exported flow file below.", + "importFlowDialog.parsingError": "Something has gone wrong with parsing the selected file.", + "importFlowDialog.selectFile": "Select file", + "importFlowDialog.close": "Close", + "importFlowDialog.import": "Import", + "importFlowDialog.selectedFileInformation": "Selected file: {fileName}", + "importFlowDialog.successfullyImportedFlow": "The flow has been successfully imported. You can view it here." } diff --git a/packages/web/src/pages/Authentication/RoleMappings.jsx b/packages/web/src/pages/Authentication/RoleMappings.jsx index 4440177b..f306a0f7 100644 --- a/packages/web/src/pages/Authentication/RoleMappings.jsx +++ b/packages/web/src/pages/Authentication/RoleMappings.jsx @@ -3,8 +3,8 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; -import { useMemo } from 'react'; +import Alert from '@mui/material/Alert'; +import { useMemo, useState } from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; @@ -63,11 +63,11 @@ const getValidationSchema = (formatMessage) => function RoleMappings({ provider, providerLoading }) { const formatMessage = useFormatMessage(); - const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: updateRoleMappings, isPending: isUpdateRoleMappingsPending, + isSuccess: isUpdateRoleMappingsSuccess, } = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id); const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } = @@ -75,9 +75,12 @@ function RoleMappings({ provider, providerLoading }) { adminSamlAuthProviderId: provider?.id, }); const roleMappings = data?.data; + const fieldNames = ['remoteRoleName', 'roleId']; + const [fieldErrors, setFieldErrors] = useState(null); const handleRoleMappingsUpdate = async (values) => { try { + setFieldErrors(null); if (provider?.id) { await updateRoleMappings( values.roleMappings.map(({ roleId, remoteRoleName }) => ({ @@ -85,29 +88,20 @@ function RoleMappings({ provider, providerLoading }) { remoteRoleName, })), ); - - enqueueSnackbar(formatMessage('roleMappingsForm.successfullySaved'), { - variant: 'success', - SnackbarProps: { - 'data-test': 'snackbar-update-role-mappings-success', - }, - }); } } catch (error) { - const errors = Object.values( - error.response.data.errors || [['Failed while saving!']], - ); - - for (const [error] of errors) { - enqueueSnackbar(error, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-update-role-mappings-error', - }, + const errors = error?.response?.data?.errors; + if (errors) { + Object.entries(errors).forEach(([fieldName, fieldErrors]) => { + if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) { + setFieldErrors((prevErrors) => [ + ...(prevErrors || []), + `${fieldName}: ${fieldErrors.join(', ')}`, + ]); + } }); } - - throw new Error('Failed while saving!'); + throw errors || error; } }; @@ -118,6 +112,25 @@ function RoleMappings({ provider, providerLoading }) { [roleMappings], ); + const renderErrors = (errors) => { + const generalError = errors?.root?.general?.message; + if (fieldErrors) { + return fieldErrors.map((error, index) => ( + + {error} + + )); + } + + if (generalError) { + return ( + + {generalError} + + ); + } + }; + if ( providerLoading || !provider?.id || @@ -140,27 +153,35 @@ function RoleMappings({ provider, providerLoading }) { reValidateMode="onChange" noValidate automaticValidation={false} - > - - - - {formatMessage('roleMappingsForm.save')} - - - + render={({ formState: { errors, isDirty } }) => ( + + + {renderErrors(errors)} + {isUpdateRoleMappingsSuccess && !isDirty && ( + + {formatMessage('roleMappingsForm.successfullySaved')} + + )} + + {formatMessage('roleMappingsForm.save')} + + + )} + /> ); } RoleMappings.propTypes = { provider: PropTypes.shape({ - id: PropTypes.oneOf([PropTypes.number, PropTypes.string]).isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), providerLoading: PropTypes.bool, }; diff --git a/packages/web/src/pages/Authentication/SamlConfiguration.jsx b/packages/web/src/pages/Authentication/SamlConfiguration.jsx index 47488241..73ff8c2d 100644 --- a/packages/web/src/pages/Authentication/SamlConfiguration.jsx +++ b/packages/web/src/pages/Authentication/SamlConfiguration.jsx @@ -2,9 +2,11 @@ import PropTypes from 'prop-types'; import LoadingButton from '@mui/lab/LoadingButton'; import Stack from '@mui/material/Stack'; import MuiTextField from '@mui/material/TextField'; +import Alert from '@mui/material/Alert'; import * as React from 'react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import ControlledAutocomplete from 'components/ControlledAutocomplete'; import Form from 'components/Form'; import Switch from 'components/Switch'; @@ -28,29 +30,94 @@ const defaultValues = { defaultRoleId: '', }; +const getValidationSchema = (formatMessage) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('authenticationForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + active: yup.boolean(), + name: yup + .string() + .trim() + .required(getMandatoryFieldMessage('authenticationForm.name')), + certificate: yup + .string() + .trim() + .required(getMandatoryFieldMessage('authenticationForm.certificate')), + signatureAlgorithm: yup + .string() + .trim() + .required( + getMandatoryFieldMessage('authenticationForm.signatureAlgorithm'), + ), + issuer: yup + .string() + .trim() + .required(getMandatoryFieldMessage('authenticationForm.issuer')), + entryPoint: yup + .string() + .trim() + .required(getMandatoryFieldMessage('authenticationForm.entryPoint')), + firstnameAttributeName: yup + .string() + .trim() + .required( + getMandatoryFieldMessage('authenticationForm.firstnameAttributeName'), + ), + surnameAttributeName: yup + .string() + .trim() + .required( + getMandatoryFieldMessage('authenticationForm.surnameAttributeName'), + ), + emailAttributeName: yup + .string() + .trim() + .required( + getMandatoryFieldMessage('authenticationForm.emailAttributeName'), + ), + roleAttributeName: yup + .string() + .trim() + .required( + getMandatoryFieldMessage('authenticationForm.roleAttributeName'), + ), + defaultRoleId: yup + .string() + .trim() + .required(getMandatoryFieldMessage('authenticationForm.defaultRole')), + }); +}; + function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); } function SamlConfiguration({ provider, providerLoading }) { const formatMessage = useFormatMessage(); - const { data, loading: isRolesLoading } = useRoles(); + const { data, isLoading: isRolesLoading } = useRoles(); const roles = data?.data; - const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: createSamlAuthProvider, isPending: isCreateSamlAuthProviderPending, + isSuccess: isCreateSamlAuthProviderSuccess, } = useAdminCreateSamlAuthProvider(); const { mutateAsync: updateSamlAuthProvider, isPending: isUpdateSamlAuthProviderPending, + isSuccess: isUpdateSamlAuthProviderSuccess, } = useAdminUpdateSamlAuthProvider(provider?.id); const isPending = isCreateSamlAuthProviderPending || isUpdateSamlAuthProviderPending; + const isSuccess = + isCreateSamlAuthProviderSuccess || isUpdateSamlAuthProviderSuccess; + const handleSubmit = async (providerData) => { try { if (provider?.id) { @@ -58,15 +125,9 @@ function SamlConfiguration({ provider, providerLoading }) { } else { await createSamlAuthProvider(providerData); } - - enqueueSnackbar(formatMessage('authenticationForm.successfullySaved'), { - variant: 'success', - SnackbarProps: { - 'data-test': 'snackbar-save-saml-provider-success', - }, - }); - } catch { - throw new Error('Failed while saving!'); + } catch (error) { + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -75,103 +136,145 @@ function SamlConfiguration({ provider, providerLoading }) { } return ( -
- - - - - ( - + ( + + + + + ( + + )} + /> + + + + + + + ( + + )} + loading={isRolesLoading} + /> + {errors?.root?.general && ( + + {errors.root.general.message} + )} - /> - - - - - - - ( - + {isSuccess && !isDirty && ( + + {formatMessage('authenticationForm.successfullySaved')} + )} - loading={isRolesLoading} - /> - - {formatMessage('authenticationForm.save')} - - - + + {formatMessage('authenticationForm.save')} + +
+ )} + /> ); } diff --git a/packages/web/src/pages/CreateRole/index.ee.jsx b/packages/web/src/pages/CreateRole/index.ee.jsx index 99a66901..88ac1d82 100644 --- a/packages/web/src/pages/CreateRole/index.ee.jsx +++ b/packages/web/src/pages/CreateRole/index.ee.jsx @@ -1,10 +1,14 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useNavigate } from 'react-router-dom'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; import Container from 'components/Container'; import Form from 'components/Form'; @@ -19,6 +23,40 @@ import useFormatMessage from 'hooks/useFormatMessage'; import useAdminCreateRole from 'hooks/useAdminCreateRole'; import usePermissionCatalog from 'hooks/usePermissionCatalog.ee'; +const getValidationSchema = (formatMessage) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('roleForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + name: yup + .string() + .trim() + .required(getMandatoryFieldMessage('roleForm.name')), + description: yup.string().trim(), + }); +}; + +const getPermissionsErrorMessage = (error) => { + const errors = error?.response?.data?.errors; + + if (errors) { + const permissionsErrors = Object.keys(errors) + .filter((key) => key.startsWith('permissions')) + .reduce((obj, key) => { + obj[key] = errors[key]; + return obj; + }, {}); + + if (Object.keys(permissionsErrors).length > 0) { + return JSON.stringify(permissionsErrors, null, 2); + } + } + + return null; +}; + export default function CreateRole() { const navigate = useNavigate(); const formatMessage = useFormatMessage(); @@ -27,6 +65,7 @@ export default function CreateRole() { useAdminCreateRole(); const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } = usePermissionCatalog(); + const [permissionError, setPermissionError] = React.useState(null); const defaultValues = React.useMemo( () => ({ @@ -44,6 +83,7 @@ export default function CreateRole() { const handleRoleCreation = async (roleData) => { try { + setPermissionError(null); const permissions = getPermissions(roleData.computedPermissions); await createRole({ @@ -61,16 +101,13 @@ export default function CreateRole() { navigate(URLS.ROLES); } catch (error) { - const errors = Object.values(error.response.data.errors); - - for (const [errorMessage] of errors) { - enqueueSnackbar(errorMessage, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-error', - }, - }); + const permissionError = getPermissionsErrorMessage(error); + if (permissionError) { + setPermissionError(permissionError); } + + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -84,39 +121,67 @@ export default function CreateRole() { -
- - + ( + + - + - + - - {formatMessage('createRole.submit')} - - - + {permissionError && ( + + + {formatMessage('createRole.permissionsError')} + +
+                      {permissionError}
+                    
+
+ )} + + {errors?.root?.general && !permissionError && ( + + {errors?.root?.general?.message} + + )} + + + {formatMessage('createRole.submit')} + +
+ )} + />
diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index ad96ba96..5eb04376 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import Alert from '@mui/material/Alert'; import MuiTextField from '@mui/material/TextField'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; import Can from 'components/Can'; import Container from 'components/Container'; @@ -16,50 +17,70 @@ import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; import useAdminCreateUser from 'hooks/useAdminCreateUser'; +import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); } +const getValidationSchema = (formatMessage, canUpdateRole) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('userForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required(getMandatoryFieldMessage('userForm.fullName')), + email: yup + .string() + .trim() + .email(formatMessage('userForm.validateEmail')) + .required(getMandatoryFieldMessage('userForm.email')), + ...(canUpdateRole + ? { + roleId: yup + .string() + .required(getMandatoryFieldMessage('userForm.role')), + } + : {}), + }); +}; + +const defaultValues = { + fullName: '', + email: '', + roleId: '', +}; + export default function CreateUser() { const formatMessage = useFormatMessage(); const { mutateAsync: createUser, isPending: isCreateUserPending, data: createdUser, + isSuccess: createUserSuccess, } = useAdminCreateUser(); const { data: rolesData, loading: isRolesLoading } = useRoles(); const roles = rolesData?.data; - const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); + const currentUserAbility = useCurrentUserAbility(); + const canUpdateRole = currentUserAbility.can('update', 'Role'); const handleUserCreation = async (userData) => { try { await createUser({ fullName: userData.fullName, email: userData.email, - roleId: userData.role?.id, + roleId: userData.roleId, }); queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); - - enqueueSnackbar(formatMessage('createUser.successfullyCreated'), { - variant: 'success', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-create-user-success', - }, - }); } catch (error) { - enqueueSnackbar(formatMessage('createUser.error'), { - variant: 'error', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-error', - }, - }); - - throw new Error('Failed while creating!'); + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -73,74 +94,111 @@ export default function CreateUser() { -
- - - - - - - ( + + ( - - )} - loading={isRolesLoading} + error={!!errors?.fullName} + helperText={errors?.fullName?.message} /> - - - {formatMessage('createUser.submit')} - + - {createdUser && ( - + ( + + )} + loading={isRolesLoading} + showHelperText={false} + /> + + + {errors?.root?.general && ( + + {errors?.root?.general?.message} + + )} + + {createUserSuccess && ( + + {formatMessage('createUser.successfullyCreated')} + + )} + + {createdUser && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {createdUser.data.acceptInvitationUrl} + + ), + })} + + )} + + - {formatMessage('createUser.invitationEmailInfo', { - link: () => ( - - {createdUser.data.acceptInvitationUrl} - - ), - })} - - )} - -
+ {formatMessage('createUser.submit')} +
+
+ )} + /> diff --git a/packages/web/src/pages/EditUser/index.jsx b/packages/web/src/pages/EditUser/index.jsx index becbb854..28713ece 100644 --- a/packages/web/src/pages/EditUser/index.jsx +++ b/packages/web/src/pages/EditUser/index.jsx @@ -5,9 +5,12 @@ import Stack from '@mui/material/Stack'; import Chip from '@mui/material/Chip'; import Typography from '@mui/material/Typography'; import MuiTextField from '@mui/material/TextField'; +import Alert from '@mui/material/Alert'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; import Can from 'components/Can'; import Container from 'components/Container'; @@ -20,11 +23,44 @@ import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; import useAdminUpdateUser from 'hooks/useAdminUpdateUser'; import useAdminUser from 'hooks/useAdminUser'; +import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); } +const getValidationSchema = (formatMessage, canUpdateRole) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('userForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required(getMandatoryFieldMessage('userForm.fullName')), + email: yup + .string() + .trim() + .email(formatMessage('userForm.validateEmail')) + .required(getMandatoryFieldMessage('userForm.email')), + ...(canUpdateRole + ? { + roleId: yup + .string() + .required(getMandatoryFieldMessage('userForm.role')), + } + : {}), + }); +}; + +const defaultValues = { + fullName: '', + email: '', + roleId: '', +}; + export default function EditUser() { const formatMessage = useFormatMessage(); const { userId } = useParams(); @@ -36,13 +72,15 @@ export default function EditUser() { const roles = data?.data; const enqueueSnackbar = useEnqueueSnackbar(); const navigate = useNavigate(); + const currentUserAbility = useCurrentUserAbility(); + const canUpdateRole = currentUserAbility.can('update', 'Role'); const handleUserUpdate = async (userDataToUpdate) => { try { await updateUser({ fullName: userDataToUpdate.fullName, email: userDataToUpdate.email, - roleId: userDataToUpdate.role?.id, + roleId: userDataToUpdate.roleId, }); enqueueSnackbar(formatMessage('editUser.successfullyUpdated'), { @@ -55,7 +93,9 @@ export default function EditUser() { navigate(URLS.USERS); } catch (error) { - throw new Error('Failed while updating!'); + const errors = error?.response?.data?.errors; + + throw errors || error; } }; @@ -80,65 +120,94 @@ export default function EditUser() { )} {!isUserLoading && ( -
- - - - {formatMessage('editUser.status')} - + ( + + + + {formatMessage('editUser.status')} + - - + + - - - - - - ( - - )} - loading={isRolesLoading} + error={!!errors?.fullName} + helperText={errors?.fullName?.message} /> - - - {formatMessage('editUser.submit')} - - - + + + + ( + + )} + loading={isRolesLoading} + showHelperText={false} + /> + + + {errors?.root?.general && ( + + {errors?.root?.general?.message} + + )} + + + {formatMessage('editUser.submit')} + +
+ )} + /> )} diff --git a/packages/web/src/pages/Flow/index.jsx b/packages/web/src/pages/Flow/index.jsx index 922dfe6b..7da82e95 100644 --- a/packages/web/src/pages/Flow/index.jsx +++ b/packages/web/src/pages/Flow/index.jsx @@ -2,9 +2,12 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; + import Container from 'components/Container'; + export default function Flow() { const { flowId } = useParams(); + return ( diff --git a/packages/web/src/pages/Flows/index.jsx b/packages/web/src/pages/Flows/index.jsx index 40c5721f..92d3308e 100644 --- a/packages/web/src/pages/Flows/index.jsx +++ b/packages/web/src/pages/Flows/index.jsx @@ -1,9 +1,15 @@ import * as React from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import debounce from 'lodash/debounce'; +import { + Link, + useNavigate, + useSearchParams, + Routes, + Route, +} from 'react-router-dom'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import AddIcon from '@mui/icons-material/Add'; +import UploadIcon from '@mui/icons-material/Upload'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import Pagination from '@mui/material/Pagination'; @@ -16,10 +22,11 @@ import ConditionalIconButton from 'components/ConditionalIconButton'; import Container from 'components/Container'; import PageTitle from 'components/PageTitle'; import SearchInput from 'components/SearchInput'; +import ImportFlowDialog from 'components/ImportFlowDialog'; import useFormatMessage from 'hooks/useFormatMessage'; import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; import * as URLS from 'config/urls'; -import useLazyFlows from 'hooks/useLazyFlows'; +import useFlows from 'hooks/useFlows'; export default function Flows() { const formatMessage = useFormatMessage(); @@ -27,21 +34,9 @@ export default function Flows() { const [searchParams, setSearchParams] = useSearchParams(); const page = parseInt(searchParams.get('page') || '', 10) || 1; const flowName = searchParams.get('flowName') || ''; - const [isLoading, setIsLoading] = React.useState(true); const currentUserAbility = useCurrentUserAbility(); - const { - data, - mutate: fetchFlows, - isSuccess, - } = useLazyFlows( - { flowName, page }, - { - onSettled: () => { - setIsLoading(false); - }, - }, - ); + const { data, isSuccess, isLoading } = useFlows({ flowName, page }); const flows = data?.data || []; const pageInfo = data?.meta; @@ -68,26 +63,9 @@ export default function Flows() { const onDuplicateFlow = () => { if (pageInfo?.currentPage > 1) { navigate(getPathWithSearchParams(1, flowName)); - } else { - fetchFlows(); } }; - const fetchData = React.useMemo( - () => debounce(fetchFlows, 300), - [fetchFlows], - ); - - React.useEffect(() => { - setIsLoading(true); - - fetchData({ flowName, page }); - - return () => { - fetchData.cancel(); - }; - }, [fetchData, flowName, page]); - React.useEffect( function redirectToLastPage() { if (navigateToLastPage) { @@ -98,85 +76,119 @@ export default function Flows() { ); return ( - - - - - {formatMessage('flows.title')} - - - - - - + <> + + - - {(allowed) => ( - } - to={URLS.CREATE_FLOW} - data-test="create-flow-button" - > - {formatMessage('flows.create')} - - )} - - - + + {formatMessage('flows.title')} + - - {(isLoading || navigateToLastPage) && ( - - )} - {!isLoading && - flows?.map((flow) => ( - - ))} - {!isLoading && !navigateToLastPage && !hasFlows && ( - - )} - {!isLoading && - !navigateToLastPage && - pageInfo && - pageInfo.totalPages > 1 && ( - ( - - )} + + + + + + + {(allowed) => ( + } + to={URLS.IMPORT_FLOW} + data-test="import-flow-button" + > + {formatMessage('flows.import')} + + )} + + + + {(allowed) => ( + } + to={URLS.CREATE_FLOW} + data-test="create-flow-button" + > + {formatMessage('flows.create')} + + )} + + + + + + + {(isLoading || navigateToLastPage) && ( + + )} + + {!isLoading && + flows?.map((flow) => ( + + ))} + + {!isLoading && !navigateToLastPage && !hasFlows && ( + )} - - + + {!isLoading && + !navigateToLastPage && + pageInfo && + pageInfo.totalPages > 1 && ( + ( + + )} + /> + )} + + + + + } /> + + ); } diff --git a/packages/web/src/propTypes/propTypes.js b/packages/web/src/propTypes/propTypes.js index bb594912..77acf9ff 100644 --- a/packages/web/src/propTypes/propTypes.js +++ b/packages/web/src/propTypes/propTypes.js @@ -123,8 +123,6 @@ export const RawTriggerPropType = PropTypes.shape({ showWebhookUrl: PropTypes.bool, pollInterval: PropTypes.number, description: PropTypes.string, - useSingletonWebhook: PropTypes.bool, - singletonWebhookRefValueParameter: PropTypes.string, getInterval: PropTypes.func, run: PropTypes.func, testRun: PropTypes.func, @@ -140,8 +138,6 @@ export const TriggerPropType = PropTypes.shape({ showWebhookUrl: PropTypes.bool, pollInterval: PropTypes.number, description: PropTypes.string, - useSingletonWebhook: PropTypes.bool, - singletonWebhookRefValueParameter: PropTypes.string, getInterval: PropTypes.func, run: PropTypes.func, testRun: PropTypes.func, diff --git a/packages/web/src/routes.jsx b/packages/web/src/routes.jsx index 1f634275..9d3013bd 100644 --- a/packages/web/src/routes.jsx +++ b/packages/web/src/routes.jsx @@ -38,7 +38,9 @@ function Routes() { const { isAuthenticated } = useAuthentication(); const config = configData?.data; - const installed = isSuccess ? automatischInfo.data.installationCompleted : true; + const installed = isSuccess + ? automatischInfo.data.installationCompleted + : true; const navigate = useNavigate(); useEffect(() => { @@ -68,7 +70,7 @@ function Routes() { /> @@ -76,15 +78,6 @@ function Routes() { } /> - - - - } - /> - }> {adminSettingsRoutes} + } /> ); diff --git a/packages/web/src/styles/theme.js b/packages/web/src/styles/theme.js index 7c2257bc..1e05d062 100644 --- a/packages/web/src/styles/theme.js +++ b/packages/web/src/styles/theme.js @@ -158,6 +158,10 @@ export const defaultTheme = createTheme({ fontSize: referenceTheme.typography.pxToRem(16), }, }, + stepApp: { + fontSize: referenceTheme.typography.pxToRem(12), + color: '#5C5C5C', + }, }, components: { MuiAppBar: { @@ -211,6 +215,23 @@ export const defaultTheme = createTheme({ }), }, }, + MuiChip: { + variants: [ + { + props: { variant: 'stepType' }, + style: ({ theme }) => ({ + color: '#001F52', + fontSize: theme.typography.pxToRem(12), + border: '1px solid', + borderColor: alpha(theme.palette.primary.main, 0.3), + bgcolor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity, + ), + }), + }, + ], + }, MuiContainer: { defaultProps: { maxWidth: 'xl', @@ -294,6 +315,7 @@ export const defaultTheme = createTheme({ }, }, }); + export const mationTheme = createTheme( deepmerge(defaultTheme, { palette: { @@ -315,4 +337,5 @@ export const mationTheme = createTheme( }, }), ); + export default defaultTheme; diff --git a/packages/web/yarn.lock b/packages/web/yarn.lock index 8023fb30..d2b3f70b 100644 --- a/packages/web/yarn.lock +++ b/packages/web/yarn.lock @@ -2126,11 +2126,6 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1" integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA== -"@simbathesailor/use-what-changed@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403" - integrity sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw== - "@sinclair/typebox@^0.24.1": version "0.24.51" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" @@ -9638,6 +9633,11 @@ slate@^0.94.1: is-plain-object "^5.0.0" tiny-warning "^1.0.3" +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"