mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(editor): Add available-types store to the type-availability-policies module (no-changelog) (#38784)
This commit is contained in:
@@ -10,6 +10,12 @@ The descriptor `id` must match the backend module id
|
||||
The module is license-gated on `feat:nodeTypePolicies` and is not a default module. Enable it
|
||||
in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`.
|
||||
|
||||
`useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is
|
||||
passive: it does not watch the route or the active project, because the projects store lives in the
|
||||
shell. The shell calls `fetchForProject(projectId)` when a workflow opens or the active project
|
||||
changes. When the module is off, the request fails, a request is in flight for a different project,
|
||||
or nothing has loaded yet, every lookup reports "available".
|
||||
|
||||
```bash
|
||||
pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies
|
||||
```
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/frontend-module-sdk": "workspace:*"
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/frontend-module-sdk": "workspace:*",
|
||||
"@n8n/rest-api-client": "workspace:*",
|
||||
"@n8n/stores": "workspace:*",
|
||||
"pinia": "catalog:frontend",
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
|
||||
export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { AvailableTypesResponse } from '@n8n/api-types';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
||||
export async function fetchAvailableTypes(
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
): Promise<AvailableTypesResponse> {
|
||||
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`);
|
||||
}
|
||||
+2
@@ -1,2 +1,4 @@
|
||||
/** Must stay the same as the backend module id: `settings.activeModules` is keyed by it. */
|
||||
export const TYPE_AVAILABILITY_POLICIES_MODULE_ID = 'type-availability-policies';
|
||||
|
||||
export const TYPE_AVAILABILITY_POLICIES_STORE_ID = 'typeAvailabilityPolicies';
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import type { AvailableTypesResponse } from '@n8n/api-types';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchAvailableTypes: vi.fn(),
|
||||
isModuleActive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./type-availability-policies.api', () => ({
|
||||
fetchAvailableTypes: mocks.fetchAvailableTypes,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/settings.store', () => ({
|
||||
useSettingsStore: () => ({ isModuleActive: mocks.isModuleActive }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({
|
||||
restApiContext: { baseUrl: 'http://localhost', pushRef: 'test' },
|
||||
}),
|
||||
}));
|
||||
|
||||
const ALLOWED = 'n8n-nodes-base.slack';
|
||||
const RESTRICTED = 'n8n-nodes-base.executeCommand';
|
||||
const UNKNOWN = 'n8n-nodes-base.doesNotExist';
|
||||
|
||||
const PROJECT_A_RESPONSE: AvailableTypesResponse = [
|
||||
{ name: ALLOWED, available: true },
|
||||
{ name: RESTRICTED, available: false, scope: 'project', matchedRuleId: 'rule-1' },
|
||||
];
|
||||
|
||||
const PROJECT_B_RESPONSE: AvailableTypesResponse = [
|
||||
{ name: ALLOWED, available: false, scope: 'instance' },
|
||||
{ name: RESTRICTED, available: true },
|
||||
];
|
||||
|
||||
describe('useTypeAvailabilityPoliciesStore', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetchAvailableTypes.mockReset();
|
||||
mocks.isModuleActive.mockReset().mockReturnValue(true);
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when the module is not active', () => {
|
||||
beforeEach(() => {
|
||||
mocks.isModuleActive.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('reports every type as available and makes no request', async () => {
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(store.isEnabled).toBe(false);
|
||||
expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled();
|
||||
expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
|
||||
name: RESTRICTED,
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the instance reports no active modules', () => {
|
||||
beforeEach(() => {
|
||||
mocks.isModuleActive.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('reports every type as available and makes no request', async () => {
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(store.isEnabled).toBe(false);
|
||||
expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled();
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the module is active', () => {
|
||||
it('returns the restricted entry with its scope and rule', async () => {
|
||||
mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(mocks.fetchAvailableTypes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseUrl: 'http://localhost' }),
|
||||
'project-a',
|
||||
);
|
||||
expect(store.loadedProjectId).toBe('project-a');
|
||||
expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
|
||||
name: RESTRICTED,
|
||||
available: false,
|
||||
scope: 'project',
|
||||
matchedRuleId: 'rule-1',
|
||||
});
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports allowed and unknown types as available', async () => {
|
||||
mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(store.getNodeTypeAvailability(ALLOWED)).toEqual({ name: ALLOWED, available: true });
|
||||
expect(store.getNodeTypeAvailability(UNKNOWN)).toEqual({ name: UNKNOWN, available: true });
|
||||
});
|
||||
|
||||
it('does not refetch the project that is already loaded', async () => {
|
||||
mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reflects the new project after a switch', async () => {
|
||||
mocks.fetchAvailableTypes
|
||||
.mockResolvedValueOnce(PROJECT_A_RESPONSE)
|
||||
.mockResolvedValueOnce(PROJECT_B_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
await store.fetchForProject('project-b');
|
||||
|
||||
expect(store.loadedProjectId).toBe('project-b');
|
||||
expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports every type as available while a project switch is in flight', async () => {
|
||||
let resolveB: (value: AvailableTypesResponse) => void = () => {};
|
||||
mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<AvailableTypesResponse>((resolve) => {
|
||||
resolveB = resolve;
|
||||
}),
|
||||
);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
const pendingB = store.fetchForProject('project-b');
|
||||
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
|
||||
name: RESTRICTED,
|
||||
available: true,
|
||||
});
|
||||
|
||||
resolveB(PROJECT_B_RESPONSE);
|
||||
await pendingB;
|
||||
|
||||
expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
|
||||
it('discards a response for a project that is no longer requested', async () => {
|
||||
let resolveA: (value: AvailableTypesResponse) => void = () => {};
|
||||
mocks.fetchAvailableTypes
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<AvailableTypesResponse>((resolve) => {
|
||||
resolveA = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(PROJECT_B_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
const pendingA = store.fetchForProject('project-a');
|
||||
await store.fetchForProject('project-b');
|
||||
resolveA(PROJECT_A_RESPONSE);
|
||||
await pendingA;
|
||||
|
||||
expect(store.loadedProjectId).toBe('project-b');
|
||||
expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
|
||||
expect(store.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('discards an in-flight response when the user returns to the loaded project', async () => {
|
||||
let resolveB: (value: AvailableTypesResponse) => void = () => {};
|
||||
mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<AvailableTypesResponse>((resolve) => {
|
||||
resolveB = resolve;
|
||||
}),
|
||||
);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
const pendingB = store.fetchForProject('project-b');
|
||||
await store.fetchForProject('project-a');
|
||||
resolveB(PROJECT_B_RESPONSE);
|
||||
await pendingB;
|
||||
|
||||
expect(store.loadedProjectId).toBe('project-a');
|
||||
expect(store.isNodeTypeAvailable(ALLOWED)).toBe(true);
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
|
||||
expect(store.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to available and logs when the request fails', async () => {
|
||||
mocks.fetchAvailableTypes.mockRejectedValue(new Error('endpoint failure'));
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(store.loadedProjectId).toBeNull();
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
|
||||
it('retries after a failed request', async () => {
|
||||
mocks.fetchAvailableTypes
|
||||
.mockRejectedValueOnce(new Error('endpoint failure'))
|
||||
.mockResolvedValueOnce(PROJECT_A_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(2);
|
||||
expect(store.loadedProjectId).toBe('project-a');
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears all state on reset', async () => {
|
||||
mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
store.reset();
|
||||
|
||||
expect(store.loadedProjectId).toBeNull();
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import type { NodeTypeAvailability } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import { fetchAvailableTypes } from './type-availability-policies.api';
|
||||
import {
|
||||
TYPE_AVAILABILITY_POLICIES_MODULE_ID,
|
||||
TYPE_AVAILABILITY_POLICIES_STORE_ID,
|
||||
} from './type-availability-policies.constants';
|
||||
|
||||
export const useTypeAvailabilityPoliciesStore = defineStore(
|
||||
TYPE_AVAILABILITY_POLICIES_STORE_ID,
|
||||
() => {
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const restrictedNodeTypes = shallowRef<ReadonlyMap<string, NodeTypeAvailability>>(new Map());
|
||||
const loadedProjectId = ref<string | null>(null);
|
||||
const requestedProjectId = ref<string | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const isEnabled = computed(
|
||||
() => settingsStore.isModuleActive(TYPE_AVAILABILITY_POLICIES_MODULE_ID) ?? false,
|
||||
);
|
||||
|
||||
async function fetchForProject(projectId: string): Promise<void> {
|
||||
if (!isEnabled.value) return;
|
||||
|
||||
requestedProjectId.value = projectId;
|
||||
|
||||
if (projectId === loadedProjectId.value) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId);
|
||||
if (requestedProjectId.value !== projectId) return;
|
||||
|
||||
restrictedNodeTypes.value = new Map(
|
||||
entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]),
|
||||
);
|
||||
loadedProjectId.value = projectId;
|
||||
} catch (error) {
|
||||
if (requestedProjectId.value !== projectId) return;
|
||||
|
||||
console.error('Failed to fetch available types for project', projectId, error);
|
||||
restrictedNodeTypes.value = new Map();
|
||||
loadedProjectId.value = null;
|
||||
} finally {
|
||||
if (requestedProjectId.value === projectId) isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeTypeAvailability(name: string): NodeTypeAvailability {
|
||||
if (loadedProjectId.value !== requestedProjectId.value) return { name, available: true };
|
||||
|
||||
return restrictedNodeTypes.value.get(name) ?? { name, available: true };
|
||||
}
|
||||
|
||||
function isNodeTypeAvailable(name: string): boolean {
|
||||
return getNodeTypeAvailability(name).available;
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
restrictedNodeTypes.value = new Map();
|
||||
loadedProjectId.value = null;
|
||||
requestedProjectId.value = null;
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isLoading,
|
||||
loadedProjectId,
|
||||
fetchForProject,
|
||||
getNodeTypeAvailability,
|
||||
isNodeTypeAvailable,
|
||||
reset,
|
||||
};
|
||||
},
|
||||
);
|
||||
Generated
+15
@@ -6729,9 +6729,24 @@ importers:
|
||||
|
||||
packages/modules/type-availability-policies/frontend:
|
||||
dependencies:
|
||||
'@n8n/api-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../../@n8n/api-types
|
||||
'@n8n/frontend-module-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../../frontend/@n8n/frontend-module-sdk
|
||||
'@n8n/rest-api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../../frontend/@n8n/rest-api-client
|
||||
'@n8n/stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../frontend/@n8n/stores
|
||||
pinia:
|
||||
specifier: catalog:frontend
|
||||
version: 2.2.4([email protected])([email protected]([email protected]))
|
||||
vue:
|
||||
specifier: catalog:frontend
|
||||
version: 3.5.26([email protected])
|
||||
devDependencies:
|
||||
'@n8n/eslint-config':
|
||||
specifier: workspace:*
|
||||
|
||||
Reference in New Issue
Block a user