feat(editor): VariablesView Reskin - Add Filters for missing values (#12611)

This commit is contained in:
Raúl Gómez Morales 2025-01-20 10:59:15 +01:00 committed by GitHub
parent 652b8d170b
commit 1eeb788d32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 832 additions and 841 deletions

View file

@ -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);

View file

@ -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>>) => {

View file

@ -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;

View file

@ -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;

View file

@ -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">

View file

@ -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"

View 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>

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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);
});
});

View 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>

View file

@ -1,11 +1,9 @@
<script lang="ts" setup>
interface Props {
overflow: boolean;
overflow?: boolean;
}
withDefaults(defineProps<Props>(), {
overflow: false,
});
defineProps<Props>();
</script>
<template>

View file

@ -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">

View file

@ -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)",

View file

@ -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');
});
});
});

View file

@ -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>