@n8n/frontend-module-type-availability-policies 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,8 +13,9 @@ in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`.
13
13
  `useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is
14
14
  passive: it does not watch the route or the active project, because the projects store lives in the
15
15
  shell. The shell calls `fetchForProject(projectId)` when a workflow opens or the active project
16
- changes. When the module is off, the request fails, a request is in flight for a different project,
17
- or nothing has loaded yet, every lookup reports "available".
16
+ changes, and `reload()` after a community node install, because the server answers only for the
17
+ node types it has loaded. When the module is off, the request fails, a request is in flight for a
18
+ different project, or nothing has loaded yet, every lookup reports "available".
18
19
 
19
20
  ```bash
20
21
  pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies
@@ -27,6 +28,8 @@ and on a cold tree turbo builds them first.
27
28
  for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
28
29
  shell reads restriction through these and never through the store directly.
29
30
 
31
+ Tests stub the store through `mockRestrictedNodeTypes` from the module's `__tests__/mocks`.
32
+
30
33
  `ContactInstanceAdminModal` lists the instance owners with a mail link. The surfaces below render it
31
34
  as a sibling of their own content, so a popover that closes never unmounts the open dialog.
32
35
  `RestrictedNodePopover` is the explanation for a row in a list: the lock trigger, the hover-opened
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-type-availability-policies",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
7
- ".": "./src/index.ts"
7
+ ".": "./src/index.ts",
8
+ "./__tests__/mocks": "./src/__tests__/mocks.ts"
8
9
  },
9
10
  "dependencies": {
10
- "@n8n/api-types": "1.42.2",
11
- "@n8n/design-system": "2.41.2",
12
- "@n8n/frontend-module-sdk": "0.12.2",
13
- "@n8n/i18n": "2.42.2",
14
- "@n8n/rest-api-client": "2.42.2",
15
- "@n8n/stores": "2.42.2",
16
- "@n8n/utils": "1.49.0",
11
+ "@n8n/api-types": "1.43.0",
12
+ "@n8n/design-system": "2.42.0",
13
+ "@n8n/frontend-module-sdk": "0.13.0",
14
+ "@n8n/i18n": "2.43.0",
15
+ "@n8n/rest-api-client": "2.43.0",
16
+ "@n8n/stores": "2.43.0",
17
+ "@n8n/utils": "1.50.0",
17
18
  "@vueuse/core": "^14.3.0",
18
19
  "pinia": "^2.2.4",
19
20
  "vue": "^3.5.13"
@@ -25,7 +26,7 @@
25
26
  "@n8n/frontend-vite-config": "0.1.0",
26
27
  "@n8n/playwright-janitor": "0.1.0",
27
28
  "@n8n/typescript-config": "1.12.0",
28
- "@n8n/vitest-config": "1.23.0",
29
+ "@n8n/vitest-config": "1.24.0",
29
30
  "@pinia/testing": "^0.1.6",
30
31
  "@testing-library/jest-dom": "^6.6.3",
31
32
  "@testing-library/user-event": "^14.6.1",
@@ -0,0 +1,19 @@
1
+ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
2
+ import { vi } from 'vitest';
3
+
4
+ import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
5
+
6
+ /**
7
+ * Makes the active pinia's policy store report these node types as restricted. Call after
8
+ * `setActivePinia` (or after rendering with the pinia the component uses).
9
+ */
10
+ export function mockRestrictedNodeTypes(
11
+ restricted: Record<string, NodeTypeAvailabilityScope> = {},
12
+ ): void {
13
+ vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
14
+ (name) => {
15
+ const scope = restricted[name];
16
+ return scope ? { name, available: false, scope } : { name, available: true };
17
+ },
18
+ );
19
+ }
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
3
3
  import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
4
- import { useI18n, type BaseTextKey } from '@n8n/i18n';
4
+ import { useI18n } from '@n8n/i18n';
5
5
  import { computed, ref } from 'vue';
6
6
 
7
7
  import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
8
+ import { describeNodeTypeRestriction } from '../composables/useNodeTypeRestriction';
8
9
 
9
10
  const {
10
11
  nodeTypeName,
@@ -18,27 +19,13 @@ const {
18
19
 
19
20
  const emit = defineEmits<{ replaceNode: [] }>();
20
21
 
21
- const DESCRIPTION_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
22
- instance: 'typeAvailabilityPolicies.restrictedNode.description.instance',
23
- project: 'typeAvailabilityPolicies.restrictedNode.description.project',
24
- };
25
-
26
22
  const i18n = useI18n();
27
23
 
28
24
  const isContactAdminOpen = ref(false);
29
25
 
30
- // The next step is interpolated into the description, so each locale controls the sentence order.
31
- const description = computed(() => {
32
- const nextStep = i18n.baseText(
33
- showReplace
34
- ? 'typeAvailabilityPolicies.restrictedNode.nextStep.replace'
35
- : 'typeAvailabilityPolicies.restrictedNode.nextStep.contact',
36
- );
37
- return i18n.baseText(
38
- scope ? DESCRIPTION_KEY[scope] : 'typeAvailabilityPolicies.restrictedNode.description.generic',
39
- { interpolate: { nodeType: nodeTypeName, nextStep } },
40
- );
41
- });
26
+ const description = computed(() =>
27
+ describeNodeTypeRestriction(nodeTypeName, scope, showReplace ? 'replace' : 'contact'),
28
+ );
42
29
  </script>
43
30
 
44
31
  <template>
@@ -75,6 +75,31 @@ describe('RestrictedNodePopover', () => {
75
75
  expect(screen.getByTestId('node-restricted-popover')).toBeInTheDocument();
76
76
  });
77
77
 
78
+ it('opens while focus is inside the anchor row and closes when focus leaves', async () => {
79
+ const button = document.createElement('button');
80
+ anchor.appendChild(button);
81
+ renderPopover({ anchor });
82
+
83
+ button.focus();
84
+ expect(await screen.findByTestId('node-restricted-popover')).toBeInTheDocument();
85
+
86
+ button.blur();
87
+ await waitFor(() =>
88
+ expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument(),
89
+ );
90
+ });
91
+
92
+ it('closes while the contact-admin dialog is open', async () => {
93
+ renderPopover({ active: true });
94
+
95
+ await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
96
+
97
+ await waitFor(() =>
98
+ expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument(),
99
+ );
100
+ expect(screen.getByTestId('contact-instance-admin-modal')).toBeInTheDocument();
101
+ });
102
+
78
103
  it('opens the contact-admin dialog for this node type', async () => {
79
104
  renderPopover({ active: true });
80
105
  expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
@@ -2,7 +2,7 @@
2
2
  import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
3
3
  import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
4
4
  import { useI18n, type BaseTextKey } from '@n8n/i18n';
5
- import { unrefElement, useElementHover, type MaybeElement } from '@vueuse/core';
5
+ import { unrefElement, useElementHover, useFocusWithin, type MaybeElement } from '@vueuse/core';
6
6
  import { computed, ref } from 'vue';
7
7
 
8
8
  import { SCOPE_LABEL_KEY } from '../type-availability-policies.constants';
@@ -13,7 +13,7 @@ const props = defineProps<{
13
13
  scope?: NodeTypeAvailabilityScope;
14
14
  /** The list row the popover explains. It opens beside this element, not beside the lock. */
15
15
  anchor?: MaybeElement;
16
- /** The row is the keyboard-active item, which opens the popover like a hover does. */
16
+ /** Keyboard-active without DOM focus, such as a virtual list selection. */
17
17
  active?: boolean;
18
18
  }>();
19
19
 
@@ -26,9 +26,20 @@ const anchorElement = computed(() => unrefElement(props.anchor) ?? undefined);
26
26
  const contentRef = ref<HTMLElement | null>(null);
27
27
  const anchorHovered = useElementHover(anchorElement, { delayLeave: HOVER_GRACE_MS });
28
28
  const contentHovered = useElementHover(contentRef, { delayLeave: HOVER_GRACE_MS });
29
- const open = computed(() => anchorHovered.value || contentHovered.value || props.active);
30
-
31
29
  const isContactAdminOpen = ref(false);
30
+ // Content is teleported, so focus in it is outside the anchor.
31
+ // Close for the contact-admin dialog, which this would otherwise cover.
32
+ const { focused: anchorFocused } = useFocusWithin(anchorElement);
33
+ const { focused: contentFocused } = useFocusWithin(contentRef);
34
+ const open = computed(
35
+ () =>
36
+ !isContactAdminOpen.value &&
37
+ (anchorHovered.value ||
38
+ contentHovered.value ||
39
+ anchorFocused.value ||
40
+ contentFocused.value ||
41
+ props.active),
42
+ );
32
43
 
33
44
  const scopeKey = computed<BaseTextKey>(
34
45
  () =>
@@ -39,6 +50,7 @@ const scopeKey = computed<BaseTextKey>(
39
50
 
40
51
  <template>
41
52
  <span :class="$style.root">
53
+ <!-- The tool pickers render this inside a modal. -->
42
54
  <N8nPopover
43
55
  :open="open"
44
56
  side="left"
@@ -48,14 +60,17 @@ const scopeKey = computed<BaseTextKey>(
48
60
  :suppress-auto-focus="true"
49
61
  :content-class="$style.card"
50
62
  width="254px"
63
+ z-index="var(--floating-ui--z)"
51
64
  >
52
65
  <template #trigger>
53
- <N8nIcon
54
- icon="lock"
55
- size="small"
56
- :title="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
57
- data-test-id="node-restricted-icon"
58
- />
66
+ <span
67
+ :class="$style.marker"
68
+ tabindex="0"
69
+ role="img"
70
+ :aria-label="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
71
+ >
72
+ <N8nIcon icon="lock" size="small" data-test-id="node-restricted-icon" />
73
+ </span>
59
74
  </template>
60
75
  <template #content>
61
76
  <div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
@@ -88,6 +103,19 @@ const scopeKey = computed<BaseTextKey>(
88
103
  display: contents;
89
104
  }
90
105
 
106
+ .marker {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ padding: var(--spacing--3xs);
111
+ color: var(--color--text--tint-1);
112
+
113
+ &:focus-visible {
114
+ outline: var(--focus--border-width) solid var(--focus--border-color);
115
+ outline-offset: 2px;
116
+ }
117
+ }
118
+
91
119
  // The design system defaults popovers to --radius--xs (8px); the design uses the editor's 4px.
92
120
  .card {
93
121
  border-radius: var(--radius);
@@ -1,4 +1,5 @@
1
- import type { NodeTypeAvailability } from '@n8n/api-types';
1
+ import type { NodeTypeAvailability, NodeTypeAvailabilityScope } from '@n8n/api-types';
2
+ import { useI18n, type BaseTextKey } from '@n8n/i18n';
2
3
  import { computed, toValue, type MaybeRefOrGetter } from 'vue';
3
4
 
4
5
  import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
@@ -12,6 +13,28 @@ export function isNodeTypeRestricted(nodeType: string): boolean {
12
13
  return getNodeTypeRestriction(nodeType) !== null;
13
14
  }
14
15
 
16
+ const DESCRIPTION_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
17
+ instance: 'typeAvailabilityPolicies.restrictedNode.description.instance',
18
+ project: 'typeAvailabilityPolicies.restrictedNode.description.project',
19
+ };
20
+
21
+ export function describeNodeTypeRestriction(
22
+ nodeTypeName: string,
23
+ scope?: NodeTypeAvailabilityScope,
24
+ nextStep: 'contact' | 'replace' = 'contact',
25
+ ): string {
26
+ const i18n = useI18n();
27
+ const nextStepText = i18n.baseText(
28
+ nextStep === 'replace'
29
+ ? 'typeAvailabilityPolicies.restrictedNode.nextStep.replace'
30
+ : 'typeAvailabilityPolicies.restrictedNode.nextStep.contact',
31
+ );
32
+ return i18n.baseText(
33
+ scope ? DESCRIPTION_KEY[scope] : 'typeAvailabilityPolicies.restrictedNode.description.generic',
34
+ { interpolate: { nodeType: nodeTypeName, nextStep: nextStepText } },
35
+ );
36
+ }
37
+
15
38
  export function useNodeTypeRestriction(nodeType: MaybeRefOrGetter<string | null | undefined>) {
16
39
  const restriction = computed(() => {
17
40
  const type = toValue(nodeType);
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
2
2
  export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
3
3
  export {
4
+ describeNodeTypeRestriction,
4
5
  getNodeTypeRestriction,
5
6
  isNodeTypeRestricted,
6
7
  useNodeTypeRestriction,
@@ -1,4 +1,5 @@
1
1
  import type { AvailableTypesResponse } from '@n8n/api-types';
2
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
2
3
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
4
 
4
5
  import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
@@ -125,6 +126,62 @@ describe('useTypeAvailabilityPoliciesStore', () => {
125
126
  expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(1);
126
127
  });
127
128
 
129
+ it('reloads the requested project', async () => {
130
+ mocks.fetchAvailableTypes
131
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE)
132
+ .mockResolvedValueOnce(PROJECT_B_RESPONSE);
133
+ const store = useTypeAvailabilityPoliciesStore();
134
+
135
+ await store.fetchForProject('project-a');
136
+ await store.reload();
137
+
138
+ expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(2);
139
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
140
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
141
+ });
142
+
143
+ it('keeps the loaded answer while a reload is in flight', async () => {
144
+ const reload = createDeferredPromise<AvailableTypesResponse>();
145
+ mocks.fetchAvailableTypes
146
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE)
147
+ .mockReturnValueOnce(reload.promise);
148
+ const store = useTypeAvailabilityPoliciesStore();
149
+
150
+ await store.fetchForProject('project-a');
151
+ const pending = store.reload();
152
+
153
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
154
+
155
+ reload.resolve(PROJECT_B_RESPONSE);
156
+ await pending;
157
+
158
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
159
+ });
160
+
161
+ it('ignores an older response for the same project after a reload', async () => {
162
+ const first = createDeferredPromise<AvailableTypesResponse>();
163
+ mocks.fetchAvailableTypes
164
+ .mockReturnValueOnce(first.promise)
165
+ .mockResolvedValueOnce(PROJECT_B_RESPONSE);
166
+ const store = useTypeAvailabilityPoliciesStore();
167
+
168
+ const pending = store.fetchForProject('project-a');
169
+ await store.reload();
170
+ first.resolve(PROJECT_A_RESPONSE);
171
+ await pending;
172
+
173
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
174
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
175
+ });
176
+
177
+ it('does not reload before a project was requested', async () => {
178
+ const store = useTypeAvailabilityPoliciesStore();
179
+
180
+ await store.reload();
181
+
182
+ expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled();
183
+ });
184
+
128
185
  it('reflects the new project after a switch', async () => {
129
186
  mocks.fetchAvailableTypes
130
187
  .mockResolvedValueOnce(PROJECT_A_RESPONSE)
@@ -140,13 +197,10 @@ describe('useTypeAvailabilityPoliciesStore', () => {
140
197
  });
141
198
 
142
199
  it('reports every type as available while a project switch is in flight', async () => {
143
- let resolveB: (value: AvailableTypesResponse) => void = () => {};
144
- mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
145
- async () =>
146
- await new Promise<AvailableTypesResponse>((resolve) => {
147
- resolveB = resolve;
148
- }),
149
- );
200
+ const deferredB = createDeferredPromise<AvailableTypesResponse>();
201
+ mocks.fetchAvailableTypes
202
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE)
203
+ .mockReturnValueOnce(deferredB.promise);
150
204
  const store = useTypeAvailabilityPoliciesStore();
151
205
 
152
206
  await store.fetchForProject('project-a');
@@ -158,7 +212,7 @@ describe('useTypeAvailabilityPoliciesStore', () => {
158
212
  available: true,
159
213
  });
160
214
 
161
- resolveB(PROJECT_B_RESPONSE);
215
+ deferredB.resolve(PROJECT_B_RESPONSE);
162
216
  await pendingB;
163
217
 
164
218
  expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
@@ -166,20 +220,15 @@ describe('useTypeAvailabilityPoliciesStore', () => {
166
220
  });
167
221
 
168
222
  it('discards a response for a project that is no longer requested', async () => {
169
- let resolveA: (value: AvailableTypesResponse) => void = () => {};
223
+ const deferredA = createDeferredPromise<AvailableTypesResponse>();
170
224
  mocks.fetchAvailableTypes
171
- .mockImplementationOnce(
172
- async () =>
173
- await new Promise<AvailableTypesResponse>((resolve) => {
174
- resolveA = resolve;
175
- }),
176
- )
225
+ .mockReturnValueOnce(deferredA.promise)
177
226
  .mockResolvedValueOnce(PROJECT_B_RESPONSE);
178
227
  const store = useTypeAvailabilityPoliciesStore();
179
228
 
180
229
  const pendingA = store.fetchForProject('project-a');
181
230
  await store.fetchForProject('project-b');
182
- resolveA(PROJECT_A_RESPONSE);
231
+ deferredA.resolve(PROJECT_A_RESPONSE);
183
232
  await pendingA;
184
233
 
185
234
  expect(store.loadedProjectId).toBe('project-b');
@@ -188,19 +237,16 @@ describe('useTypeAvailabilityPoliciesStore', () => {
188
237
  });
189
238
 
190
239
  it('discards an in-flight response when the user returns to the loaded project', async () => {
191
- let resolveB: (value: AvailableTypesResponse) => void = () => {};
192
- mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
193
- async () =>
194
- await new Promise<AvailableTypesResponse>((resolve) => {
195
- resolveB = resolve;
196
- }),
197
- );
240
+ const deferredB = createDeferredPromise<AvailableTypesResponse>();
241
+ mocks.fetchAvailableTypes
242
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE)
243
+ .mockReturnValueOnce(deferredB.promise);
198
244
  const store = useTypeAvailabilityPoliciesStore();
199
245
 
200
246
  await store.fetchForProject('project-a');
201
247
  const pendingB = store.fetchForProject('project-b');
202
248
  await store.fetchForProject('project-a');
203
- resolveB(PROJECT_B_RESPONSE);
249
+ deferredB.resolve(PROJECT_B_RESPONSE);
204
250
  await pendingB;
205
251
 
206
252
  expect(store.loadedProjectId).toBe('project-a');
@@ -20,6 +20,7 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
20
20
  const loadedProjectId = ref<string | null>(null);
21
21
  const requestedProjectId = ref<string | null>(null);
22
22
  const isLoading = ref(false);
23
+ let latestRequest = 0;
23
24
 
24
25
  const isEnabled = computed(
25
26
  () => settingsStore.isModuleActive(TYPE_AVAILABILITY_POLICIES_MODULE_ID) ?? false,
@@ -29,29 +30,42 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
29
30
  if (!isEnabled.value) return;
30
31
 
31
32
  requestedProjectId.value = projectId;
33
+ latestRequest++;
32
34
 
33
35
  if (projectId === loadedProjectId.value) {
34
36
  isLoading.value = false;
35
37
  return;
36
38
  }
37
39
 
40
+ await load(projectId);
41
+ }
42
+
43
+ /** Refetches the current project after the server's node set changed. */
44
+ async function reload(): Promise<void> {
45
+ if (!isEnabled.value || requestedProjectId.value === null) return;
46
+
47
+ await load(requestedProjectId.value);
48
+ }
49
+
50
+ async function load(projectId: string): Promise<void> {
51
+ const request = ++latestRequest;
38
52
  isLoading.value = true;
39
53
  try {
40
54
  const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId);
41
- if (requestedProjectId.value !== projectId) return;
55
+ if (request !== latestRequest) return;
42
56
 
43
57
  restrictedNodeTypes.value = new Map(
44
58
  entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]),
45
59
  );
46
60
  loadedProjectId.value = projectId;
47
61
  } catch (error) {
48
- if (requestedProjectId.value !== projectId) return;
62
+ if (request !== latestRequest) return;
49
63
 
50
64
  console.error('Failed to fetch available types for project', projectId, error);
51
65
  restrictedNodeTypes.value = new Map();
52
66
  loadedProjectId.value = null;
53
67
  } finally {
54
- if (requestedProjectId.value === projectId) isLoading.value = false;
68
+ if (request === latestRequest) isLoading.value = false;
55
69
  }
56
70
  }
57
71
 
@@ -66,6 +80,7 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
66
80
  }
67
81
 
68
82
  function reset(): void {
83
+ latestRequest++;
69
84
  restrictedNodeTypes.value = new Map();
70
85
  loadedProjectId.value = null;
71
86
  requestedProjectId.value = null;
@@ -77,6 +92,7 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
77
92
  isLoading,
78
93
  loadedProjectId,
79
94
  fetchForProject,
95
+ reload,
80
96
  getNodeTypeAvailability,
81
97
  isNodeTypeAvailable,
82
98
  reset,