feat(editor): Use website as the main templates repository (#8591)

This commit is contained in:
Milorad FIlipović 2024-02-09 13:47:43 +01:00 committed by GitHub
parent 5ab34fe335
commit 79b09fdf84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 281 additions and 154 deletions

View file

@ -1,139 +1,44 @@
import { TemplatesPage } from '../pages/templates';
import { WorkflowPage } from '../pages/workflow';
import OnboardingWorkflow from '../fixtures/Onboarding_workflow.json';
import WorkflowTemplate from '../fixtures/Workflow_template_write_http_query.json';
import { TemplateWorkflowPage } from '../pages/template-workflow';
import { WorkflowsPage } from '../pages/workflows';
import { MainSidebar } from '../pages/sidebar/main-sidebar';
const templatesPage = new TemplatesPage();
const workflowPage = new WorkflowPage();
const templateWorkflowPage = new TemplateWorkflowPage();
const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
describe('Templates', () => {
describe('Workflow templates', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/templates/search?page=1&rows=20&category=&search=', { fixture: 'templates_search/all_templates_search_response.json' }).as('searchRequest');
cy.intercept('GET', '**/api/templates/search?page=1&rows=20&category=Sales*', { fixture: 'templates_search/sales_templates_search_response.json' }).as('categorySearchRequest');
cy.intercept('GET', '**/api/templates/workflows/*', { fixture: 'templates_search/test_template_preview.json' }).as('singleTemplateRequest');
cy.intercept('GET', '**/api/workflows/templates/*', { fixture: 'templates_search/test_template_import.json' }).as('singleTemplateRequest');
cy.intercept('GET', '**/rest/settings', (req) => {
// Disable cache
delete req.headers['if-none-match']
req.reply((res) => {
if (res.body.data) {
// Disable custom templates host if it has been overridden by another intercept
res.body.data.templates = { enabled: true, host: 'https://api.n8n.io/api/' };
}
});
}).as('settingsRequest');
});
it('can open onboarding flow', () => {
templatesPage.actions.openOnboardingFlow(1234, OnboardingWorkflow.name, OnboardingWorkflow);
cy.url().then(($url) => {
expect($url).to.match(/.*\/workflow\/.*?onboardingId=1234$/);
it('Opens website when clicking templates sidebar link', () => {
cy.visit(workflowsPage.url);
mainSidebar.getters.menuItem('Templates').should('be.visible');
// Templates should be a link to the website
mainSidebar.getters.templates().parent('a').should('have.attr', 'href').and('include', 'https://n8n.io/workflows');
mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank');
});
it('Redirects to website when visiting templates page directly', () => {
cy.visit(templatesPage.url);
cy.origin('https://n8n.io', () => {
cy.url().should('include', 'https://n8n.io/workflows');
})
workflowPage.actions.shouldHaveWorkflowName(`Demo: ${name}`);
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon');
});
it('can import template', () => {
templatesPage.actions.importTemplate(1234, OnboardingWorkflow.name, OnboardingWorkflow);
cy.url().then(($url) => {
expect($url).to.include('/workflow/new?templateId=1234');
});
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.actions.shouldHaveWorkflowName(OnboardingWorkflow.name);
});
it('should save template id with the workflow', () => {
cy.visit(templatesPage.url);
cy.get('.el-skeleton.n8n-loading').should('not.exist');
templatesPage.getters.firstTemplateCard().should('exist');
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.firstTemplateCard().click();
cy.url().should('include', '/templates/');
cy.url().then(($url) => {
const templateId = $url.split('/').pop();
templatesPage.getters.useTemplateButton().click();
cy.url().should('include', '/workflow/new');
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.selectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
expect(workflowJSON).to.contain(`"templateId": "${templateId}"`);
});
});
});
it('can open template with images and hides workflow screenshots', () => {
templateWorkflowPage.actions.openTemplate(WorkflowTemplate);
templateWorkflowPage.getters.description().find('img').should('have.length', 1);
});
it('renders search elements correctly', () => {
cy.visit(templatesPage.url);
templatesPage.getters.searchInput().should('exist');
templatesPage.getters.allCategoriesFilter().should('exist');
templatesPage.getters.categoryFilters().should('have.length.greaterThan', 1);
templatesPage.getters.templateCards().should('have.length.greaterThan', 0);
});
it('can filter templates by category', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.expandCategoriesButton().click();
templatesPage.getters.categoryFilter('sales').should('exist');
let initialTemplateCount = 0;
let initialCollectionCount = 0;
templatesPage.getters.templateCountLabel().then(($el) => {
initialTemplateCount = parseInt($el.text().replace(/\D/g, ''), 10);
templatesPage.getters.collectionCountLabel().then(($el) => {
initialCollectionCount = parseInt($el.text().replace(/\D/g, ''), 10);
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.templatesLoadingContainer().should('not.exist');
// Should have less templates and collections after selecting a category
templatesPage.getters.templateCountLabel().should(($el) => {
expect(parseInt($el.text().replace(/\D/g, ''), 10)).to.be.lessThan(initialTemplateCount);
});
templatesPage.getters.collectionCountLabel().should(($el) => {
expect(parseInt($el.text().replace(/\D/g, ''), 10)).to.be.lessThan(initialCollectionCount);
});
});
});
});
it('should preserve search query in URL', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.expandCategoriesButton().click();
templatesPage.getters.categoryFilter('sales').should('exist');
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.searchInput().type('auto');
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
cy.reload();
// Should preserve search query in URL
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
// Sales category should still be selected
templatesPage.getters.categoryFilter('sales').find('label').should('have.class', 'is-checked');
// Search input should still have the search query
templatesPage.getters.searchInput().should('have.value', 'auto');
// Sales checkbox should be pushed to the top
templatesPage.getters.categoryFilters().eq(1).then(($el) => {
expect($el.text()).to.equal('Sales');
});
it('Redirects to website when visiting template by id page directly', () => {
cy.visit(`${templatesPage.url}/1`);
cy.origin('https://n8n.io', () => {
cy.url().should('include', 'https://n8n.io/workflows/1');
})
});
});

View file

@ -4,13 +4,11 @@ import {
testData,
} from '../pages/template-collection';
import * as templateCredentialsSetupPage from '../pages/template-credential-setup';
import { TemplateWorkflowPage } from '../pages/template-workflow';
import { WorkflowPage } from '../pages/workflow';
import * as formStep from '../composables/setup-template-form-step';
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
import * as setupCredsModal from '../composables/modals/workflow-credential-setup-modal';
const templateWorkflowPage = new TemplateWorkflowPage();
const workflowPage = new WorkflowPage();
const testTemplate = templateCredentialsSetupPage.testData.simpleTemplate;
@ -34,18 +32,16 @@ describe('Template credentials setup', () => {
cy.intercept('GET', `https://api.n8n.io/api/templates/workflows/${testTemplate.id}`, {
fixture: testTemplate.fixture,
});
});
it('can be opened from template workflow page', () => {
templateWorkflowPage.actions.visit(testTemplate.id);
templateCredentialsSetupPage.enableTemplateCredentialSetupFeatureFlag();
templateWorkflowPage.getters.useTemplateButton().should('be.visible');
templateCredentialsSetupPage.enableTemplateCredentialSetupFeatureFlag();
templateWorkflowPage.actions.clickUseThisWorkflowButton();
templateCredentialsSetupPage.getters
.title(`Set up 'Promote new Shopify products on Twitter and Telegram' template`)
.should('be.visible');
cy.intercept('GET', '**/rest/settings', (req) => {
// Disable cache
delete req.headers['if-none-match']
req.reply((res) => {
if (res.body.data) {
// Disable custom templates host if it has been overridden by another intercept
res.body.data.templates = { enabled: true, host: 'https://api.n8n.io/api/' };
}
});
}).as('settingsRequest');
});
it('can be opened from template collection page', () => {

View file

@ -0,0 +1,147 @@
import { TemplatesPage } from '../pages/templates';
import { WorkflowPage } from '../pages/workflow';
import { TemplateWorkflowPage } from '../pages/template-workflow';
import OnboardingWorkflow from '../fixtures/Onboarding_workflow.json';
import WorkflowTemplate from '../fixtures/Workflow_template_write_http_query.json';
const templatesPage = new TemplatesPage();
const workflowPage = new WorkflowPage();
const templateWorkflowPage = new TemplateWorkflowPage();
describe('In-app templates repository', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/templates/search?page=1&rows=20&category=&search=', { fixture: 'templates_search/all_templates_search_response.json' }).as('searchRequest');
cy.intercept('GET', '**/api/templates/search?page=1&rows=20&category=Sales*', { fixture: 'templates_search/sales_templates_search_response.json' }).as('categorySearchRequest');
cy.intercept('GET', '**/api/templates/workflows/*', { fixture: 'templates_search/test_template_preview.json' }).as('singleTemplateRequest');
cy.intercept('GET', '**/api/workflows/templates/*', { fixture: 'templates_search/test_template_import.json' }).as('singleTemplateRequest');
cy.intercept('GET', '**/rest/settings', (req) => {
// Disable cache
delete req.headers['if-none-match']
req.reply((res) => {
if (res.body.data) {
// Enable in-app templates by setting a custom host
res.body.data.templates = { enabled: true, host: 'https://api-staging.n8n.io/api/' };
}
});
}).as('settingsRequest');
});
it('can open onboarding flow', () => {
templatesPage.actions.openOnboardingFlow(1, OnboardingWorkflow.name, OnboardingWorkflow, 'https://api-staging.n8n.io');
cy.url().then(($url) => {
expect($url).to.match(/.*\/workflow\/.*?onboardingId=1$/);
})
workflowPage.actions.shouldHaveWorkflowName(`Demo: ${name}`);
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon');
});
it('can import template', () => {
templatesPage.actions.importTemplate(1, OnboardingWorkflow.name, OnboardingWorkflow, 'https://api-staging.n8n.io');
cy.url().then(($url) => {
expect($url).to.include('/workflow/new?templateId=1');
});
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.actions.shouldHaveWorkflowName(OnboardingWorkflow.name);
});
it('should save template id with the workflow', () => {
cy.visit(templatesPage.url);
cy.get('.el-skeleton.n8n-loading').should('not.exist');
templatesPage.getters.firstTemplateCard().should('exist');
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.firstTemplateCard().click();
cy.url().should('include', '/templates/');
cy.url().then(($url) => {
const templateId = $url.split('/').pop();
templatesPage.getters.useTemplateButton().click();
cy.url().should('include', '/workflow/new');
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.selectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
expect(workflowJSON).to.contain(`"templateId": "${templateId}"`);
});
});
});
it('can open template with images and hides workflow screenshots', () => {
templateWorkflowPage.actions.openTemplate(WorkflowTemplate, 'https://api-staging.n8n.io');
templateWorkflowPage.getters.description().find('img').should('have.length', 1);
});
it('renders search elements correctly', () => {
cy.visit(templatesPage.url);
templatesPage.getters.searchInput().should('exist');
templatesPage.getters.allCategoriesFilter().should('exist');
templatesPage.getters.categoryFilters().should('have.length.greaterThan', 1);
templatesPage.getters.templateCards().should('have.length.greaterThan', 0);
});
it('can filter templates by category', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.categoryFilter('sales').should('exist');
let initialTemplateCount = 0;
let initialCollectionCount = 0;
templatesPage.getters.templateCountLabel().then(($el) => {
initialTemplateCount = parseInt($el.text().replace(/\D/g, ''), 10);
templatesPage.getters.collectionCountLabel().then(($el) => {
initialCollectionCount = parseInt($el.text().replace(/\D/g, ''), 10);
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.templatesLoadingContainer().should('not.exist');
// Should have less templates and collections after selecting a category
templatesPage.getters.templateCountLabel().should(($el) => {
expect(parseInt($el.text().replace(/\D/g, ''), 10)).to.be.lessThan(initialTemplateCount);
});
templatesPage.getters.collectionCountLabel().should(($el) => {
expect(parseInt($el.text().replace(/\D/g, ''), 10)).to.be.lessThan(initialCollectionCount);
});
});
});
});
it('should preserve search query in URL', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.categoryFilter('sales').should('exist');
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.searchInput().type('auto');
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
cy.reload();
// Should preserve search query in URL
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
// Sales category should still be selected
templatesPage.getters.categoryFilter('sales').find('label').should('have.class', 'is-checked');
// Search input should still have the search query
templatesPage.getters.searchInput().should('have.value', 'auto');
// Sales checkbox should be pushed to the top
templatesPage.getters.categoryFilters().eq(1).then(($el) => {
expect($el.text()).to.equal('Sales');
});
});
});

