mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): VariablesView Reskin - Add Filters for missing values (#12611)
This commit is contained in:
parent
652b8d170b
commit
1eeb788d32
|
@ -65,8 +65,11 @@ describe('Variables', () => {
|
|||
const editingRow = variablesPage.getters.variablesEditableRows().eq(0);
|
||||
variablesPage.actions.setRowValue(editingRow, 'key', key);
|
||||
variablesPage.actions.setRowValue(editingRow, 'value', value);
|
||||
editingRow.should('contain', 'This field may contain only letters');
|
||||
variablesPage.getters.editableRowSaveButton(editingRow).should('be.disabled');
|
||||
variablesPage.actions.saveRowEditing(editingRow);
|
||||
variablesPage.getters
|
||||
.variablesEditableRows()
|
||||
.eq(0)
|
||||
.should('contain', 'This field may contain only letters');
|
||||
variablesPage.actions.cancelRowEditing(editingRow);
|
||||
|
||||
variablesPage.getters.variablesRows().should('have.length', 3);
|
||||
|
|
|
@ -68,7 +68,10 @@ export class VariablesPage extends BasePage {
|
|||
},
|
||||
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
|
||||
row.within(() => {
|
||||
cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value);
|
||||
cy.getByTestId(`variable-row-${field}-input`)
|
||||
.find('input, textarea')
|
||||
.type('{selectAll}{del}')
|
||||
.type(value);
|
||||
});
|
||||
},
|
||||
cancelRowEditing: (row: Chainable<JQuery<HTMLElement>>) => {
|
||||
|
|
|
@ -10,8 +10,8 @@ import N8nText from '../N8nText';
|
|||
interface ActionBoxProps {
|
||||
emoji: string;
|
||||
heading: string;
|
||||
buttonText: string;
|
||||
buttonType: ButtonType;
|
||||
buttonText?: string;
|
||||
buttonType?: ButtonType;
|
||||
buttonDisabled?: boolean;
|
||||
buttonIcon?: string;
|
||||
description: string;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, useCssModule } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import type { DatatableColumn, DatatableRow, DatatableRowDataType } from '../../types';
|
||||
|
@ -33,8 +33,6 @@ const emit = defineEmits<{
|
|||
const { t } = useI18n();
|
||||
const rowsPerPageOptions = ref([1, 10, 25, 50, 100]);
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(props.rows.length / props.rowsPerPage);
|
||||
});
|
||||
|
@ -52,11 +50,6 @@ const visibleRows = computed(() => {
|
|||
return props.rows.slice(start, end);
|
||||
});
|
||||
|
||||
const classes = computed(() => ({
|
||||
datatable: true,
|
||||
[$style.datatableWrapper]: true,
|
||||
}));
|
||||
|
||||
function onUpdateCurrentPage(value: number) {
|
||||
emit('update:currentPage', value);
|
||||
}
|
||||
|
@ -87,9 +80,9 @@ function getThStyle(column: DatatableColumn) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes" v-bind="$attrs">
|
||||
<table :class="$style.datatable">
|
||||
<thead :class="$style.datatableHeader">
|
||||
<div class="datatable datatableWrapper" v-bind="$attrs">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
|
@ -115,7 +108,7 @@ function getThStyle(column: DatatableColumn) {
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<div :class="$style.pagination">
|
||||
<div class="pagination">
|
||||
<N8nPagination
|
||||
v-if="totalPages > 1"
|
||||
background
|
||||
|
@ -127,7 +120,7 @@ function getThStyle(column: DatatableColumn) {
|
|||
@update:current-page="onUpdateCurrentPage"
|
||||
/>
|
||||
|
||||
<div :class="$style.pageSizeSelector">
|
||||
<div class="pageSizeSelector">
|
||||
<N8nSelect
|
||||
size="mini"
|
||||
:model-value="rowsPerPage"
|
||||
|
@ -148,47 +141,75 @@ function getThStyle(column: DatatableColumn) {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.datatableWrapper {
|
||||
display: block;
|
||||
<style lang="scss" scoped>
|
||||
:deep(table) {
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
border-collapse: separate;
|
||||
overflow: hidden;
|
||||
border-spacing: 0;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
background-color: var(--color-background-xlight);
|
||||
table-layout: fixed;
|
||||
|
||||
.datatable {
|
||||
width: 100%;
|
||||
th {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: var(--font-size-s);
|
||||
padding: 3px 8px;
|
||||
height: 47px;
|
||||
width: auto;
|
||||
&:first-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
td {
|
||||
vertical-align: top;
|
||||
color: var(--color-text-base);
|
||||
padding: var(--spacing-s) var(--spacing-2xs);
|
||||
&:hover {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background: var(--color-background-xlight);
|
||||
}
|
||||
|
||||
&:nth-of-type(odd) {
|
||||
background: var(--color-background-light);
|
||||
&:last-child {
|
||||
th,
|
||||
td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datatableHeader {
|
||||
background: var(--color-background-base);
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: var(--spacing-s) var(--spacing-2xs);
|
||||
}
|
||||
.datatableWrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
overflow: visible;
|
||||
|
|
|
@ -1,101 +1,101 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`components > N8nDatatable > should render correctly 1`] = `
|
||||
"<div class="datatable datatableWrapper">
|
||||
<table class="datatable">
|
||||
<thead class="datatableHeader">
|
||||
<tr>
|
||||
<th class="">ID</th>
|
||||
<th class="">Name</th>
|
||||
<th class="">Age</th>
|
||||
<th class="">Action</th>
|
||||
"<div data-v-c73ff18f="" class="datatable datatableWrapper">
|
||||
<table data-v-c73ff18f="">
|
||||
<thead data-v-c73ff18f="">
|
||||
<tr data-v-c73ff18f="">
|
||||
<th data-v-c73ff18f="" class="">ID</th>
|
||||
<th data-v-c73ff18f="" class="">Name</th>
|
||||
<th data-v-c73ff18f="" class="">Age</th>
|
||||
<th data-v-c73ff18f="" class="">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class=""><span>1</span></td>
|
||||
<td class=""><span>Richard Hendricks</span></td>
|
||||
<td class=""><span>29</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tbody data-v-c73ff18f="">
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">1</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>2</span></td>
|
||||
<td class=""><span>Bertram Gilfoyle</span></td>
|
||||
<td class=""><span>44</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">2</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>3</span></td>
|
||||
<td class=""><span>Dinesh Chugtai</span></td>
|
||||
<td class=""><span>31</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">3</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Dinesh Chugtai</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">31</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>4</span></td>
|
||||
<td class=""><span>Jared Dunn </span></td>
|
||||
<td class=""><span>38</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">4</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Jared Dunn </span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">38</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>5</span></td>
|
||||
<td class=""><span>Richard Hendricks</span></td>
|
||||
<td class=""><span>29</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">5</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>6</span></td>
|
||||
<td class=""><span>Bertram Gilfoyle</span></td>
|
||||
<td class=""><span>44</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">6</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>7</span></td>
|
||||
<td class=""><span>Dinesh Chugtai</span></td>
|
||||
<td class=""><span>31</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">7</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Dinesh Chugtai</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">31</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>8</span></td>
|
||||
<td class=""><span>Jared Dunn </span></td>
|
||||
<td class=""><span>38</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">8</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Jared Dunn </span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">38</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>9</span></td>
|
||||
<td class=""><span>Richard Hendricks</span></td>
|
||||
<td class=""><span>29</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">9</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Richard Hendricks</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">29</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class=""><span>10</span></td>
|
||||
<td class=""><span>Bertram Gilfoyle</span></td>
|
||||
<td class=""><span>44</span></td>
|
||||
<td class="">
|
||||
<n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
<tr data-v-c73ff18f="">
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">10</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">Bertram Gilfoyle</span></td>
|
||||
<td data-v-c73ff18f="" class=""><span data-v-c73ff18f="">44</span></td>
|
||||
<td data-v-c73ff18f="" class="">
|
||||
<n8n-button-stub data-v-c73ff18f="" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<div class="el-pagination is-background is-background"><button type="button" class="btn-prev is-first" disabled="" aria-label="Go to previous page" aria-disabled="true"><i class="el-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<div data-v-c73ff18f="" class="pagination">
|
||||
<div data-v-c73ff18f="" class="el-pagination is-background is-background"><button type="button" class="btn-prev is-first" disabled="" aria-label="Go to previous page" aria-disabled="true"><i class="el-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path fill="currentColor" d="M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.592 30.592 0 0 0 0-42.752L339.264 511.936l311.872-319.872a30.592 30.592 0 0 0 0-42.688 29.12 29.12 0 0 0-41.728 0z"></path>
|
||||
</svg></i></button>
|
||||
<ul class="el-pager">
|
||||
|
@ -107,8 +107,8 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
|
|||
<path fill="currentColor" d="M340.864 149.312a30.592 30.592 0 0 0 0 42.752L652.736 512 340.864 831.872a30.592 30.592 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"></path>
|
||||
</svg></i></button>
|
||||
</div>
|
||||
<div class="pageSizeSelector">
|
||||
<div class="n8n-select container withPrepend">
|
||||
<div data-v-c73ff18f="" class="pageSizeSelector">
|
||||
<div data-v-c73ff18f="" class="n8n-select container withPrepend">
|
||||
<div class="prepend">Page size</div>
|
||||
<div class="el-select el-select--small" popperappendtobody="false" limitpopperwidth="false">
|
||||
<div class="select-trigger el-tooltip__trigger el-tooltip__trigger">
|
||||
|
|
|
@ -50,6 +50,7 @@ export interface Props {
|
|||
inactiveColor?: string;
|
||||
teleported?: boolean;
|
||||
tagSize?: 'small' | 'medium' | 'large';
|
||||
autosize?: boolean | { minRows: number; maxRows: number };
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -60,6 +61,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
validateOnBlur: true,
|
||||
teleported: true,
|
||||
tagSize: 'large',
|
||||
autosize: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -250,6 +252,7 @@ defineExpose({ inputRef });
|
|||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
:size="tagSize"
|
||||
:autosize
|
||||
@update:model-value="onUpdateModelValue"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
|
|
132
packages/editor-ui/src/components/VariablesForm.vue
Normal file
132
packages/editor-ui/src/components/VariablesForm.vue
Normal file
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts" setup>
|
||||
import type { EnvironmentVariable, Rule, RuleGroup } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { computed, ref, reactive, toRaw } from 'vue';
|
||||
import { N8nFormInput, N8nButton } from 'n8n-design-system';
|
||||
import VariablesUsageBadge from './VariablesUsageBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
variable: EnvironmentVariable;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [variable: EnvironmentVariable];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const keyValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'REQUIRED' },
|
||||
{ name: 'MAX_LENGTH', config: { maximum: 50 } },
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: /^[a-zA-Z]/,
|
||||
message: i18n.baseText('variables.editing.key.error.startsWithLetter'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: /^[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
message: i18n.baseText('variables.editing.key.error.jsonKey'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const VALUE_MAX_LENGTH = 220;
|
||||
const valueValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'MAX_LENGTH', config: { maximum: VALUE_MAX_LENGTH } },
|
||||
];
|
||||
const form = reactive<EnvironmentVariable>(structuredClone(toRaw(props.variable)));
|
||||
const formValidation = reactive<{
|
||||
key: boolean;
|
||||
value: boolean;
|
||||
}>({
|
||||
key: false,
|
||||
value: false,
|
||||
});
|
||||
const isValid = computed(() => Object.values(formValidation).every((value) => value));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const validateOnBlur = ref(false);
|
||||
const handleSubmit = () => {
|
||||
validateOnBlur.value = true;
|
||||
if (isValid.value) {
|
||||
emit('submit', form);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td class="key-cell">
|
||||
<N8nFormInput
|
||||
v-model="form.key"
|
||||
label=""
|
||||
name="key"
|
||||
data-test-id="variable-row-key-input"
|
||||
:placeholder="i18n.baseText('variables.editing.key.placeholder')"
|
||||
required
|
||||
:validate-on-blur="validateOnBlur"
|
||||
:validation-rules="keyValidationRules"
|
||||
focus-initially
|
||||
@validate="(value: boolean) => (formValidation.key = value)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td class="value-cell" width="100%">
|
||||
<N8nFormInput
|
||||
v-model="form.value"
|
||||
class="key-input"
|
||||
label=""
|
||||
name="value"
|
||||
data-test-id="variable-row-value-input"
|
||||
:placeholder="i18n.baseText('variables.editing.value.placeholder')"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
size="medium"
|
||||
:maxlength="VALUE_MAX_LENGTH"
|
||||
:validate-on-blur="validateOnBlur"
|
||||
:validation-rules="valueValidationRules"
|
||||
@validate="(value: boolean) => (formValidation.value = value)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td><VariablesUsageBadge v-if="formValidation.key" :name="form.key" /></td>
|
||||
<td align="right">
|
||||
<N8nButton
|
||||
data-test-id="variable-row-cancel-button"
|
||||
type="tertiary"
|
||||
class="mr-xs"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton data-test-id="variable-row-save-button" type="primary" @click="handleSubmit">
|
||||
{{ i18n.baseText('variables.row.button.save') }}
|
||||
</N8nButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.key-cell,
|
||||
.value-cell {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
width: 100%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
:deep(textarea) {
|
||||
min-height: 40px !important;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,106 +0,0 @@
|
|||
import VariablesRow from './VariablesRow.vue';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { setupServer } from '@/__tests__/server';
|
||||
import { afterAll, beforeAll } from 'vitest';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { STORES } from '@/constants';
|
||||
|
||||
const renderComponent = createComponentRenderer(VariablesRow, {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
variables: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
global: {
|
||||
stubs: ['n8n-tooltip'],
|
||||
},
|
||||
});
|
||||
|
||||
describe('VariablesRow', () => {
|
||||
let server: ReturnType<typeof setupServer>;
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await useSettingsStore().getSettings();
|
||||
await useUsersStore().loginWithCookie();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.shutdown();
|
||||
});
|
||||
|
||||
const environmentVariable = {
|
||||
id: '1',
|
||||
name: 'key',
|
||||
value: 'value',
|
||||
};
|
||||
|
||||
it('should render correctly', () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
data: environmentVariable,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.container.querySelectorAll('td')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should show edit and delete buttons on hover', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
data: environmentVariable,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.mouseEnter(wrapper.container);
|
||||
|
||||
expect(wrapper.getByTestId('variable-row-edit-button')).toBeVisible();
|
||||
expect(wrapper.getByTestId('variable-row-delete-button')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show key and value inputs in edit mode', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
data: environmentVariable,
|
||||
editing: true,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.mouseEnter(wrapper.container);
|
||||
|
||||
expect(wrapper.getByTestId('variable-row-key-input')).toBeVisible();
|
||||
expect(wrapper.getByTestId('variable-row-key-input').querySelector('input')).toHaveValue(
|
||||
environmentVariable.name,
|
||||
);
|
||||
expect(wrapper.getByTestId('variable-row-value-input')).toBeVisible();
|
||||
expect(wrapper.getByTestId('variable-row-value-input').querySelector('input')).toHaveValue(
|
||||
environmentVariable.value,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show cancel and save buttons in edit mode', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
data: environmentVariable,
|
||||
editing: true,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.mouseEnter(wrapper.container);
|
||||
|
||||
expect(wrapper.getByTestId('variable-row-cancel-button')).toBeVisible();
|
||||
expect(wrapper.getByTestId('variable-row-save-button')).toBeVisible();
|
||||
});
|
||||
});
|
|
@ -1,278 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import type { Rule, RuleGroup } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import type { IResource } from './layouts/ResourcesListLayout.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const clipboard = useClipboard();
|
||||
const { showMessage } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: IResource];
|
||||
cancel: [data: IResource];
|
||||
edit: [data: IResource];
|
||||
delete: [data: IResource];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: IResource;
|
||||
editing: boolean;
|
||||
}>(),
|
||||
{
|
||||
editing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
|
||||
);
|
||||
const modelValue = ref<IResource>({ ...props.data });
|
||||
|
||||
const formValidationStatus = ref<Record<string, boolean>>({
|
||||
key: false,
|
||||
value: false,
|
||||
});
|
||||
const formValid = computed(() => {
|
||||
return formValidationStatus.value.name && formValidationStatus.value.value;
|
||||
});
|
||||
|
||||
const keyInputRef = ref<ComponentPublicInstance & { inputRef?: HTMLElement }>();
|
||||
const valueInputRef = ref<HTMLElement>();
|
||||
|
||||
const usage = ref(`$vars.${props.data.name}`);
|
||||
|
||||
const isFeatureEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables],
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
focusFirstInput();
|
||||
});
|
||||
|
||||
const keyValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'REQUIRED' },
|
||||
{ name: 'MAX_LENGTH', config: { maximum: 50 } },
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: /^[a-zA-Z]/,
|
||||
message: i18n.baseText('variables.editing.key.error.startsWithLetter'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MATCH_REGEX',
|
||||
config: {
|
||||
regex: /^[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
message: i18n.baseText('variables.editing.key.error.jsonKey'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const valueValidationRules: Array<Rule | RuleGroup> = [
|
||||
{ name: 'MAX_LENGTH', config: { maximum: 220 } },
|
||||
];
|
||||
|
||||
watch(
|
||||
() => modelValue.value.name,
|
||||
async () => {
|
||||
await nextTick();
|
||||
if (formValidationStatus.value.name) {
|
||||
updateUsageSyntax();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function updateUsageSyntax() {
|
||||
usage.value = `$vars.${modelValue.value.name || props.data.name}`;
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
modelValue.value = { ...props.data };
|
||||
emit('cancel', modelValue.value);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
emit('save', modelValue.value);
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
emit('edit', modelValue.value);
|
||||
|
||||
await nextTick();
|
||||
|
||||
focusFirstInput();
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
emit('delete', modelValue.value);
|
||||
}
|
||||
|
||||
function onValidate(name: string, value: boolean) {
|
||||
formValidationStatus.value[name] = value;
|
||||
}
|
||||
|
||||
function onUsageClick() {
|
||||
void clipboard.copy(usage.value);
|
||||
showMessage({
|
||||
title: i18n.baseText('variables.row.usage.copiedToClipboard'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
function focusFirstInput() {
|
||||
keyInputRef.value?.inputRef?.focus?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr :class="$style.variablesRow" data-test-id="variables-row">
|
||||
<td class="variables-key-column">
|
||||
<div>
|
||||
<span v-if="!editing">{{ data.name }}</span>
|
||||
<n8n-form-input
|
||||
v-else
|
||||
ref="keyInputRef"
|
||||
v-model="modelValue.name"
|
||||
label
|
||||
name="name"
|
||||
data-test-id="variable-row-key-input"
|
||||
:placeholder="i18n.baseText('variables.editing.key.placeholder')"
|
||||
required
|
||||
validate-on-blur
|
||||
:validation-rules="keyValidationRules"
|
||||
@validate="(value: boolean) => onValidate('name', value)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="variables-value-column">
|
||||
<div>
|
||||
<span v-if="!editing">{{ data.value }}</span>
|
||||
<n8n-form-input
|
||||
v-else
|
||||
ref="valueInputRef"
|
||||
v-model="modelValue.value"
|
||||
label
|
||||
name="value"
|
||||
data-test-id="variable-row-value-input"
|
||||
:placeholder="i18n.baseText('variables.editing.value.placeholder')"
|
||||
validate-on-blur
|
||||
:validation-rules="valueValidationRules"
|
||||
@validate="(value: boolean) => onValidate('value', value)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="variables-usage-column">
|
||||
<div>
|
||||
<n8n-tooltip placement="top">
|
||||
<span v-if="modelValue.name && usage" :class="$style.usageSyntax" @click="onUsageClick">{{
|
||||
usage
|
||||
}}</span>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.usage.copyToClipboard') }}
|
||||
</template>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="isFeatureEnabled">
|
||||
<div v-if="editing" :class="$style.buttons">
|
||||
<n8n-button
|
||||
data-test-id="variable-row-cancel-button"
|
||||
type="tertiary"
|
||||
class="mr-xs"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.cancel') }}
|
||||
</n8n-button>
|
||||
<n8n-button
|
||||
data-test-id="variable-row-save-button"
|
||||
:disabled="!formValid"
|
||||
type="primary"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.save') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<div v-else :class="[$style.buttons, $style.hoverButtons]">
|
||||
<n8n-tooltip :disabled="permissions.update" placement="top">
|
||||
<div>
|
||||
<n8n-button
|
||||
data-test-id="variable-row-edit-button"
|
||||
type="tertiary"
|
||||
class="mr-xs"
|
||||
:disabled="!permissions.update"
|
||||
@click="onEdit"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.edit') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.button.edit.onlyRoleCanEdit') }}
|
||||
</template>
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip :disabled="permissions.delete" placement="top">
|
||||
<div>
|
||||
<n8n-button
|
||||
data-test-id="variable-row-delete-button"
|
||||
type="tertiary"
|
||||
:disabled="!permissions.delete"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.delete') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.button.delete.onlyRoleCanDelete') }}
|
||||
</template>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.variablesRow {
|
||||
&:hover {
|
||||
.hoverButtons {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hoverButtons {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.usageSyntax {
|
||||
cursor: pointer;
|
||||
background: var(--color-variables-usage-syntax-bg);
|
||||
color: var(--color-variables-usage-font);
|
||||
font-family: var(--font-family-monospace);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,31 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import VariablesUsageBadge from './VariablesUsageBadge.vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const renderComponent = createComponentRenderer(VariablesUsageBadge);
|
||||
|
||||
const showMessage = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage }),
|
||||
}));
|
||||
|
||||
const copy = vi.fn();
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({ copy }),
|
||||
}));
|
||||
|
||||
describe('VariablesUsageBadge', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should copy to the clipboard', async () => {
|
||||
const name = 'myVar';
|
||||
const output = `$vars.${name}`;
|
||||
const { getByText } = renderComponent({ props: { name } });
|
||||
await userEvent.click(getByText(output));
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
expect(copy).toHaveBeenCalledWith(output);
|
||||
});
|
||||
});
|
44
packages/editor-ui/src/components/VariablesUsageBadge.vue
Normal file
44
packages/editor-ui/src/components/VariablesUsageBadge.vue
Normal file
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { N8nTooltip } from 'n8n-design-system';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
|
||||
const i18n = useI18n();
|
||||
const clipboard = useClipboard();
|
||||
const { showMessage } = useToast();
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
}>();
|
||||
|
||||
const usage = computed(() => `$vars.${props.name}`);
|
||||
|
||||
const handleClick = () => {
|
||||
void clipboard.copy(usage.value);
|
||||
showMessage({
|
||||
title: i18n.baseText('variables.row.usage.copiedToClipboard'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTooltip placement="top">
|
||||
<span class="usageSyntax" @click="handleClick">{{ usage }}</span>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.usage.copyToClipboard') }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.usageSyntax {
|
||||
cursor: pointer;
|
||||
background: var(--color-variables-usage-syntax-bg);
|
||||
color: var(--color-variables-usage-font);
|
||||
font-family: var(--font-family-monospace);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,9 @@
|
|||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
overflow: boolean;
|
||||
overflow?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
overflow: false,
|
||||
});
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -17,7 +17,7 @@ import type { Scope } from '@n8n/permissions';
|
|||
|
||||
export type IResource = {
|
||||
id: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
key?: string;
|
||||
updatedAt?: string;
|
||||
|
@ -42,7 +42,7 @@ const props = withDefaults(
|
|||
displayName?: (resource: IResource) => string;
|
||||
resources: IResource[];
|
||||
disabled: boolean;
|
||||
initialize: () => Promise<void>;
|
||||
initialize?: () => Promise<void>;
|
||||
filters?: IFilters;
|
||||
additionalFiltersHandler?: (
|
||||
resource: IResource,
|
||||
|
@ -58,7 +58,7 @@ const props = withDefaults(
|
|||
loading: boolean;
|
||||
}>(),
|
||||
{
|
||||
displayName: (resource: IResource) => resource.name,
|
||||
displayName: (resource: IResource) => resource.name || '',
|
||||
initialize: async () => {},
|
||||
filters: () => ({ search: '', homeProject: '' }),
|
||||
sortFns: () => ({}),
|
||||
|
@ -103,7 +103,7 @@ const sortBy = ref(props.sortOptions[0]);
|
|||
const hasFilters = ref(false);
|
||||
const filtersModel = ref(props.filters);
|
||||
const currentPage = ref(1);
|
||||
const rowsPerPage = ref<number>(10);
|
||||
const rowsPerPage = ref<number>(25);
|
||||
const resettingFilters = ref(false);
|
||||
const search = ref<HTMLElement | null>(null);
|
||||
|
||||
|
@ -371,7 +371,7 @@ onMounted(async () => {
|
|||
</n8n-action-box>
|
||||
</slot>
|
||||
</div>
|
||||
<PageViewLayoutList v-else :overflow="type !== 'list'">
|
||||
<PageViewLayoutList v-else>
|
||||
<template #header>
|
||||
<div :class="$style['filters-row']">
|
||||
<div :class="$style.filters">
|
||||
|
|
|
@ -2385,6 +2385,8 @@
|
|||
"variables.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create variables",
|
||||
"variables.empty.notAllowedToCreate.heading": "{name}, start using variables",
|
||||
"variables.empty.notAllowedToCreate.description": "Ask your n8n instance owner to create the variables you need. Once configured, you can utilize them in your workflows using the syntax $vars.MY_VAR.",
|
||||
"variables.filters.active": "Some variables may be hidden since filters are applied.",
|
||||
"variables.filters.active.reset": "Remove filters",
|
||||
"variables.noResults": "No variables found",
|
||||
"variables.sort.nameAsc": "Sort by name (A-Z)",
|
||||
"variables.sort.nameDesc": "Sort by name (Z-A)",
|
||||
|
|
|
@ -1,57 +1,53 @@
|
|||
import { afterAll, beforeAll } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { setupServer } from '@/__tests__/server';
|
||||
import VariablesView from '@/views/VariablesView.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { EnterpriseEditionFeature, STORES } from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import type { IUser, EnvironmentVariable } from '@/Interface';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: { template: '<div></div>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(VariablesView, { global: { plugins: [router] } });
|
||||
|
||||
const fullAccessScopes: Scope[] = [
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
];
|
||||
|
||||
describe('VariablesView', () => {
|
||||
let server: ReturnType<typeof setupServer>;
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let usersStore: ReturnType<typeof useUsersStore>;
|
||||
let rbacStore: ReturnType<typeof useRBACStore>;
|
||||
|
||||
const renderComponent = createComponentRenderer(VariablesView, {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
settingsStore = useSettingsStore();
|
||||
usersStore = useUsersStore();
|
||||
rbacStore = useRBACStore();
|
||||
await settingsStore.getSettings();
|
||||
await usersStore.fetchUsers();
|
||||
await usersStore.loginWithCookie();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.shutdown();
|
||||
createTestingPinia({ initialState: { [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE } });
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
});
|
||||
|
||||
describe('should render empty state', () => {
|
||||
it('when feature is disabled and logged in user is not owner', async () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.globalScopes = ['variable:read', 'variable:list'];
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
|
@ -61,16 +57,12 @@ describe('VariablesView', () => {
|
|||
});
|
||||
|
||||
it('when feature is disabled and logged in user is owner', async () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
rbacStore.setGlobalScopes([
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
]);
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.globalScopes = fullAccessScopes;
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
|
@ -80,16 +72,18 @@ describe('VariablesView', () => {
|
|||
});
|
||||
|
||||
it('when feature is enabled and logged in user is owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
rbacStore.setGlobalScopes([
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.globalScopes = [
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
]);
|
||||
];
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
|
@ -99,10 +93,12 @@ describe('VariablesView', () => {
|
|||
});
|
||||
|
||||
it('when feature is enabled and logged in user is not owner', async () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.globalScopes = ['variable:read', 'variable:list'];
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
|
@ -112,14 +108,193 @@ describe('VariablesView', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should render variable entries', async () => {
|
||||
const userWithPrivileges = (variables: EnvironmentVariable[]) => {
|
||||
const userStore = mockedStore(useUsersStore);
|
||||
userStore.currentUser = { globalScopes: fullAccessScopes } as IUser;
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
server.createList('variable', 3);
|
||||
const environmentsStore = mockedStore(useEnvironmentsStore);
|
||||
environmentsStore.variables = variables;
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
return { userStore, settingsStore, environmentsStore };
|
||||
};
|
||||
|
||||
it('should render variable entries', async () => {
|
||||
userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'b',
|
||||
value: 'b',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: 'c',
|
||||
value: 'c',
|
||||
},
|
||||
]);
|
||||
|
||||
const wrapper = renderComponent();
|
||||
|
||||
const table = await wrapper.findByTestId('resources-table');
|
||||
expect(table).toBeVisible();
|
||||
expect(wrapper.container.querySelectorAll('tr')).toHaveLength(4);
|
||||
});
|
||||
|
||||
describe('CRUD', () => {
|
||||
it('should create variables', async () => {
|
||||
const { environmentsStore } = userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByTestId, queryAllByTestId, getByPlaceholderText } = renderComponent();
|
||||
await waitFor(() => expect(getByTestId('resources-list-add')).toBeVisible());
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(1);
|
||||
|
||||
await userEvent.click(getByTestId('resources-list-add'));
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(2);
|
||||
|
||||
const newVariable = { key: 'b', value: 'b' };
|
||||
|
||||
await userEvent.type(getByPlaceholderText('Enter a name'), newVariable.key);
|
||||
await userEvent.type(getByPlaceholderText('Enter a value'), newVariable.value);
|
||||
|
||||
await userEvent.click(getByTestId('variable-row-save-button'));
|
||||
|
||||
expect(environmentsStore.createVariable).toHaveBeenCalledWith(newVariable);
|
||||
});
|
||||
|
||||
it('should delete variables', async () => {
|
||||
const { environmentsStore } = userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'b',
|
||||
value: 'b',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByTestId, queryAllByTestId, getByLabelText, queryAllByLabelText } =
|
||||
renderComponent();
|
||||
await waitFor(() => expect(getByTestId('resources-list-add')).toBeVisible());
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(2);
|
||||
|
||||
await userEvent.hover(queryAllByTestId('variables-row')[0]);
|
||||
expect(queryAllByTestId('variable-row-delete-button')[0]).toBeVisible();
|
||||
await userEvent.click(queryAllByTestId('variable-row-delete-button')[0]);
|
||||
|
||||
// Cancel
|
||||
expect(getByLabelText('Delete variable')).toBeVisible();
|
||||
await userEvent.click(within(getByLabelText('Delete variable')).getByText('Cancel'));
|
||||
expect(environmentsStore.deleteVariable).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.hover(queryAllByTestId('variables-row')[0]);
|
||||
expect(queryAllByTestId('variable-row-delete-button')[0]).toBeVisible();
|
||||
await userEvent.click(queryAllByTestId('variable-row-delete-button')[0]);
|
||||
|
||||
// Delete
|
||||
const dialog = queryAllByLabelText('Delete variable').at(-1);
|
||||
expect(dialog).toBeVisible();
|
||||
await userEvent.click(within(dialog as HTMLElement).getByText('Delete'));
|
||||
|
||||
expect(environmentsStore.deleteVariable).toHaveBeenCalledWith(environmentsStore.variables[0]);
|
||||
});
|
||||
|
||||
it('should update variable', async () => {
|
||||
const { environmentsStore } = userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByTestId, queryAllByTestId, getByPlaceholderText } = renderComponent();
|
||||
await waitFor(() => expect(getByTestId('resources-list-add')).toBeVisible());
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(1);
|
||||
|
||||
await userEvent.hover(getByTestId('variables-row'));
|
||||
|
||||
expect(getByTestId('variable-row-edit-button')).toBeVisible();
|
||||
await userEvent.click(getByTestId('variable-row-edit-button'));
|
||||
|
||||
const newVariable = { id: '1', key: 'ab', value: 'ab' };
|
||||
|
||||
await userEvent.type(getByPlaceholderText('Enter a name'), 'b');
|
||||
await userEvent.type(getByPlaceholderText('Enter a value'), 'b');
|
||||
|
||||
await userEvent.click(getByTestId('variable-row-save-button'));
|
||||
|
||||
expect(environmentsStore.updateVariable).toHaveBeenCalledWith(newVariable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter', () => {
|
||||
it('should filter by incomplete', async () => {
|
||||
userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'b',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByTestId, queryAllByTestId } = renderComponent();
|
||||
await waitFor(() => expect(getByTestId('resources-list-add')).toBeVisible());
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(2);
|
||||
await userEvent.click(getByTestId('variable-filter-incomplete'));
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
it('should sort by name (asc | desc)', async () => {
|
||||
userWithPrivileges([
|
||||
{
|
||||
id: '1',
|
||||
key: 'a',
|
||||
value: 'a',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: 'b',
|
||||
value: 'b',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getByTestId, queryAllByTestId } = renderComponent();
|
||||
await waitFor(() => expect(getByTestId('resources-list-add')).toBeVisible());
|
||||
|
||||
expect(queryAllByTestId('variables-row').length).toBe(2);
|
||||
expect(queryAllByTestId('variables-row')[0].querySelector('td')?.textContent).toBe('a');
|
||||
|
||||
await userEvent.click(getByTestId('resources-list-sort'));
|
||||
await userEvent.click(queryAllByTestId('resources-list-sort-item')[1]);
|
||||
|
||||
expect(queryAllByTestId('variables-row')[0].querySelector('td')?.textContent).toBe('b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { computed, ref, useTemplateRef, onMounted } from 'vue';
|
||||
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
|
@ -10,17 +10,30 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
|
||||
import VariablesForm from '@/components/VariablesForm.vue';
|
||||
import VariablesUsageBadge from '@/components/VariablesUsageBadge.vue';
|
||||
|
||||
import type { IResource } from '@/components/layouts/ResourcesListLayout.vue';
|
||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||
import VariablesRow from '@/components/VariablesRow.vue';
|
||||
import ResourcesListLayout, {
|
||||
type IResource,
|
||||
type IFilters,
|
||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||
|
||||
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
||||
import type { DatatableColumn, EnvironmentVariable } from '@/Interface';
|
||||
import { uid } from 'n8n-design-system/utils';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
import { pickBy } from 'lodash-es';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nTooltip,
|
||||
N8nActionBox,
|
||||
N8nInputLabel,
|
||||
N8nCheckbox,
|
||||
N8nBadge,
|
||||
} from 'n8n-design-system';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
|
@ -30,184 +43,94 @@ const telemetry = useTelemetry();
|
|||
const i18n = useI18n();
|
||||
const message = useMessage();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
let sourceControlStoreUnsubscribe = () => {};
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const layoutRef = ref<InstanceType<typeof ResourcesListLayout> | null>(null);
|
||||
const layoutRef = useTemplateRef<InstanceType<typeof ResourcesListLayout>>('layoutRef');
|
||||
|
||||
const { showError } = useToast();
|
||||
|
||||
const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
|
||||
|
||||
const allVariables = ref<EnvironmentVariable[]>([]);
|
||||
const editMode = ref<Record<string, boolean>>({});
|
||||
const loading = ref(false);
|
||||
|
||||
const permissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
|
||||
);
|
||||
|
||||
const { isLoading, execute } = useAsyncState(environmentsStore.fetchAllVariables, [], {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const isFeatureEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables],
|
||||
);
|
||||
|
||||
const variablesToResources = computed((): IResource[] =>
|
||||
allVariables.value.map((v) => ({ id: v.id, name: v.key, value: v.value })),
|
||||
);
|
||||
const variableForms = ref<Map<string, EnvironmentVariable>>(new Map());
|
||||
const editableVariables = ref<string[]>([]);
|
||||
const addToEditableVariables = (variableId: string) => editableVariables.value.push(variableId);
|
||||
const removeEditableVariable = (variableId: string) => {
|
||||
editableVariables.value = editableVariables.value.filter((id) => id !== variableId);
|
||||
variableForms.value.delete(variableId);
|
||||
};
|
||||
|
||||
const addEmptyVariableForm = () => {
|
||||
const variable = { id: uid(TEMPORARY_VARIABLE_UID_BASE), key: '', value: '' };
|
||||
variableForms.value.set(variable.id, variable);
|
||||
|
||||
// Reset pagination
|
||||
if (layoutRef.value?.currentPage !== 1) {
|
||||
layoutRef.value?.setCurrentPage(1);
|
||||
}
|
||||
|
||||
addToEditableVariables(variable.id);
|
||||
telemetry.track('User clicked add variable button');
|
||||
};
|
||||
|
||||
const variables = computed(() => [...variableForms.value.values(), ...environmentsStore.variables]);
|
||||
|
||||
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
|
||||
|
||||
const datatableColumns = computed<DatatableColumn[]>(() => [
|
||||
{
|
||||
id: 0,
|
||||
path: 'name',
|
||||
label: i18n.baseText('variables.table.key'),
|
||||
classes: ['variables-key-column'],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
path: 'value',
|
||||
label: i18n.baseText('variables.table.value'),
|
||||
classes: ['variables-value-column'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: 'usage',
|
||||
label: i18n.baseText('variables.table.usage'),
|
||||
classes: ['variables-usage-column'],
|
||||
},
|
||||
...(isFeatureEnabled.value
|
||||
? [
|
||||
{
|
||||
id: 3,
|
||||
path: 'actions',
|
||||
label: '',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const columns = computed(() => {
|
||||
const cols: DatatableColumn[] = [
|
||||
{
|
||||
id: 0,
|
||||
path: 'name',
|
||||
label: i18n.baseText('variables.table.key'),
|
||||
classes: ['variables-key-column'],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
path: 'value',
|
||||
label: i18n.baseText('variables.table.value'),
|
||||
classes: ['variables-value-column'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: 'usage',
|
||||
label: i18n.baseText('variables.table.usage'),
|
||||
classes: ['variables-usage-column'],
|
||||
},
|
||||
];
|
||||
|
||||
const contextBasedTranslationKeys = computed(() => uiStore.contextBasedTranslationKeys);
|
||||
if (!isFeatureEnabled.value) return cols;
|
||||
|
||||
const newlyAddedVariableIds = ref<string[]>([]);
|
||||
|
||||
const nameSortFn = (a: IResource, b: IResource, direction: 'asc' | 'desc') => {
|
||||
if (`${a.id}`.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
return -1;
|
||||
} else if (`${b.id}`.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
return 1;
|
||||
} else if (
|
||||
newlyAddedVariableIds.value.includes(a.id) &&
|
||||
newlyAddedVariableIds.value.includes(b.id)
|
||||
) {
|
||||
return newlyAddedVariableIds.value.indexOf(a.id) - newlyAddedVariableIds.value.indexOf(b.id);
|
||||
} else if (newlyAddedVariableIds.value.includes(a.id)) {
|
||||
return -1;
|
||||
} else if (newlyAddedVariableIds.value.includes(b.id)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return direction === 'asc'
|
||||
? displayName(a).trim().localeCompare(displayName(b).trim())
|
||||
: displayName(b).trim().localeCompare(displayName(a).trim());
|
||||
};
|
||||
const sortFns = {
|
||||
nameAsc: (a: IResource, b: IResource) => {
|
||||
return nameSortFn(a, b, 'asc');
|
||||
},
|
||||
nameDesc: (a: IResource, b: IResource) => {
|
||||
return nameSortFn(a, b, 'desc');
|
||||
},
|
||||
};
|
||||
|
||||
function resetNewVariablesList() {
|
||||
newlyAddedVariableIds.value = [];
|
||||
}
|
||||
|
||||
const resourceToEnvironmentVariable = (data: IResource): EnvironmentVariable => ({
|
||||
id: data.id,
|
||||
key: data.name,
|
||||
value: 'value' in data ? (data.value ?? '') : '',
|
||||
return cols.concat({ id: 3, path: 'actions', label: '', classes: ['variables-actions-column'] });
|
||||
});
|
||||
|
||||
const environmentVariableToResource = (data: EnvironmentVariable): IResource => ({
|
||||
id: data.id,
|
||||
name: data.key,
|
||||
value: 'value' in data ? data.value : '',
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
if (!isFeatureEnabled.value) return;
|
||||
loading.value = true;
|
||||
await environmentsStore.fetchAllVariables();
|
||||
|
||||
allVariables.value = [...environmentsStore.variables];
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function addTemporaryVariable() {
|
||||
const temporaryVariable: EnvironmentVariable = {
|
||||
id: uid(TEMPORARY_VARIABLE_UID_BASE),
|
||||
key: '',
|
||||
value: '',
|
||||
};
|
||||
|
||||
if (layoutRef.value) {
|
||||
// Reset scroll position
|
||||
if (layoutRef.value.$refs.listWrapperRef) {
|
||||
(layoutRef.value.$refs.listWrapperRef as HTMLDivElement).scrollTop = 0;
|
||||
}
|
||||
|
||||
// Reset pagination
|
||||
if (layoutRef.value.currentPage !== 1) {
|
||||
layoutRef.value.setCurrentPage(1);
|
||||
}
|
||||
}
|
||||
|
||||
allVariables.value.unshift(temporaryVariable);
|
||||
editMode.value[temporaryVariable.id] = true;
|
||||
|
||||
telemetry.track('User clicked add variable button');
|
||||
}
|
||||
|
||||
async function saveVariable(data: IResource) {
|
||||
const variable = resourceToEnvironmentVariable(data);
|
||||
const handleSubmit = async (variable: EnvironmentVariable) => {
|
||||
try {
|
||||
if (typeof variable.id === 'string' && variable.id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
const { id, ...rest } = variable;
|
||||
const updatedVariable = await environmentsStore.createVariable(rest);
|
||||
allVariables.value.unshift(updatedVariable);
|
||||
allVariables.value = allVariables.value.filter((variable) => variable.id !== data.id);
|
||||
newlyAddedVariableIds.value.unshift(updatedVariable.id);
|
||||
const { id, ...rest } = variable;
|
||||
if (id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
await environmentsStore.createVariable(rest);
|
||||
} else {
|
||||
const updatedVariable = await environmentsStore.updateVariable(variable);
|
||||
allVariables.value = allVariables.value.filter((variable) => variable.id !== data.id);
|
||||
allVariables.value.push(updatedVariable);
|
||||
toggleEditing(environmentVariableToResource(updatedVariable));
|
||||
await environmentsStore.updateVariable(variable);
|
||||
}
|
||||
removeEditableVariable(id);
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('variables.errors.save'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function toggleEditing(data: IResource) {
|
||||
editMode.value = {
|
||||
...editMode.value,
|
||||
[data.id]: !editMode.value[data.id],
|
||||
};
|
||||
}
|
||||
|
||||
function cancelEditing(data: IResource) {
|
||||
if (typeof data.id === 'string' && data.id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
allVariables.value = allVariables.value.filter((variable) => variable.id !== data.id);
|
||||
} else {
|
||||
toggleEditing(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVariable(data: IResource) {
|
||||
const variable = resourceToEnvironmentVariable(data);
|
||||
const handleDeleteVariable = async (variable: EnvironmentVariable) => {
|
||||
try {
|
||||
const confirmed = await message.confirm(
|
||||
i18n.baseText('variables.modals.deleteConfirm.message', {
|
||||
|
@ -225,57 +148,95 @@ async function deleteVariable(data: IResource) {
|
|||
}
|
||||
|
||||
await environmentsStore.deleteVariable(variable);
|
||||
allVariables.value = allVariables.value.filter((variable) => variable.id !== data.id);
|
||||
removeEditableVariable(variable.id);
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('variables.errors.delete'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type Filters = IFilters & { incomplete?: boolean };
|
||||
const updateFilter = (state: Filters) => {
|
||||
void router.replace({ query: pickBy(state) as LocationQueryRaw });
|
||||
};
|
||||
const filters = computed<Filters>(
|
||||
() => ({ ...route.query, incomplete: route.query.incomplete?.toString() === 'true' }) as Filters,
|
||||
);
|
||||
|
||||
const handleFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
|
||||
const iResource = resource as EnvironmentVariable;
|
||||
const filtersToApply = newFilters as Filters;
|
||||
|
||||
if (filtersToApply.incomplete) {
|
||||
matches = matches && !iResource.value;
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const nameSortFn = (a: IResource, b: IResource, direction: 'asc' | 'desc') => {
|
||||
if (`${a.id}`.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
return -1;
|
||||
} else if (`${b.id}`.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return direction === 'asc'
|
||||
? displayName(a).trim().localeCompare(displayName(b).trim())
|
||||
: displayName(b).trim().localeCompare(displayName(a).trim());
|
||||
};
|
||||
const sortFns = {
|
||||
nameAsc: (a: IResource, b: IResource) => nameSortFn(a, b, 'asc'),
|
||||
nameDesc: (a: IResource, b: IResource) => nameSortFn(a, b, 'desc'),
|
||||
};
|
||||
|
||||
const unavailableNoticeProps = computed(() => ({
|
||||
emoji: '👋',
|
||||
heading: i18n.baseText(uiStore.contextBasedTranslationKeys.variables.unavailable.title),
|
||||
description: i18n.baseText(uiStore.contextBasedTranslationKeys.variables.unavailable.description),
|
||||
buttonText: i18n.baseText(uiStore.contextBasedTranslationKeys.variables.unavailable.button),
|
||||
buttonType: 'secondary' as const,
|
||||
'onClick:button': goToUpgrade,
|
||||
'data-test-id': 'unavailable-resources-list',
|
||||
}));
|
||||
|
||||
function goToUpgrade() {
|
||||
void pageRedirectionHelper.goToUpgrade('variables', 'upgrade-variables');
|
||||
void usePageRedirectionHelper().goToUpgrade('variables', 'upgrade-variables');
|
||||
}
|
||||
|
||||
function displayName(resource: IResource) {
|
||||
return resource.name;
|
||||
return (resource as EnvironmentVariable).key;
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
sourceControlStoreUnsubscribe = sourceControlStore.$onAction(({ name, after }) => {
|
||||
if (name === 'pullWorkfolder' && after) {
|
||||
after(() => {
|
||||
void initialize();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sourceControlStoreUnsubscribe();
|
||||
sourceControlStore.$onAction(({ name, after }) => {
|
||||
if (name === 'pullWorkfolder' && after) {
|
||||
after(() => {
|
||||
void execute();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('variables.heading'));
|
||||
useDocumentTitle().set(i18n.baseText('variables.heading'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ResourcesListLayout
|
||||
ref="layoutRef"
|
||||
class="variables-view"
|
||||
resource-key="variables"
|
||||
:disabled="!isFeatureEnabled"
|
||||
:resources="variablesToResources"
|
||||
:initialize="initialize"
|
||||
:resources="variables"
|
||||
:filters="filters"
|
||||
:additional-filters-handler="handleFilter"
|
||||
:shareable="false"
|
||||
:display-name="displayName"
|
||||
:sort-fns="sortFns"
|
||||
:sort-options="['nameAsc', 'nameDesc']"
|
||||
:show-filters-dropdown="false"
|
||||
type="datatable"
|
||||
:type-props="{ columns: datatableColumns }"
|
||||
:loading="loading"
|
||||
@sort="resetNewVariablesList"
|
||||
@click:add="addTemporaryVariable"
|
||||
:type-props="{ columns }"
|
||||
:loading="isLoading"
|
||||
@update:filters="updateFilter"
|
||||
@click:add="addEmptyVariableForm"
|
||||
>
|
||||
<template #header>
|
||||
<n8n-heading size="2xlarge" class="mb-m">
|
||||
|
@ -283,66 +244,50 @@ onMounted(() => {
|
|||
</n8n-heading>
|
||||
</template>
|
||||
<template #add-button>
|
||||
<n8n-tooltip placement="top" :disabled="canCreateVariables">
|
||||
<N8nTooltip placement="top" :disabled="canCreateVariables">
|
||||
<div>
|
||||
<n8n-button
|
||||
<N8nButton
|
||||
size="large"
|
||||
block
|
||||
:disabled="!canCreateVariables"
|
||||
data-test-id="resources-list-add"
|
||||
@click="addTemporaryVariable"
|
||||
@click="addEmptyVariableForm"
|
||||
>
|
||||
{{ i18n.baseText(`variables.add`) }}
|
||||
</n8n-button>
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #content>
|
||||
<span v-if="!isFeatureEnabled">{{
|
||||
i18n.baseText(`variables.add.unavailable${allVariables.length === 0 ? '.empty' : ''}`)
|
||||
i18n.baseText(`variables.add.unavailable${variables.length === 0 ? '.empty' : ''}`)
|
||||
}}</span>
|
||||
<span v-else>{{ i18n.baseText('variables.add.onlyOwnerCanCreate') }}</span>
|
||||
</template>
|
||||
</n8n-tooltip>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template #filters="{ setKeyValue }">
|
||||
<div class="mb-s">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('credentials.filters.status')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-base"
|
||||
class="mb-3xs"
|
||||
/>
|
||||
|
||||
<N8nCheckbox
|
||||
label="Value missing"
|
||||
data-test-id="variable-filter-incomplete"
|
||||
:model-value="filters.incomplete"
|
||||
@update:model-value="setKeyValue('incomplete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!isFeatureEnabled" #preamble>
|
||||
<n8n-action-box
|
||||
class="mb-m"
|
||||
data-test-id="unavailable-resources-list"
|
||||
emoji="👋"
|
||||
:heading="
|
||||
i18n.baseText(contextBasedTranslationKeys.variables.unavailable.title as BaseTextKey)
|
||||
"
|
||||
:description="
|
||||
i18n.baseText(
|
||||
contextBasedTranslationKeys.variables.unavailable.description as BaseTextKey,
|
||||
)
|
||||
"
|
||||
:button-text="
|
||||
i18n.baseText(contextBasedTranslationKeys.variables.unavailable.button as BaseTextKey)
|
||||
"
|
||||
button-type="secondary"
|
||||
@click:button="goToUpgrade"
|
||||
/>
|
||||
<N8nActionBox class="mb-m" v-bind="unavailableNoticeProps" />
|
||||
</template>
|
||||
<template v-if="!isFeatureEnabled || (isFeatureEnabled && !canCreateVariables)" #empty>
|
||||
<n8n-action-box
|
||||
v-if="!isFeatureEnabled"
|
||||
data-test-id="unavailable-resources-list"
|
||||
emoji="👋"
|
||||
:heading="
|
||||
i18n.baseText(contextBasedTranslationKeys.variables.unavailable.title as BaseTextKey)
|
||||
"
|
||||
:description="
|
||||
i18n.baseText(
|
||||
contextBasedTranslationKeys.variables.unavailable.description as BaseTextKey,
|
||||
)
|
||||
"
|
||||
:button-text="
|
||||
i18n.baseText(contextBasedTranslationKeys.variables.unavailable.button as BaseTextKey)
|
||||
"
|
||||
button-type="secondary"
|
||||
@click:button="goToUpgrade"
|
||||
/>
|
||||
<n8n-action-box
|
||||
<N8nActionBox v-if="!isFeatureEnabled" v-bind="unavailableNoticeProps" />
|
||||
<N8nActionBox
|
||||
v-else-if="!canCreateVariables"
|
||||
data-test-id="cannot-create-variables"
|
||||
emoji="👋"
|
||||
|
@ -356,72 +301,90 @@ onMounted(() => {
|
|||
/>
|
||||
</template>
|
||||
<template #default="{ data }">
|
||||
<VariablesRow
|
||||
<VariablesForm
|
||||
v-if="editableVariables.includes(data.id)"
|
||||
:key="data.id"
|
||||
:editing="editMode[data.id]"
|
||||
:data="data"
|
||||
@save="saveVariable"
|
||||
@edit="toggleEditing"
|
||||
@cancel="cancelEditing"
|
||||
@delete="deleteVariable"
|
||||
data-test-id="variables-row"
|
||||
:variable="data"
|
||||
@submit="handleSubmit"
|
||||
@cancel="removeEditableVariable(data.id)"
|
||||
/>
|
||||
|
||||
<tr v-else data-test-id="variables-row">
|
||||
<td>
|
||||
{{ data.key }}
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="data.value">
|
||||
{{ data.value }}
|
||||
</template>
|
||||
<N8nBadge v-else theme="warning"> Value missing </N8nBadge>
|
||||
</td>
|
||||
<td>
|
||||
<VariablesUsageBadge v-if="data.key" :name="data.key" />
|
||||
</td>
|
||||
<td v-if="isFeatureEnabled" align="right">
|
||||
<div class="action-buttons">
|
||||
<N8nTooltip :disabled="permissions.update" placement="top">
|
||||
<N8nButton
|
||||
data-test-id="variable-row-edit-button"
|
||||
type="tertiary"
|
||||
class="mr-xs"
|
||||
:disabled="!permissions.update"
|
||||
@click="addToEditableVariables(data.id)"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.edit') }}
|
||||
</N8nButton>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.button.edit.onlyRoleCanEdit') }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip :disabled="permissions.delete" placement="top">
|
||||
<N8nButton
|
||||
data-test-id="variable-row-delete-button"
|
||||
type="tertiary"
|
||||
:disabled="!permissions.delete"
|
||||
@click="handleDeleteVariable(data)"
|
||||
>
|
||||
{{ i18n.baseText('variables.row.button.delete') }}
|
||||
</N8nButton>
|
||||
<template #content>
|
||||
{{ i18n.baseText('variables.row.button.delete.onlyRoleCanDelete') }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</ResourcesListLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.type-input {
|
||||
--max-width: 265px;
|
||||
}
|
||||
|
||||
.sidebarContainer ul {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use 'n8n-design-system/css/common/var.scss';
|
||||
.action-buttons {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.variables-view {
|
||||
:deep(.datatable) {
|
||||
table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
:deep(.datatable) {
|
||||
white-space: nowrap;
|
||||
|
||||
th,
|
||||
td {
|
||||
width: 25%;
|
||||
|
||||
@media screen and (max-width: var.$md) {
|
||||
width: 33.33%;
|
||||
}
|
||||
|
||||
&.variables-value-column,
|
||||
&.variables-key-column,
|
||||
&.variables-usage-column {
|
||||
> div {
|
||||
width: 100%;
|
||||
|
||||
> span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variables-usage-column {
|
||||
@media screen and (max-width: var.$md) {
|
||||
display: none;
|
||||
table tr {
|
||||
&:hover {
|
||||
.action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $breakpoint-sm) {
|
||||
table tr th:nth-child(3),
|
||||
table tr td:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-actions-column {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in a new issue