diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 9ffe8bf9f6b26..8a0755a6c6d5f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -238,6 +238,14 @@ export interface IWorkflowDb { usedCredentials?: IUsedCredential[]; } +export interface TestSuiteDb { + name: string; + id: string; + createdAt: string; + updatedAt: string; + description: string; +} + // Identical to cli.Interfaces.ts export interface IWorkflowShortResponse { id: string; @@ -871,6 +879,7 @@ export interface WorkflowsState { workflowExecutionData: IExecutionResponse | null; workflowExecutionPairedItemMappings: { [itemId: string]: Set<string> }; workflowsById: IWorkflowsMap; + testSuitesById: TestSuiteDbMap; } export interface RootState { @@ -1172,7 +1181,9 @@ export interface IWorkflowsState { export interface IWorkflowsMap { [name: string]: IWorkflowDb; } - +export interface TestSuiteDbMap { + [name: string]: TestSuiteDb; +} export interface CommunityNodesState { availablePackageCount: number; installedPackages: CommunityPackageMap; diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index 508ed74e4a550..7b30abe26874e 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -1,4 +1,4 @@ -import type { IExecutionsCurrentSummaryExtended, IRestApiContext } from '@/Interface'; +import type { IExecutionsCurrentSummaryExtended, IRestApiContext, TestSuiteDb } from '@/Interface'; import type { ExecutionFilters, ExecutionOptions, IDataObject } from 'n8n-workflow'; import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; import { makeRestApiRequest } from '@/utils'; @@ -23,6 +23,62 @@ export async function getWorkflows(context: IRestApiContext, filter?: object) { return await makeRestApiRequest(context, 'GET', '/workflows', sendData); } +export async function getTestSuite(workFlowId: string) { + return await new Promise<TestSuiteDb[]>((resolve) => { + /** + * TODO: FETCH ALL TEST SUITES WITH WORKFLOW ID + */ + setTimeout(() => { + resolve([ + { + name: workFlowId, + id: workFlowId, + createdAt: new Date().toUTCString(), + updatedAt: new Date().toUTCString(), + description: 'some description', + }, + ]); + }, 3000); + }); +} + +export async function postTestSuite(workFlowId: string, description: string) { + return await new Promise<TestSuiteDb[]>((resolve) => { + /** + * TODO: FETCH ALL TEST SUITES WITH WORKFLOW ID + */ + setTimeout(() => { + resolve([ + { + name: workFlowId, + id: workFlowId, + createdAt: new Date().toUTCString(), + updatedAt: new Date().toUTCString(), + description, + }, + ]); + }, 3000); + }); +} + +export async function patchTestSuite(payload: { + workflowId: string; + testId: string; + id: string; + outputType: string; + error: string; + output: string; +}) { + return await new Promise((resolve) => { + /** + * TODO: FETCH ALL TEST SUITES WITH WORKFLOW ID + */ + setTimeout(() => { + resolve(payload); + }, 3000); + }); +} + export async function getActiveWorkflows(context: IRestApiContext) { return await makeRestApiRequest(context, 'GET', '/active'); } diff --git a/packages/editor-ui/src/components/AddTestSuiteModal.vue b/packages/editor-ui/src/components/AddTestSuiteModal.vue new file mode 100644 index 0000000000000..9933b5e67273c --- /dev/null +++ b/packages/editor-ui/src/components/AddTestSuiteModal.vue @@ -0,0 +1,171 @@ +<template> + <Modal + width="540px" + :name="ADD_TEST_SUITE_MODAL_KEY" + :title="$locale.baseText('testSuites.addTestSuite.title')" + :eventBus="modalBus" + :center="true" + :beforeClose="onModalClose" + :showClose="!loading" + > + <template #content> + <div :class="[$style.formContainer, 'mt-m']"> + <n8n-input-label + :class="$style.labelTooltip" + :label="$locale.baseText('testSuites.addTest.description.label')" + :tooltipText="$locale.baseText('testSuites.addTest.description.tooltip')" + > + <n8n-input + name="description" + v-model="description" + type="text" + :maxlength="300" + :placeholder="''" + :required="true" + :disabled="loading" + /> + </n8n-input-label> + <div :class="[$style.infoText, 'mt-4xs']"> + <span + size="small" + :class="[$style.infoText, infoTextErrorMessage ? $style.error : '']" + v-text="infoTextErrorMessage" + ></span> + </div> + </div> + </template> + <template #footer> + <n8n-button + :loading="loading" + :disabled="!description || loading" + :label=" + loading + ? $locale.baseText('testSuites.addTest.saveButton.label.loading') + : $locale.baseText('testSuites.addTest.saveButton.label') + " + size="large" + float="right" + @click="onAddClick" + /> + </template> + </Modal> +</template> + +<script lang="ts"> +import Modal from './Modal.vue'; +import { ADD_TEST_SUITE_MODAL_KEY } from '../constants'; +import mixins from 'vue-typed-mixins'; +import { showMessage } from '@/mixins/showMessage'; +import { mapStores } from 'pinia'; +import { createEventBus } from '@/event-bus'; +import { useWorkflowsStore } from '@/stores'; + +export default mixins(showMessage).extend({ + name: 'AddTestSuiteModal', + components: { + Modal, + }, + props: { + workFlowId: { + type: String, + default: '', + }, + }, + data() { + return { + loading: false, + description: '', + modalBus: createEventBus(), + infoTextErrorMessage: '', + ADD_TEST_SUITE_MODAL_KEY, + }; + }, + computed: { + ...mapStores(useWorkflowsStore), + }, + methods: { + async onAddClick() { + try { + this.infoTextErrorMessage = ''; + this.loading = true; + await this.workflowsStore.addWorkflowTestSuite( + this.$route.params.workflow, + this.description, + ); + this.loading = false; + this.modalBus.emit('close'); + this.$showMessage({ + title: this.$locale.baseText('testSuites.addTest.saveButton.success'), + type: 'success', + }); + } catch (error) { + if (error.httpStatusCode && error.httpStatusCode === 400) { + this.infoTextErrorMessage = error.message; + } else { + this.$showError(error, this.$locale.baseText('testSuites.addTest.saveButton.error')); + } + } finally { + this.loading = false; + } + }, + onModalClose() { + return !this.loading; + }, + }, +}); +</script> + +<style module lang="scss"> +.descriptionContainer { + display: flex; + justify-content: space-between; + align-items: center; + border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1); + border-radius: var(--border-radius-base); + background-color: var(--color-background-light); + + button { + & > span { + flex-direction: row-reverse; + & > span { + margin-left: var(--spacing-3xs); + } + } + } +} + +.formContainer { + font-size: var(--font-size-2xs); + font-weight: var(--font-weight-regular); + color: var(--color-text-base); +} + +.checkbox { + span:nth-child(2) { + vertical-align: text-top; + } +} + +.error { + color: var(--color-danger); + + span { + border-color: var(--color-danger); + } +} +</style> + +<style lang="scss"> +.el-tooltip__popper { + max-width: 240px; + img { + width: 100%; + } + p { + line-height: 1.2; + } + p + p { + margin-top: var(--spacing-2xs); + } +} +</style> diff --git a/packages/editor-ui/src/components/EditTestSuiteModal.vue b/packages/editor-ui/src/components/EditTestSuiteModal.vue new file mode 100644 index 0000000000000..bcc03e04ec941 --- /dev/null +++ b/packages/editor-ui/src/components/EditTestSuiteModal.vue @@ -0,0 +1,229 @@ +<template> + <Modal + width="540px" + :name="EDIT_TEST_SUITE_MODAL_KEY" + :title=" + $locale.baseText('testSuites.editTestSuite.title', { + interpolate: { name: data.name }, + }) + " + :eventBus="modalBus" + :center="true" + :beforeClose="onModalClose" + :showClose="!loading" + > + <template #content> + <div :class="[$style.formContainer, 'mt-m']"> + <n8n-radio-buttons + size="small" + :value="selectedType" + @input="onTypeSelected" + :options="[ + { label: $locale.baseText('parameterInput.test.error'), value: 'error' }, + { label: $locale.baseText('parameterInput.test.output'), value: 'output' }, + ]" + /> + + <div :class="['mt-2xl']"> + <n8n-input-label + v-if="selectedType === 'error'" + :class="$style.labelTooltip" + :label="$locale.baseText('testSuites.editTest.error.label')" + > + <n8n-input + name="error" + v-model="error" + type="text" + :placeholder="''" + :required="true" + :disabled="loading" + /> + </n8n-input-label> + + <n8n-input-label + v-if="selectedType === 'output'" + :class="$style.labelTooltip" + :label="$locale.baseText('testSuites.editTest.output.label')" + > + <n8n-input + name="output" + v-model="output" + type="text" + :placeholder="''" + :required="true" + :disabled="loading" + /> + </n8n-input-label> + </div> + <div :class="[$style.infoText, 'mt-4xs']"> + <span + size="small" + :class="[$style.infoText, infoTextErrorMessage ? $style.error : '']" + v-text="infoTextErrorMessage" + ></span> + </div> + </div> + </template> + <template #footer> + <n8n-button + :loading="loading" + :disabled="disabled || loading" + :label=" + loading + ? $locale.baseText('testSuites.editTest.saveButton.label.loading') + : $locale.baseText('testSuites.editTest.saveButton.label') + " + size="large" + float="right" + @click="onSave" + /> + </template> + </Modal> +</template> + +<script lang="ts"> +import Modal from './Modal.vue'; +import { EDIT_TEST_SUITE_MODAL_KEY } from '../constants'; +import mixins from 'vue-typed-mixins'; +import { showMessage } from '@/mixins/showMessage'; +import { mapStores } from 'pinia'; +import { createEventBus } from '@/event-bus'; +import { useWorkflowsStore } from '@/stores'; + +export default mixins(showMessage).extend({ + name: 'EditTestSuiteModal', + components: { + Modal, + }, + props: { + data: { + type: Object, + default: { + name: '', + }, + }, + }, + data() { + return { + loading: false, + modalBus: createEventBus(), + infoTextErrorMessage: '', + EDIT_TEST_SUITE_MODAL_KEY, + selectedType: '', + output: '', + error: '', + }; + }, + computed: { + ...mapStores(useWorkflowsStore), + disabled() { + if (!this.selectedType) { + return true; + } + if (this.selectedType === 'output' && !this.output) { + return true; + } + if (this.selectedType === 'error' && !this.error) { + return true; + } + return false; + }, + }, + methods: { + onTypeSelected(selected: string) { + this.selectedType = selected; + }, + async onSave() { + try { + this.infoTextErrorMessage = ''; + this.loading = true; + await this.workflowsStore.updateWorkflowTestSuite({ + workflowId: this.$route.params.workflow, + testId: this.$route.params.test, + id: this.data.id, + outputType: this.selectedType, + error: this.error, + output: this.output, + }); + this.loading = false; + this.modalBus.emit('close'); + this.$showMessage({ + title: this.$locale.baseText('testSuites.editTest.saveButton.success'), + type: 'success', + }); + } catch (error) { + if (error.httpStatusCode && error.httpStatusCode === 400) { + this.infoTextErrorMessage = error.message; + } else { + this.$showError(error, this.$locale.baseText('testSuites.editTest.saveButton.error')); + } + } finally { + this.loading = false; + } + }, + onModalClose() { + return !this.loading; + }, + }, + mounted() { + this.output = this.data.output || ''; + this.error = this.data.error || ''; + this.selectedType = this.data.outputType || ''; + }, +}); +</script> + +<style module lang="scss"> +.descriptionContainer { + display: flex; + justify-content: space-between; + align-items: center; + border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1); + border-radius: var(--border-radius-base); + background-color: var(--color-background-light); + + button { + & > span { + flex-direction: row-reverse; + & > span { + margin-left: var(--spacing-3xs); + } + } + } +} + +.formContainer { + font-size: var(--font-size-2xs); + font-weight: var(--font-weight-regular); + color: var(--color-text-base); +} + +.checkbox { + span:nth-child(2) { + vertical-align: text-top; + } +} + +.error { + color: var(--color-danger); + + span { + border-color: var(--color-danger); + } +} +</style> + +<style lang="scss"> +.el-tooltip__popper { + max-width: 240px; + img { + width: 100%; + } + p { + line-height: 1.2; + } + p + p { + margin-top: var(--spacing-2xs); + } +} +</style> diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 3c01907bce00d..0fbe21ee229ce 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -29,6 +29,18 @@ </template> </ModalRoot> + <ModalRoot :name="ADD_TEST_SUITE_MODAL_KEY"> + <template #default="{ data }"> + <AddTestSuiteModal :workFlowId="data?.workflow || ''" /> + </template> + </ModalRoot> + + <ModalRoot :name="EDIT_TEST_SUITE_MODAL_KEY"> + <template #default="{ data }"> + <EditTestSuiteModal :data="data" /> + </template> + </ModalRoot> + <ModalRoot :name="PERSONALIZATION_MODAL_KEY"> <PersonalizationModal /> </ModalRoot> @@ -125,6 +137,8 @@ import { CHANGE_PASSWORD_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, + ADD_TEST_SUITE_MODAL_KEY, + EDIT_TEST_SUITE_MODAL_KEY, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, @@ -170,6 +184,8 @@ import ImportCurlModal from './ImportCurlModal.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import WorkflowSuccessModal from './UserActivationSurveyModal.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; +import AddTestSuiteModal from './AddTestSuiteModal.vue'; +import EditTestSuiteModal from './EditTestSuiteModal.vue'; export default defineComponent({ name: 'Modals', @@ -198,6 +214,8 @@ export default defineComponent({ ImportCurlModal, EventDestinationSettingsModal, WorkflowSuccessModal, + AddTestSuiteModal, + EditTestSuiteModal, }, data: () => ({ COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, @@ -223,6 +241,8 @@ export default defineComponent({ IMPORT_CURL_MODAL_KEY, LOG_STREAM_MODAL_KEY, USER_ACTIVATION_SURVEY_MODAL, + ADD_TEST_SUITE_MODAL_KEY, + EDIT_TEST_SUITE_MODAL_KEY, }), }); </script> diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index e024bd96be2a4..f6b4a79a2dbd0 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -130,6 +130,15 @@ export default mixins(userHelpers).extend({ activateOnRouteNames: [VIEWS.COMMUNITY_NODES], }); + menuItems.push({ + id: 'test-suites', + icon: 'fas:chart-column', + label: this.$locale.baseText('settings.testSuites'), + position: 'top', + available: true, + activateOnRouteNames: [VIEWS.TEST_SUITES], + }); + return menuItems; }, }, @@ -197,13 +206,18 @@ export default mixins(userHelpers).extend({ case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud case 'environments': case 'logging': - this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {}); + this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => { }); break; case 'settings-community-nodes': if (this.$router.currentRoute.name !== VIEWS.COMMUNITY_NODES) { this.$router.push({ name: VIEWS.COMMUNITY_NODES }); } break; + case 'test-suites': + if (this.$router.currentRoute.name !== VIEWS.TEST_SUITES) { + this.$router.push({ name: VIEWS.TEST_SUITES }); + } + break; case 'settings-usage-and-plan': if (this.$router.currentRoute.name !== VIEWS.USAGE) { this.$router.push({ name: VIEWS.USAGE }); @@ -240,12 +254,14 @@ export default mixins(userHelpers).extend({ .returnButton { padding: var(--spacing-s) var(--spacing-l); cursor: pointer; + &:hover { color: var(--color-primary); } } @media screen and (max-height: 420px) { + .updatesSubmenu, .versionContainer { display: none; diff --git a/packages/editor-ui/src/components/TestSuiteCard.vue b/packages/editor-ui/src/components/TestSuiteCard.vue new file mode 100644 index 0000000000000..e6294180fdf4e --- /dev/null +++ b/packages/editor-ui/src/components/TestSuiteCard.vue @@ -0,0 +1,151 @@ +<template> + <n8n-card :class="$style.cardLink" @click="onClick"> + <template #header> + <n8n-heading + tag="h2" + bold + class="ph-no-capture" + :class="$style.cardHeading" + data-test-id="workflow-card-name" + > + {{ data.name }} + </n8n-heading> + </template> + <div :class="$style.cardDescription"> + <div :class="$style.flex1"> + <n8n-text color="text-light" size="small"> + <span v-show="data" + >{{ $locale.baseText('workflows.item.updated') }} <time-ago :date="data.updatedAt" /> + | + </span> + <span v-show="data" class="mr-2xs" + >{{ $locale.baseText('workflows.item.created') }} {{ formattedCreatedAtDate }} + </span> + </n8n-text> + <div v-if="isTestCard" :class="$style.flex1"> + <n8n-text size="large" color="text-base"> + {{ data.description }} + </n8n-text> + </div> + </div> + </div> + </n8n-card> +</template> + +<script lang="ts"> +import mixins from 'vue-typed-mixins'; +import type { IUser } from '@/Interface'; +import { VIEWS } from '@/constants'; +import { showMessage } from '@/mixins/showMessage'; +import dateformat from 'dateformat'; +import type Vue from 'vue'; +import { mapStores } from 'pinia'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; +import WorkflowActivator from './WorkflowActivator.vue'; + +type ActivatorRef = InstanceType<typeof WorkflowActivator>; + +export default mixins(showMessage).extend({ + name: 'test-suite-card', + data() { + return {}; + }, + components: {}, + props: { + isTestCard: { + type: Boolean, + default: false, + }, + data: { + type: Object, + required: true, + default: { + id: '', + createdAt: '', + updatedAt: '', + active: false, + connections: {}, + nodes: [], + name: '', + sharedWith: [], + ownedBy: {} as IUser, + versionId: '', + description: '', + }, + }, + readonly: { + type: Boolean, + default: false, + }, + }, + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + formattedCreatedAtDate(): string { + const currentYear = new Date().getFullYear(); + return dateformat( + this.data.createdAt, + `d mmmm${this.data.createdAt.startsWith(currentYear) ? '' : ', yyyy'}`, + ); + }, + }, + methods: { + async onClick(event?: PointerEvent) { + const view = this.isTestCard + ? { + name: VIEWS.TEST_SUITE_NODES, + params: { workflow: this.$route.params.workflow, test: this.data.id }, + } + : { + name: VIEWS.TEST_SUITE, + params: { workflow: this.data.id, test: '' }, + }; + if (event) { + if ((this.$refs.activator as ActivatorRef)?.$el.contains(event.target as HTMLElement)) { + return; + } + if (event.metaKey || event.ctrlKey) { + const route = this.$router.resolve(view); + window.open(route.href, '_blank'); + return; + } + } + + this.$router.push(view); + }, + }, +}); +</script> + +<style lang="scss" module> +.cardLink { + transition: box-shadow 0.3s ease; + cursor: pointer; + + &:hover { + box-shadow: 0 2px 8px rgba(#441c17, 0.1); + } +} + +.cardHeading { + font-size: var(--font-size-s); + word-break: break-word; +} + +.cardDescription { + min-height: 19px; + display: flex; + align-items: center; + justify-content: flex-start; + flex-flow: nowrap; + gap: 16px; +} + +.flex1 { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex: 1; +} +</style> diff --git a/packages/editor-ui/src/components/TestSuiteNodeCard.vue b/packages/editor-ui/src/components/TestSuiteNodeCard.vue new file mode 100644 index 0000000000000..405f95ac4a286 --- /dev/null +++ b/packages/editor-ui/src/components/TestSuiteNodeCard.vue @@ -0,0 +1,116 @@ +<template> + <n8n-card :class="$style.cardLink"> + <div :class="$style.cardDescription"> + <div :class="$style.flex1"> + <n8n-heading + tag="h2" + bold + class="ph-no-capture" + :class="$style.cardHeading" + data-test-id="workflow-card-name" + > + Name: {{ data.name }} + <br /> + ID: {{ data.id }} + <br /> + Type: {{ data.type || '' }} + </n8n-heading> + </div> + + <div :class="$style.flex1"> + <n8n-text size="large" color="text-base"> + {{ getOutput(data.id) }} + </n8n-text> + </div> + <div :class="$style.flexNorm"> + <n8n-button size="large" @click="editTestOutput"> + {{ $locale.baseText('testSuites.edit') }} + </n8n-button> + </div> + </div> + </n8n-card> +</template> + +<script lang="ts"> +import mixins from 'vue-typed-mixins'; +import { showMessage } from '@/mixins/showMessage'; +import type Vue from 'vue'; +import { mapStores } from 'pinia'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; +import { EDIT_TEST_SUITE_MODAL_KEY } from '@/constants'; + +export default mixins(showMessage).extend({ + name: 'test-suite-node-card', + data() { + return {}; + }, + components: {}, + props: { + data: { + type: Object, + required: true, + default: { + id: '', + name: '', + type: '', + }, + }, + }, + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + }, + methods: { + getOutput(id: string) { + return id; + }, + editTestOutput() { + this.openEditTestModal(); + }, + openEditTestModal(): void { + this.uiStore.openModalWithData({ + name: EDIT_TEST_SUITE_MODAL_KEY, + data: this.data, + }); + }, + }, +}); +</script> + +<style lang="scss" module> +.cardLink { + transition: box-shadow 0.3s ease; + + &:hover { + box-shadow: 0 2px 8px rgba(#441c17, 0.1); + } +} + +.cardHeading { + font-size: var(--font-size-s); + word-break: break-word; +} + +.cardDescription { + min-height: 19px; + display: flex; + align-items: center; + justify-content: flex-start; + flex-flow: nowrap; + gap: 16px; +} + +.flex1 { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex: 1; +} +.flexNorm { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} +</style> diff --git a/packages/editor-ui/src/components/layouts/TestsListLayout.vue b/packages/editor-ui/src/components/layouts/TestsListLayout.vue new file mode 100644 index 0000000000000..e2a68dfdfee94 --- /dev/null +++ b/packages/editor-ui/src/components/layouts/TestsListLayout.vue @@ -0,0 +1,173 @@ +<template> + <page-view-layout> + <div v-if="loading"> + <n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" /> + <n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" /> + <n8n-loading :class="$style['card-loading']" variant="custom" /> + </div> + <template v-else> + <div v-if="testSuites.length === 0"> + <n8n-action-box + :heading=" + $locale.baseText('testSuites.workflows.tests.heading', { + interpolate: { name: currentWorkFlowName }, + }) + " + :description="$locale.baseText('testSuites.workflows.tests.empty.description')" + :buttonText="$locale.baseText('testSuites.add')" + buttonType="secondary" + @click="$emit('click:add', $event)" + /> + </div> + <page-view-layout-list v-else> + <template #header> + <div :class="[$style['flex'], 'mb-2xs']"> + <div> + <n8n-heading tag="h2" size="xlarge"> + {{ + $locale.baseText('testSuites.workflows.tests.heading', { + interpolate: { name: currentWorkFlowName }, + }) + }} + </n8n-heading> + </div> + <div> + <n8n-button + size="large" + block + :disabled="disabled" + @click="$emit('click:add', $event)" + > + {{ $locale.baseText('testSuites.add') }} + </n8n-button> + </div> + </div> + </template> + + <div v-if="testSuites.length > 0" :class="$style.listWrapper" ref="listWrapperRef"> + <n8n-recycle-scroller + :class="[$style.list, 'list-style-none']" + :items="testSuites" + item-key="id" + :item-size="0" + > + <template #default="{ item, updateItemSize }"> + <slot :data="item" :updateItemSize="updateItemSize" /> + </template> + </n8n-recycle-scroller> + </div> + </page-view-layout-list> + </template> + </page-view-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import type { IUser } from '@/Interface'; +import mixins from 'vue-typed-mixins'; + +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import TemplateCard from '@/components/TemplateCard.vue'; +import type { PropType } from 'vue'; +import type Vue from 'vue'; +import { debounceHelper } from '@/mixins/debounce'; +import ResourceOwnershipSelect from '@/components/forms/ResourceOwnershipSelect.ee.vue'; +import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; +import { mapStores } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { useUsersStore } from '@/stores/users'; + +export interface IResource { + id: string; + name: string; + updatedAt: string; + createdAt: string; + ownedBy?: Partial<IUser>; + sharedWith?: Array<Partial<IUser>>; +} + +export default mixins(showMessage, debounceHelper).extend({ + name: 'tests-list-layout', + components: { + TemplateCard, + PageViewLayout, + PageViewLayoutList, + ResourceOwnershipSelect, + ResourceFiltersDropdown, + }, + props: { + displayName: { + type: Function as PropType<(resource: IResource) => string>, + default: (resource: IResource) => resource.name, + }, + testSuites: { + type: Array, + default: (): IResource[] => [], + }, + disabled: { + type: Boolean, + default: false, + }, + initialize: { + type: Function as PropType<() => Promise<void>>, + default: () => async () => {}, + }, + currentWorkFlowName: { + type: String, + default: '', + }, + }, + data() { + return { + loading: true, + }; + }, + computed: { + ...mapStores(useSettingsStore, useUsersStore), + }, + methods: { + async onMounted() { + await this.initialize(); + this.loading = false; + }, + }, + mounted() { + this.onMounted(); + }, + watch: {}, +}); +</script> + +<style lang="scss" module> +.heading-wrapper { + padding-bottom: 1px; // Match input height +} + +.flex { + display: flex; + justify-content: space-between; + align-items: center; + flex-flow: row nowrap; +} + +.search { + max-width: 240px; +} + +.list { + display: block; +} + +.listWrapper { + height: 100%; +} + +.header-loading { + height: 36px; +} + +.card-loading { + height: 69px; +} +</style> diff --git a/packages/editor-ui/src/components/layouts/TestsListNodeLayout.vue b/packages/editor-ui/src/components/layouts/TestsListNodeLayout.vue new file mode 100644 index 0000000000000..cb7fcd06e7c1a --- /dev/null +++ b/packages/editor-ui/src/components/layouts/TestsListNodeLayout.vue @@ -0,0 +1,150 @@ +<template> + <page-view-layout> + <div v-if="loading"> + <n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" /> + <n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" /> + <n8n-loading :class="$style['card-loading']" variant="custom" /> + </div> + + <page-view-layout-list v-else> + <template #header> + <div :class="[$style['flex'], 'mb-2xs']"> + <div> + <n8n-heading tag="h2" size="xlarge"> + {{ + $locale.baseText('testSuites.workflows.tests.node.heading', { + interpolate: { id: currentTestSuiteName }, + }) + }} + </n8n-heading> + </div> + <div> + <n8n-heading tag="h2" size="xlarge"> + {{ currentTestSuiteDescription }} + </n8n-heading> + </div> + </div> + </template> + + <div v-if="workFlowNodes.length > 0" :class="$style.listWrapper" ref="listWrapperRef"> + <n8n-recycle-scroller + :class="[$style.list, 'list-style-none']" + :items="workFlowNodes" + item-key="id" + :item-size="0" + > + <template #default="{ item, updateItemSize }"> + <slot :data="item" :updateItemSize="updateItemSize" /> + </template> + </n8n-recycle-scroller> + </div> + </page-view-layout-list> + </page-view-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import type { IUser } from '@/Interface'; +import mixins from 'vue-typed-mixins'; + +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import TemplateCard from '@/components/TemplateCard.vue'; +import type { PropType } from 'vue'; +import type Vue from 'vue'; +import { debounceHelper } from '@/mixins/debounce'; +import ResourceOwnershipSelect from '@/components/forms/ResourceOwnershipSelect.ee.vue'; +import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; +import { mapStores } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { useUsersStore } from '@/stores/users'; + +export interface IResource { + id: string; + name: string; + updatedAt: string; + createdAt: string; + ownedBy?: Partial<IUser>; + sharedWith?: Array<Partial<IUser>>; +} + +export default mixins(showMessage, debounceHelper).extend({ + name: 'tests-list-node-layout', + components: { + TemplateCard, + PageViewLayout, + PageViewLayoutList, + ResourceOwnershipSelect, + ResourceFiltersDropdown, + }, + props: { + workFlowNodes: { + type: Array, + default: (): IResource[] => [], + }, + initialize: { + type: Function as PropType<() => Promise<void>>, + default: () => async () => {}, + }, + currentTestSuiteDescription: { + type: String, + default: '', + }, + currentTestSuiteName: { + type: String, + default: '', + }, + }, + data() { + return { + loading: true, + }; + }, + computed: { + ...mapStores(useSettingsStore, useUsersStore), + }, + methods: { + async onMounted() { + await this.initialize(); + this.loading = false; + }, + }, + mounted() { + this.onMounted(); + }, + watch: {}, +}); +</script> + +<style lang="scss" module> +.heading-wrapper { + padding-bottom: 1px; // Match input height +} + +.flex { + display: flex; + justify-content: space-between; + align-items: center; + flex-flow: row nowrap; +} + +.search { + max-width: 240px; +} + +.list { + display: block; +} + +.listWrapper { + height: 100%; +} + +.header-loading { + height: 36px; +} + +.card-loading { + height: 69px; +} +</style> diff --git a/packages/editor-ui/src/components/layouts/WorkflowsListLayout.vue b/packages/editor-ui/src/components/layouts/WorkflowsListLayout.vue new file mode 100644 index 0000000000000..bf1f5d642f6a7 --- /dev/null +++ b/packages/editor-ui/src/components/layouts/WorkflowsListLayout.vue @@ -0,0 +1,459 @@ +<template> + <page-view-layout> + <div v-if="loading"> + <n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" /> + <n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" /> + <n8n-loading :class="$style['card-loading']" variant="custom" /> + </div> + <page-view-layout-list :overflow="type !== 'list'" v-else> + <template #header> + <div class="mb-xs"> + <div :class="$style['filters-row']"> + <n8n-input + :class="[$style['search'], 'mr-2xs']" + :placeholder="$locale.baseText(`${resourceKey}.search.placeholder`)" + v-model="filters.search" + size="medium" + clearable + ref="search" + data-test-id="resources-list-search" + > + <template #prefix> + <n8n-icon icon="search" /> + </template> + </n8n-input> + <div :class="$style['sort-and-filter']"> + <n8n-select v-model="sortBy" size="medium" data-test-id="resources-list-sort"> + <n8n-option + v-for="sortOption in sortOptions" + :key="sortOption" + :value="sortOption" + :label="$locale.baseText(`${resourceKey}.sort.${sortOption}`)" + /> + </n8n-select> + <resource-filters-dropdown + v-if="showFiltersDropdown" + :keys="filterKeys" + :reset="resetFilters" + :value="filters" + @input="$emit('update:filters', $event)" + @update:filtersLength="onUpdateFiltersLength" + > + <template #default="resourceFiltersSlotProps"> + <slot name="filters" v-bind="resourceFiltersSlotProps" /> + </template> + </resource-filters-dropdown> + </div> + </div> + </div> + + <slot name="callout"></slot> + + <div v-show="hasFilters" class="mt-xs"> + <n8n-info-tip :bold="false"> + {{ $locale.baseText(`${resourceKey}.filters.active`) }} + <n8n-link @click="resetFilters" size="small"> + {{ $locale.baseText(`${resourceKey}.filters.active.reset`) }} + </n8n-link> + </n8n-info-tip> + </div> + + <div class="pb-xs" /> + </template> + + <slot name="preamble" /> + + <div + v-if="filteredAndSortedSubviewResources.length > 0" + :class="$style.listWrapper" + ref="listWrapperRef" + > + <n8n-recycle-scroller + v-if="type === 'list'" + data-test-id="resources-list" + :class="[$style.list, 'list-style-none']" + :items="filteredAndSortedSubviewResources" + :item-size="typeProps.itemSize" + item-key="id" + > + <template #default="{ item, updateItemSize }"> + <slot :data="item" :updateItemSize="updateItemSize" /> + </template> + </n8n-recycle-scroller> + <n8n-datatable + v-if="typeProps.columns" + data-test-id="resources-table" + :class="$style.datatable" + :columns="typeProps.columns" + :rows="filteredAndSortedSubviewResources" + :currentPage="currentPage" + :rowsPerPage="rowsPerPage" + @update:currentPage="setCurrentPage" + @update:rowsPerPage="setRowsPerPage" + > + <template #row="{ columns, row }"> + <slot :data="row" :columns="columns" /> + </template> + </n8n-datatable> + </div> + + <n8n-text color="text-base" size="medium" data-test-id="resources-list-empty" v-else> + {{ $locale.baseText(`${resourceKey}.noResults`) }} + <template v-if="shouldSwitchToAllSubview"> + <span v-if="!filters.search"> + ({{ $locale.baseText(`${resourceKey}.noResults.switchToShared.preamble`) }} + <n8n-link @click="setOwnerSubview(false)"> + {{ $locale.baseText(`${resourceKey}.noResults.switchToShared.link`) }} </n8n-link + >) + </span> + + <span v-else> + ({{ $locale.baseText(`${resourceKey}.noResults.withSearch.switchToShared.preamble`) }} + <n8n-link @click="setOwnerSubview(false)"> + {{ + $locale.baseText(`${resourceKey}.noResults.withSearch.switchToShared.link`) + }} </n8n-link + >) + </span> + </template> + </n8n-text> + + <slot name="postamble" /> + </page-view-layout-list> + </page-view-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import type { IUser } from '@/Interface'; +import mixins from 'vue-typed-mixins'; + +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import { EnterpriseEditionFeature } from '@/constants'; +import TemplateCard from '@/components/TemplateCard.vue'; +import type { PropType } from 'vue'; +import type Vue from 'vue'; +import { debounceHelper } from '@/mixins/debounce'; +import ResourceOwnershipSelect from '@/components/forms/ResourceOwnershipSelect.ee.vue'; +import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; +import { mapStores } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { useUsersStore } from '@/stores/users'; +import type { N8nInput } from 'n8n-design-system'; +import type { DatatableColumn } from 'n8n-design-system'; + +export interface IResource { + id: string; + name: string; + updatedAt: string; + createdAt: string; + ownedBy?: Partial<IUser>; + sharedWith?: Array<Partial<IUser>>; +} + +interface IFilters { + search: string; + ownedBy: string; + sharedWith: string; + + [key: string]: boolean | string | string[]; +} + +type IResourceKeyType = 'credentials' | 'workflows'; +type SearchRef = InstanceType<typeof N8nInput>; + +const filterKeys = ['ownedBy', 'sharedWith']; + +export default mixins(showMessage, debounceHelper).extend({ + name: 'workflows-list-layout', + components: { + TemplateCard, + PageViewLayout, + PageViewLayoutList, + ResourceOwnershipSelect, + ResourceFiltersDropdown, + }, + props: { + resourceKey: { + type: String, + default: '' as IResourceKeyType, + }, + displayName: { + type: Function as PropType<(resource: IResource) => string>, + default: (resource: IResource) => resource.name, + }, + resources: { + type: Array, + default: (): IResource[] => [], + }, + disabled: { + type: Boolean, + default: false, + }, + initialize: { + type: Function as PropType<() => Promise<void>>, + default: () => async () => {}, + }, + filters: { + type: Object, + default: (): IFilters => ({ search: '', ownedBy: '', sharedWith: '' }), + }, + additionalFiltersHandler: { + type: Function, + }, + showFiltersDropdown: { + type: Boolean, + default: true, + }, + sortFns: { + type: Object as PropType<Record<string, (a: IResource, b: IResource) => number>>, + default: (): Record<string, (a: IResource, b: IResource) => number> => ({}), + }, + sortOptions: { + type: Array as PropType<string[]>, + default: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'], + }, + type: { + type: String as PropType<'datatable' | 'list'>, + default: 'list', + }, + typeProps: { + type: Object as PropType<{ itemSize: number } | { columns: DatatableColumn[] }>, + default: () => ({ + itemSize: 0, + }), + }, + }, + data() { + return { + loading: true, + isOwnerSubview: false, + sortBy: this.sortOptions[0], + hasFilters: false, + currentPage: 1, + rowsPerPage: 10 as number | '*', + resettingFilters: false, + EnterpriseEditionFeature, + }; + }, + computed: { + ...mapStores(useSettingsStore, useUsersStore), + subviewResources(): IResource[] { + return this.resources as IResource[]; + }, + filterKeys(): string[] { + return Object.keys(this.filters); + }, + filteredAndSortedSubviewResources(): IResource[] { + const filtered: IResource[] = this.subviewResources.filter((resource: IResource) => { + let matches = true; + + if (this.filters.ownedBy) { + matches = matches && !!(resource.ownedBy && resource.ownedBy.id === this.filters.ownedBy); + } + + if (this.filters.sharedWith) { + matches = + matches && + !!( + resource.sharedWith && + resource.sharedWith.find((sharee) => sharee.id === this.filters.sharedWith) + ); + } + + if (this.filters.search) { + const searchString = this.filters.search.toLowerCase(); + + matches = matches && this.displayName(resource).toLowerCase().includes(searchString); + } + + if (this.additionalFiltersHandler) { + matches = this.additionalFiltersHandler(resource, this.filters, matches); + } + + return matches; + }); + + return filtered.sort((a, b) => { + switch (this.sortBy) { + case 'lastUpdated': + return this.sortFns['lastUpdated'] + ? this.sortFns['lastUpdated'](a, b) + : new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); + case 'lastCreated': + return this.sortFns['lastCreated'] + ? this.sortFns['lastCreated'](a, b) + : new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); + case 'nameAsc': + return this.sortFns['nameAsc'] + ? this.sortFns['nameAsc'](a, b) + : this.displayName(a).trim().localeCompare(this.displayName(b).trim()); + case 'nameDesc': + return this.sortFns['nameDesc'] + ? this.sortFns['nameDesc'](a, b) + : this.displayName(b).trim().localeCompare(this.displayName(a).trim()); + default: + return this.sortFns[this.sortBy] ? this.sortFns[this.sortBy](a, b) : 0; + } + }); + }, + resourcesNotOwned(): IResource[] { + return (this.resources as IResource[]).filter((resource) => { + return resource.ownedBy && resource.ownedBy.id !== this.usersStore.currentUser?.id; + }); + }, + shouldSwitchToAllSubview(): boolean { + return !this.hasFilters && this.isOwnerSubview && this.resourcesNotOwned.length > 0; + }, + }, + methods: { + async onMounted() { + await this.initialize(); + + this.loading = false; + this.$nextTick(this.focusSearchInput); + }, + setCurrentPage(page: number) { + this.currentPage = page; + }, + setRowsPerPage(rowsPerPage: number | '*') { + this.rowsPerPage = rowsPerPage; + }, + resetFilters() { + Object.keys(this.filters).forEach((key) => { + this.filters[key] = Array.isArray(this.filters[key]) ? [] : ''; + }); + + this.resettingFilters = true; + this.sendFiltersTelemetry('reset'); + }, + focusSearchInput() { + if (this.$refs.search) { + (this.$refs.search as SearchRef).focus(); + } + }, + setOwnerSubview(active: boolean) { + this.isOwnerSubview = active; + }, + getTelemetrySubview(): string { + return this.$locale.baseText( + `${this.resourceKey as IResourceKeyType}.menu.${this.isOwnerSubview ? 'my' : 'all'}`, + ); + }, + sendSubviewTelemetry() { + this.$telemetry.track(`User changed ${this.resourceKey} sub view`, { + sub_view: this.getTelemetrySubview(), + }); + }, + sendSortingTelemetry() { + this.$telemetry.track(`User changed sorting in ${this.resourceKey} list`, { + sub_view: this.getTelemetrySubview(), + sorting: this.sortBy, + }); + }, + sendFiltersTelemetry(source: string) { + // Prevent sending multiple telemetry events when resetting filters + // Timeout is required to wait for search debounce to be over + if (this.resettingFilters) { + if (source !== 'reset') { + return; + } + + setTimeout(() => (this.resettingFilters = false), 1500); + } + + const filters = this.filters as Record<string, string[] | string | boolean>; + const filtersSet: string[] = []; + const filterValues: Array<string[] | string | boolean | null> = []; + + Object.keys(filters).forEach((key) => { + if (filters[key]) { + filtersSet.push(key); + filterValues.push(key === 'search' ? null : filters[key]); + } + }); + + this.$telemetry.track(`User set filters in ${this.resourceKey} list`, { + filters_set: filtersSet, + filter_values: filterValues, + sub_view: this.getTelemetrySubview(), + [`${this.resourceKey}_total_in_view`]: this.subviewResources.length, + [`${this.resourceKey}_after_filtering`]: this.filteredAndSortedSubviewResources.length, + }); + }, + onUpdateFiltersLength(length: number) { + this.hasFilters = length > 0; + }, + }, + mounted() { + this.onMounted(); + }, + watch: { + isOwnerSubview() { + this.sendSubviewTelemetry(); + }, + 'filters.ownedBy'(value) { + if (value) { + this.setOwnerSubview(false); + } + this.sendFiltersTelemetry('ownedBy'); + }, + 'filters.sharedWith'() { + this.sendFiltersTelemetry('sharedWith'); + }, + 'filters.search'() { + this.callDebounced('sendFiltersTelemetry', { debounceTime: 1000, trailing: true }, 'search'); + }, + sortBy(newValue) { + this.$emit('sort', newValue); + this.sendSortingTelemetry(); + }, + }, +}); +</script> + +<style lang="scss" module> +.heading-wrapper { + padding-bottom: 1px; // Match input height +} + +.filters-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.search { + max-width: 240px; +} + +.list { + //display: flex; + //flex-direction: column; +} + +.listWrapper { + height: 100%; +} + +.sort-and-filter { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.header-loading { + height: 36px; +} + +.card-loading { + height: 69px; +} + +.datatable { + padding-bottom: var(--spacing-s); +} +</style> diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 9fbf469b31d0a..ad32056996ec3 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -44,6 +44,8 @@ export const EXECUTIONS_MODAL_KEY = 'executions'; export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation'; export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup'; export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall'; +export const ADD_TEST_SUITE_MODAL_KEY = 'addTestSuite'; +export const EDIT_TEST_SUITE_MODAL_KEY = 'editTestSuite'; export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm'; export const IMPORT_CURL_MODAL_KEY = 'importCurl'; export const LOG_STREAM_MODAL_KEY = 'settingsLogStream'; @@ -396,6 +398,9 @@ export const enum VIEWS { SSO_SETTINGS = 'SSoSettings', SAML_ONBOARDING = 'SamlOnboarding', VERSION_CONTROL = 'VersionControl', + TEST_SUITES = 'TestSuitesView', + TEST_SUITE = 'TestSuiteView', + TEST_SUITE_NODES = 'TestSuiteNodesView', } export const enum FAKE_DOOR_FEATURES { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 905ef90dad8c4..6db9520c92220 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -933,6 +933,8 @@ "parameterInput.error": "ERROR", "parameterInput.expression": "Expression", "parameterInput.fixed": "Fixed", + "parameterInput.test.error": "Error", + "parameterInput.test.output": "Output", "parameterInput.formatHtml": "Format HTML", "parameterInput.issues": "Issues", "parameterInput.loadingOptions": "Loading options...", @@ -1130,14 +1132,28 @@ "settings.communityNodes.fetchError.title": "Problem fetching installed packages", "settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance", "settings.communityNodes.installModal.title": "Install community nodes", + "testSuites.addTestSuite.title": "Add Test", + "testSuites.editTestSuite.title": "Edit {name} Test Output", "settings.communityNodes.installModal.description": "Find community nodes to add on the npm public registry.", "settings.communityNodes.browseButton.label": "Browse", "settings.communityNodes.installModal.packageName.label": "npm Package Name", + "testSuites.addTest.description.label": "Description", "settings.communityNodes.installModal.packageName.tooltip": "<img src='/static/community_package_tooltip_img.png'/><p>This is the title of the package on <a href='{npmURL}'>npmjs.com</a></p><p>Install a specific version by adding it after @, e.g. <code>package-name@0.15.0</code></p>", + "testSuites.addTest.description.tooltip": "Briefly describe this text and provide information of what it does.", "settings.communityNodes.installModal.packageName.placeholder": "e.g. n8n-nodes-chatwork", "settings.communityNodes.installModal.checkbox.label": "I understand the risks of installing unverified code from a public source.", "settings.communityNodes.installModal.installButton.label": "Install", "settings.communityNodes.installModal.installButton.label.loading": "Installing", + "testSuites.addTest.saveButton.label.loading": "Saving", + "testSuites.editTest.error.label": "Error", + "testSuites.editTest.output.label": "Output", + "testSuites.editTest.saveButton.label.loading": "Saving", + "testSuites.addTest.saveButton.label": "Save", + "testSuites.editTest.saveButton.label": "Save", + "testSuites.addTest.saveButton.success": "Test Added", + "testSuites.editTest.saveButton.success": "Output Updated", + "testSuites.addTest.saveButton.error": "Error adding new test", + "testSuites.editTest.saveButton.error": "Error updating test", "settings.communityNodes.installModal.error.packageNameNotValid": "Package name must start with n8n-nodes-", "settings.communityNodes.messages.install.success": "Package installed", "settings.communityNodes.messages.install.error": "Error installing new package", @@ -1569,7 +1585,13 @@ "workflows.noResults.switchToShared.link": "shared with you", "workflows.empty.heading": "👋 Welcome {name}!", "workflows.empty.heading.userNotSetup": "👋 Welcome!", + "testSuites.workflows.tests.heading": "{name} tests", + "testSuites.workflows.tests.node.heading": "Test ID: {id}", + "testSuites.add": "Add test", + "testSuites.edit": "Edit Output", "workflows.empty.description": "Create your first workflow", + "testSuites.workflows.empty.description": "You have no workflow", + "testSuites.workflows.tests.empty.description": "You have no tests for this workflow", "workflows.empty.startFromScratch": "Start from scratch", "workflows.shareModal.title": "Share '{name}'", "workflows.shareModal.select.placeholder": "Add users...", @@ -1649,7 +1671,6 @@ "contextual.credentials.sharing.unavailable.button": "View plans", "contextual.credentials.sharing.unavailable.button.cloud": "Upgrade now", "contextual.credentials.sharing.unavailable.button.desktop": "View plans", - "contextual.workflows.sharing.title": "Sharing", "contextual.workflows.sharing.unavailable.title": "Sharing", "contextual.workflows.sharing.unavailable.title.cloud": "Upgrade to collaborate", @@ -1663,7 +1684,6 @@ "contextual.workflows.sharing.unavailable.button": "View plans", "contextual.workflows.sharing.unavailable.button.cloud": "Upgrade now", "contextual.workflows.sharing.unavailable.button.desktop": "View plans", - "contextual.variables.unavailable.title": "Available on Enterprise plan", "contextual.variables.unavailable.title.cloud": "Available on Power plan", "contextual.variables.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate", @@ -1671,7 +1691,6 @@ "contextual.variables.unavailable.button": "View plans", "contextual.variables.unavailable.button.cloud": "Upgrade now", "contextual.variables.unavailable.button.desktop": "View plans", - "contextual.users.settings.unavailable.title": "Upgrade to add users", "contextual.users.settings.unavailable.title.cloud": "Upgrade to add users", "contextual.users.settings.unavailable.title.desktop": "Upgrade to add users", @@ -1681,15 +1700,13 @@ "contextual.users.settings.unavailable.button": "View plans", "contextual.users.settings.unavailable.button.cloud": "Upgrade now", "contextual.users.settings.unavailable.button.desktop": "View plans", - "contextual.communityNodes.unavailable.description.desktop": "Community nodes feature is unavailable on desktop. Please choose one of our available self-hosting plans.", "contextual.communityNodes.unavailable.button.desktop": "View plans", - "contextual.upgradeLinkUrl": "https://subscription.n8n.io/", "contextual.upgradeLinkUrl.cloud": "https://app.n8n.cloud/account/change-plan", "contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing/?utm_source=n8n-internal&utm_medium=desktop", - "settings.ldap": "LDAP", + "settings.testSuites": "Test Suites", "settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.", "settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>", "settings.ldap.save": "Save connection", @@ -1802,4 +1819,4 @@ "userActivationSurveyModal.sharedFeedback.error": "Problem sharing feedback, try again", "sso.login.divider": "or", "sso.login.button": "Continue with SSO" -} +} \ No newline at end of file diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 2d7920a06b518..ca562803c6e15 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -30,6 +30,8 @@ import CredentialsView from '@/views/CredentialsView.vue'; import ExecutionsView from '@/views/ExecutionsView.vue'; import WorkflowsView from '@/views/WorkflowsView.vue'; import VariablesView from '@/views/VariablesView.vue'; +import TestSuitesView from '@/views/TestSuitesView.vue'; +import TestSuiteView from '@/views/TestSuiteView.vue'; import type { IPermissions } from './Interface'; import { LOGIN_STATUS, ROLE } from '@/utils'; import type { RouteConfigSingleView } from 'vue-router/types/router'; @@ -43,6 +45,7 @@ import SignoutView from '@/views/SignoutView.vue'; import SamlOnboarding from '@/views/SamlOnboarding.vue'; import SettingsVersionControl from './views/SettingsVersionControl.vue'; import { usePostHog } from './stores/posthog'; +import TestSuiteNodeView from './views/TestSuiteNodeView.vue'; Vue.use(Router); @@ -675,6 +678,71 @@ export const routes = [ }, }, }, + { + path: 'test-suites', + name: VIEWS.TEST_SUITES, + components: { + settingsView: TestSuitesView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + deny: {}, + }, + }, + }, + { + path: 'test-suites/:workflow', + name: VIEWS.TEST_SUITE, + components: { + settingsView: TestSuiteView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + workflow: route.params['workflow'], + }; + }, + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + deny: {}, + }, + }, + }, + { + path: 'test-suites/:workflow/:test', + name: VIEWS.TEST_SUITE_NODES, + components: { + settingsView: TestSuiteNodeView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + workflow: route.params['workflow'], + test: route.params['test'], + }; + }, + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + deny: {}, + }, + }, + }, { path: 'coming-soon/:featureId', name: VIEWS.FAKE_DOOR, diff --git a/packages/editor-ui/src/stores/ui.ts b/packages/editor-ui/src/stores/ui.ts index cc9ce72dccca3..323e9beec141d 100644 --- a/packages/editor-ui/src/stores/ui.ts +++ b/packages/editor-ui/src/stores/ui.ts @@ -31,6 +31,8 @@ import { WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY, USER_ACTIVATION_SURVEY_MODAL, + ADD_TEST_SUITE_MODAL_KEY, + EDIT_TEST_SUITE_MODAL_KEY, } from '@/constants'; import type { CurlToJSONResponse, @@ -112,6 +114,12 @@ export const useUIStore = defineStore(STORES.UI, { [COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { open: false, }, + [ADD_TEST_SUITE_MODAL_KEY]: { + open: false, + }, + [EDIT_TEST_SUITE_MODAL_KEY]: { + open: false, + }, [COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { open: false, mode: '', diff --git a/packages/editor-ui/src/stores/workflows.ts b/packages/editor-ui/src/stores/workflows.ts index c1aef4c6010b8..3d085227a9c89 100644 --- a/packages/editor-ui/src/stores/workflows.ts +++ b/packages/editor-ui/src/stores/workflows.ts @@ -29,6 +29,8 @@ import type { IWorkflowDataUpdate, IWorkflowDb, IWorkflowsMap, + TestSuiteDb, + TestSuiteDbMap, WorkflowsState, } from '@/Interface'; import { defineStore } from 'pinia'; @@ -64,8 +66,11 @@ import { getExecutionData, getExecutions, getNewWorkflow, + getTestSuite, getWorkflow, getWorkflows, + patchTestSuite, + postTestSuite, } from '@/api/workflows'; import { useUIStore } from './ui'; import { dataPinningEventBus } from '@/event-bus'; @@ -114,6 +119,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { workflowExecutionData: null, workflowExecutionPairedItemMappings: {}, workflowsById: {}, + testSuitesById: {}, subWorkflowExecutionError: null, activeExecutionId: null, executingNode: null, @@ -143,6 +149,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { allWorkflows(): IWorkflowDb[] { return Object.values(this.workflowsById).sort((a, b) => a.name.localeCompare(b.name)); }, + allTestSuites(): TestSuiteDb[] { + return Object.values(this.testSuitesById).sort((a, b) => a.name.localeCompare(b.name)); + }, isNewWorkflow(): boolean { return this.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID; }, @@ -373,6 +382,29 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { return workflows; }, + async fetchWorkflowTestSuites(workflowId: string): Promise<TestSuiteDb[]> { + const testSuites = await getTestSuite(workflowId); + this.setTestSuites(testSuites); + return testSuites; + }, + + async addWorkflowTestSuite(workflowId: string, description: string): Promise<TestSuiteDb[]> { + const testSuites = await postTestSuite(workflowId, description); + this.setTestSuites(testSuites); + return testSuites; + }, + + async updateWorkflowTestSuite(payload: { + workflowId: string; + testId: string; + id: string; + outputType: string; + error: string; + output: string; + }) { + await patchTestSuite(payload); + }, + async fetchWorkflow(id: string): Promise<IWorkflowDb> { const rootStore = useRootStore(); const workflow = await getWorkflow(rootStore.getRestApiContext, id); @@ -492,6 +524,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { }, {}); }, + setTestSuites(testSuites: TestSuiteDb[]): void { + this.testSuitesById = testSuites.reduce<TestSuiteDbMap>((acc, testSuite: TestSuiteDb) => { + if (testSuite.id) { + acc[testSuite.id] = testSuite; + } + return acc; + }, {}); + }, + resetWorkflowTestSuites(): void { + this.testSuitesById = {}; + }, + async deleteWorkflow(id: string): Promise<void> { const rootStore = useRootStore(); await makeRestApiRequest(rootStore.getRestApiContext, 'DELETE', `/workflows/${id}`); diff --git a/packages/editor-ui/src/views/TestSuiteNodeView.vue b/packages/editor-ui/src/views/TestSuiteNodeView.vue new file mode 100644 index 0000000000000..7a2a973dc62f9 --- /dev/null +++ b/packages/editor-ui/src/views/TestSuiteNodeView.vue @@ -0,0 +1,103 @@ +<template> + <tests-list-node-layout + ref="layout" + :workFlowNodes="currentWorkFlow?.nodes" + :initialize="initialize" + :currentTestSuiteName="currentTestSuite?.name || ''" + :currentTestSuiteDescription="currentTestSuite?.description || ''" + > + <template #default="{ data }"> + <test-suite-node-card class="mb-2xs" :data="data" /> + </template> + </tests-list-node-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import mixins from 'vue-typed-mixins'; + +import SettingsView from './SettingsView.vue'; +import TestsListNodeLayout from '@/components/layouts/TestsListNodeLayout.vue'; +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import TestSuiteNodeCard from '@/components/TestSuiteNodeCard.vue'; +import TemplateCard from '@/components/TemplateCard.vue'; +import { debounceHelper } from '@/mixins/debounce'; +import type Vue from 'vue'; +import type { IWorkflowDb, TestSuiteDb } from '@/Interface'; +import { mapStores } from 'pinia'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; + +const TestSuiteNodeView = mixins(showMessage, debounceHelper).extend({ + name: 'TestSuiteNodeView', + components: { + TestsListNodeLayout, + TemplateCard, + PageViewLayout, + PageViewLayoutList, + SettingsView, + TestSuiteNodeCard, + }, + data() { + return {}; + }, + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + currentWorkFlow(): IWorkflowDb | null { + return this.workflowsStore.workflowsById[this.$route.params.workflow] || null; + }, + currentTestSuite(): TestSuiteDb | null { + return this.workflowsStore.testSuitesById[this.$route.params.test] || null; + }, + testSuites(): TestSuiteDb[] { + return this.workflowsStore.allTestSuites; + }, + }, + methods: { + async initialize() { + await this.workflowsStore.fetchWorkflow(this.$route.params.workflow); + await this.workflowsStore.fetchWorkflowTestSuites(this.$route.params.workflow); + }, + }, + watch: {}, + mounted() {}, + destroyed() {}, +}); + +export default TestSuiteNodeView; +</script> + +<style lang="scss" module> +.actionsContainer { + display: flex; + justify-content: center; +} + +.emptyStateCard { + width: 192px; + text-align: center; + display: inline-flex; + height: 230px; + + & + & { + margin-left: var(--spacing-s); + } + + &:hover { + svg { + color: var(--color-primary); + } + } +} + +.emptyStateCardIcon { + font-size: 48px; + + svg { + width: 48px !important; + color: var(--color-foreground-dark); + transition: color 0.3s ease; + } +} +</style> diff --git a/packages/editor-ui/src/views/TestSuiteView.vue b/packages/editor-ui/src/views/TestSuiteView.vue new file mode 100644 index 0000000000000..b1dfbb3038bb2 --- /dev/null +++ b/packages/editor-ui/src/views/TestSuiteView.vue @@ -0,0 +1,149 @@ +<template> + <tests-list-layout + ref="layout" + :testSuites="testSuites" + :initialize="initialize" + :currentWorkFlowName="currentWorkFlow?.name || ''" + @click:add="addTestSuite" + > + <template #default="{ data, updateItemSize }"> + <test-suite-card + data-test-id="resources-list-item" + class="mb-2xs" + :data="data" + @expand:tags="updateItemSize(data)" + :isTestCard="true" + /> + </template> + <template #empty> + <div class="text-center mt-s"> + <n8n-text size="large" color="text-base"> + {{ $locale.baseText('testSuites.workflows.tests.empty.description') }} + </n8n-text> + </div> + </template> + </tests-list-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import mixins from 'vue-typed-mixins'; + +import SettingsView from './SettingsView.vue'; +import TestsListLayout from '@/components/layouts/TestsListLayout.vue'; +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import TestSuiteCard from '@/components/TestSuiteCard.vue'; +import TemplateCard from '@/components/TemplateCard.vue'; +import { debounceHelper } from '@/mixins/debounce'; +import type Vue from 'vue'; +import type { IWorkflowDb, TestSuiteDb } from '@/Interface'; +import { mapStores } from 'pinia'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; +import { ADD_TEST_SUITE_MODAL_KEY } from '@/constants'; + +const StatusFilter = { + ACTIVE: true, + DEACTIVATED: false, + ALL: '', +}; + +const TestSuitesView = mixins(showMessage, debounceHelper).extend({ + name: 'TestSuitesView', + components: { + TestsListLayout, + TemplateCard, + PageViewLayout, + PageViewLayoutList, + SettingsView, + TestSuiteCard, + }, + data() { + return {}; + }, + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + currentWorkFlow(): IWorkflowDb | null { + return this.workflowsStore.workflowsById[this.$route.params.workflow] || null; + }, + testSuites(): TestSuiteDb[] { + return this.workflowsStore.allTestSuites; + }, + hasActiveWorkflows(): boolean { + return !!this.workflowsStore.activeWorkflows.length; + }, + statusFilterOptions(): Array<{ label: string; value: string | boolean }> { + return [ + { + label: this.$locale.baseText('workflows.filters.status.all'), + value: StatusFilter.ALL, + }, + { + label: this.$locale.baseText('workflows.filters.status.active'), + value: StatusFilter.ACTIVE, + }, + { + label: this.$locale.baseText('workflows.filters.status.deactivated'), + value: StatusFilter.DEACTIVATED, + }, + ]; + }, + }, + methods: { + async initialize() { + await this.workflowsStore.fetchWorkflow(this.$route.params.workflow); + await this.workflowsStore.fetchWorkflowTestSuites(this.$route.params.workflow); + }, + addTestSuite() { + this.openAddTestModal(); + }, + openAddTestModal(): void { + this.uiStore.openModalWithData({ + name: ADD_TEST_SUITE_MODAL_KEY, + data: { workflow: this.$route.params.workflow }, + }); + }, + }, + watch: {}, + mounted() { + this.workflowsStore.resetWorkflowTestSuites(); + }, +}); + +export default TestSuitesView; +</script> + +<style lang="scss" module> +.actionsContainer { + display: flex; + justify-content: center; +} + +.emptyStateCard { + width: 192px; + text-align: center; + display: inline-flex; + height: 230px; + + & + & { + margin-left: var(--spacing-s); + } + + &:hover { + svg { + color: var(--color-primary); + } + } +} + +.emptyStateCardIcon { + font-size: 48px; + + svg { + width: 48px !important; + color: var(--color-foreground-dark); + transition: color 0.3s ease; + } +} +</style> diff --git a/packages/editor-ui/src/views/TestSuitesView.vue b/packages/editor-ui/src/views/TestSuitesView.vue new file mode 100644 index 0000000000000..443035a7fc8ca --- /dev/null +++ b/packages/editor-ui/src/views/TestSuitesView.vue @@ -0,0 +1,185 @@ +<template> + <workflows-list-layout + ref="layout" + resource-key="workflows" + :resources="allWorkflows" + :filters="filters" + :additional-filters-handler="onFilter" + :initialize="initialize" + @update:filters="filters = $event" + > + <template #default="{ data, updateItemSize }"> + <test-suite-card + data-test-id="resources-list-item" + class="mb-2xs" + :data="data" + @expand:tags="updateItemSize(data)" + /> + </template> + <template #empty> + <div class="text-center mt-s"> + <n8n-text size="large" color="text-base"> + {{ $locale.baseText('testSuites.workflows.empty.description') }} + </n8n-text> + </div> + </template> + <template #filters="{ setKeyValue }"> + <div class="mb-s"> + <n8n-input-label + :label="$locale.baseText('workflows.filters.status')" + :bold="false" + size="small" + color="text-base" + class="mb-3xs" + /> + <n8n-select :value="filters.status" @input="setKeyValue('status', $event)" size="medium"> + <n8n-option + v-for="option in statusFilterOptions" + :key="option.label" + :label="option.label" + :value="option.value" + > + </n8n-option> + </n8n-select> + </div> + </template> + </workflows-list-layout> +</template> + +<script lang="ts"> +import { showMessage } from '@/mixins/showMessage'; +import mixins from 'vue-typed-mixins'; + +import SettingsView from './SettingsView.vue'; +import WorkflowsListLayout from '@/components/layouts/WorkflowsListLayout.vue'; +import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; +import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; +import TestSuiteCard from '@/components/TestSuiteCard.vue'; +import TemplateCard from '@/components/TemplateCard.vue'; +import { debounceHelper } from '@/mixins/debounce'; +import type Vue from 'vue'; +import type { IWorkflowDb } from '@/Interface'; +import { mapStores } from 'pinia'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; + +type IResourcesListLayoutInstance = Vue & { sendFiltersTelemetry: (source: string) => void }; + +const StatusFilter = { + ACTIVE: true, + DEACTIVATED: false, + ALL: '', +}; + +const TestSuitesView = mixins(showMessage, debounceHelper).extend({ + name: 'TestSuitesView', + components: { + WorkflowsListLayout, + TemplateCard, + PageViewLayout, + PageViewLayoutList, + SettingsView, + TestSuiteCard, + }, + data() { + return { + filters: { + search: '', + ownedBy: '', + sharedWith: '', + status: StatusFilter.ALL, + tags: [] as string[], + }, + }; + }, + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + allWorkflows(): IWorkflowDb[] { + return this.workflowsStore.allWorkflows; + }, + hasActiveWorkflows(): boolean { + return !!this.workflowsStore.activeWorkflows.length; + }, + statusFilterOptions(): Array<{ label: string; value: string | boolean }> { + return [ + { + label: this.$locale.baseText('workflows.filters.status.all'), + value: StatusFilter.ALL, + }, + { + label: this.$locale.baseText('workflows.filters.status.active'), + value: StatusFilter.ACTIVE, + }, + { + label: this.$locale.baseText('workflows.filters.status.deactivated'), + value: StatusFilter.DEACTIVATED, + }, + ]; + }, + }, + methods: { + async initialize() { + await Promise.all([ + this.workflowsStore.fetchAllWorkflows(), + this.workflowsStore.fetchActiveWorkflows(), + ]); + }, + onFilter( + resource: IWorkflowDb, + filters: { tags: string[]; search: string; status: string | boolean }, + matches: boolean, + ): boolean { + if (filters.status !== '') { + matches = matches && resource.active === filters.status; + } + + return matches; + }, + sendFiltersTelemetry(source: string) { + (this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source); + }, + }, + watch: { + 'filters.tags'() { + this.sendFiltersTelemetry('tags'); + }, + }, + mounted() {}, +}); + +export default TestSuitesView; +</script> + +<style lang="scss" module> +.actionsContainer { + display: flex; + justify-content: center; +} + +.emptyStateCard { + width: 192px; + text-align: center; + display: inline-flex; + height: 230px; + + & + & { + margin-left: var(--spacing-s); + } + + &:hover { + svg { + color: var(--color-primary); + } + } +} + +.emptyStateCardIcon { + font-size: 48px; + + svg { + width: 48px !important; + color: var(--color-foreground-dark); + transition: color 0.3s ease; + } +} +</style> diff --git a/packages/nodes-base/nodes/EsaTwilio/EsaTwilio.node.ts b/packages/nodes-base/nodes/EsaTwilio/EsaTwilio.node.ts index c303eca51b797..c93e3a03b221b 100644 --- a/packages/nodes-base/nodes/EsaTwilio/EsaTwilio.node.ts +++ b/packages/nodes-base/nodes/EsaTwilio/EsaTwilio.node.ts @@ -325,7 +325,6 @@ export class EsaTwilio implements INodeType { { itemIndex: i }, ); } - if (isSmsChatReusableUsed) { const mainPhoneOptedOutChat = await findOptedOutChat(to); if (mainPhoneOptedOutChat) { diff --git a/packages/nodes-base/nodes/EsaTwilio/GenericFunctions.ts b/packages/nodes-base/nodes/EsaTwilio/GenericFunctions.ts index bd57ae4a0f990..1bb54d638a66a 100644 --- a/packages/nodes-base/nodes/EsaTwilio/GenericFunctions.ts +++ b/packages/nodes-base/nodes/EsaTwilio/GenericFunctions.ts @@ -194,10 +194,10 @@ export function transformDataToSendSMS( fallbackPhone, useFallbackPhone, }); - body.Body = message; - body.MediaUrl = mediaUrls; - body.mediaUrl = mediaUrls; - body.Media_Url = mediaUrls; + body.Body = message || ''; + if (mediaUrls.length) { + body.MediaUrl = mediaUrls; + } if (toWhatsapp) { body.From = `whatsapp:${body.From}`; @@ -218,7 +218,7 @@ export async function getMessagingServices(this: ILoadOptionsFunctions) { })); return [ - { name: 'DEFAULT SMS MESSAGING SERVICE SID', value: DEFAULT_MESSAGING_SERVICE_CUSTOM_SID }, + { name: 'DEFAULT SMS MESSAGING SERVICE', value: DEFAULT_MESSAGING_SERVICE_CUSTOM_SID }, ].concat(sortBy(options, (o) => o.name)); } //