@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 +5 -2
- package/package.json +11 -10
- package/src/__tests__/mocks.ts +19 -0
- package/src/components/RestrictedNodePanel.vue +5 -18
- package/src/components/RestrictedNodePopover.test.ts +25 -0
- package/src/components/RestrictedNodePopover.vue +38 -10
- package/src/composables/useNodeTypeRestriction.ts +24 -1
- package/src/index.ts +1 -0
- package/src/type-availability-policies.store.test.ts +70 -24
- package/src/type-availability-policies.store.ts +19 -3
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
|
|
17
|
-
|
|
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
|
+
"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.
|
|
11
|
-
"@n8n/design-system": "2.
|
|
12
|
-
"@n8n/frontend-module-sdk": "0.
|
|
13
|
-
"@n8n/i18n": "2.
|
|
14
|
-
"@n8n/rest-api-client": "2.
|
|
15
|
-
"@n8n/stores": "2.
|
|
16
|
-
"@n8n/utils": "1.
|
|
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.
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
144
|
-
mocks.fetchAvailableTypes
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
const deferredA = createDeferredPromise<AvailableTypesResponse>();
|
|
170
224
|
mocks.fetchAvailableTypes
|
|
171
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
mocks.fetchAvailableTypes
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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,
|