View file

@ -1,6 +1,6 @@
{
"workflow": {
"id": 3,
"id": 1,
"name": "Write HTTP query string on image",
"views": 116,
"recentViews": 9766,
@ -185,4 +185,4 @@
}
]
}
}
}

View file

@ -25,8 +25,8 @@ export class TemplateWorkflowPage extends BasePage {
user: { username: string };
image: { id: number; url: string }[];
};
}) => {
cy.intercept('GET', `https://api.n8n.io/api/templates/workflows/${template.workflow.id}`, {
}, templateHost: string) => {
cy.intercept('GET', `${templateHost}/api/templates/workflows/${template.workflow.id}`, {
statusCode: 200,
body: template,
}).as('getTemplate');

View file

@ -23,14 +23,14 @@ export class TemplatesPage extends BasePage {
cy.waitForLoad();
},
openOnboardingFlow: (id: number, name: string, workflow: object) => {
openOnboardingFlow: (id: number, name: string, workflow: object, templatesHost: string) => {
const apiResponse = {
id,
name,
workflow,
};
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
cy.intercept('GET', `https://api.n8n.io/api/workflows/templates/${id}`, {
cy.intercept('GET', `${templatesHost}/api/workflows/templates/${id}`, {
statusCode: 200,
body: apiResponse,
}).as('getTemplate');
@ -42,13 +42,13 @@ export class TemplatesPage extends BasePage {
cy.wait(['@createWorkflow', '@getWorkflow']);
},
importTemplate: (id: number, name: string, workflow: object) => {
importTemplate: (id: number, name: string, workflow: object, templatesHost: string) => {
const apiResponse = {
id,
name,
workflow,
};
cy.intercept('GET', `https://api.n8n.io/api/workflows/templates/${id}`, {
cy.intercept('GET', `${templatesHost}/api/workflows/templates/${id}`, {
statusCode: 200,
body: apiResponse,
}).as('getTemplate');

View file

@ -118,6 +118,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useVersionsStore } from '@/stores/versions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useTemplatesStore } from '@/stores/templates.store';
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
@ -162,6 +163,7 @@ export default defineComponent({
useCloudPlanStore,
useSourceControlStore,
useBecomeTemplateCreatorStore,
useTemplatesStore,
),
logoPath(): string {
if (this.isCollapsed) return this.basePath + 'n8n-logo-collapsed.svg';
@ -225,13 +227,28 @@ export default defineComponent({
const regularItems: IMenuItem[] = [
workflows,
{
// Link to in-app templates, available if custom templates are enabled
id: 'templates',
icon: 'box-open',
label: this.$locale.baseText('mainSidebar.templates'),
position: 'top',
available: this.settingsStore.isTemplatesEnabled,
available:
this.settingsStore.isTemplatesEnabled && this.templatesStore.hasCustomTemplatesHost,
route: { to: { name: VIEWS.TEMPLATES } },
},
{
// Link to website templates, available if custom templates are not enabled
id: 'templates',
icon: 'box-open',
label: this.$locale.baseText('mainSidebar.templates'),
position: 'top',
available:
this.settingsStore.isTemplatesEnabled && !this.templatesStore.hasCustomTemplatesHost,
link: {
href: this.templatesStore.getWebsiteTemplateRepositoryURL,
target: '_blank',
},
},
{
id: 'credentials',
icon: 'key',

View file

@ -58,6 +58,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { SimplifiedNodeType } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useTemplatesStore } from '@/stores/templates.store';
export interface NodeViewItemSection {
key: string;
@ -116,6 +117,7 @@ function getAiNodesBySubcategory(nodes: INodeTypeDescription[], subcategory: str
export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const templatesStore = useTemplatesStore();
const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
@ -124,7 +126,9 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
value: AI_NODE_CREATOR_VIEW,
title: i18n.baseText('nodeCreator.aiPanel.aiNodes'),
subtitle: i18n.baseText('nodeCreator.aiPanel.selectAiNode'),
info: i18n.baseText('nodeCreator.aiPanel.infoBox'),
info: i18n.baseText('nodeCreator.aiPanel.infoBox', {
interpolate: { link: templatesStore.getWebsiteCategoryURL('ai') },
}),
items: [
...chainNodes,
...agentNodes,

View file

@ -737,3 +737,12 @@ export const MOUSE_EVENT_BUTTONS = {
BROWSER_BACK: 8,
BROWSER_FORWARD: 16,
} as const;
/**
* Urls used to route users to the right template repository
*/
export const TEMPLATES_URLS = {
DEFAULT_API_HOST: 'https://api.n8n.io/api/',
BASE_WEBSITE_URL: 'https://n8n.io/workflows',
UTM_QUERY: 'utm_source=n8n_app&utm_medium=template_library',
};

View file

@ -946,7 +946,7 @@
"nodeCreator.aiPanel.newTag": "New",
"nodeCreator.aiPanel.langchainAiNodes": "Advanced AI",
"nodeCreator.aiPanel.title": "When should this workflow run?",
"nodeCreator.aiPanel.infoBox": "Check out our <a href=\"/collections/8\" target=\"_blank\">templates</a> for workflow examples and inspiration.",
"nodeCreator.aiPanel.infoBox": "Check out our <a href=\"{link}\" target=\"_blank\">templates</a> for workflow examples and inspiration.",
"nodeCreator.aiPanel.scheduleTriggerDisplayName": "On a schedule",
"nodeCreator.aiPanel.scheduleTriggerDescription": "Runs the flow every day, hour, or custom interval",
"nodeCreator.aiPanel.webhookTriggerDisplayName": "On webhook call",

View file

@ -78,7 +78,7 @@ export const routes = [
{
path: '/',
name: VIEWS.HOMEPAGE,
redirect: (to) => {
redirect: () => {
return { name: VIEWS.WORKFLOWS };
},
meta: {
@ -130,6 +130,15 @@ export const routes = [
},
middleware: ['authenticated'],
},
beforeEnter: (to, _from, next) => {
const templatesStore = useTemplatesStore();
if (!templatesStore.hasCustomTemplatesHost) {
const id = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id;
window.location.href = templatesStore.getWebsiteTemplatePageURL(id);
} else {
next();
}
},
},
{
path: '/templates/:id/setup',
@ -180,6 +189,14 @@ export const routes = [
},
middleware: ['authenticated'],
},
beforeEnter: (_to, _from, next) => {
const templatesStore = useTemplatesStore();
if (!templatesStore.hasCustomTemplatesHost) {
window.location.href = templatesStore.getWebsiteTemplateRepositoryURL;
} else {
next();
}
},
},
{
path: '/credentials',

View file

@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { STORES } from '@/constants';
import { STORES, TEMPLATES_URLS } from '@/constants';
import type {
INodeUi,
ITemplatesCategory,
@ -109,6 +109,38 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
);
};
},
hasCustomTemplatesHost(): boolean {
const settingsStore = useSettingsStore();
return settingsStore.templatesHost !== TEMPLATES_URLS.DEFAULT_API_HOST;
},
/**
* Construct the URL for the template repository on the website
* @returns {string}
*/
getWebsiteTemplateRepositoryURL(): string {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${TEMPLATES_URLS.UTM_QUERY}&utm_instance=${this.getCurrentN8nPath}`;
},
/**
* Construct the URL for the template page on the website for a given template id
* @returns {function(string): string}
*/
getWebsiteTemplatePageURL() {
return (id: string) => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}/${id}?${TEMPLATES_URLS.UTM_QUERY}&utm_instance=${this.getCurrentN8nPath}`;
};
},
/**
* Construct the URL for the template category page on the website for a given category id
* @returns {function(string): string}
*/
getWebsiteCategoryURL() {
return (id: string) => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}/?categories=${id}&${TEMPLATES_URLS.UTM_QUERY}&utm_instance=${this.getCurrentN8nPath}`;
};
},
getCurrentN8nPath(): string {
return `${window.location.host}${window.BASE_PATH}`;
},
},
actions: {
addCategories(categories: ITemplatesCategory[]): void {