@7365admin1/layer-common 3.2.2-staging.196 → 3.2.2-staging.198
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/components/HidReaderForm.vue +8 -0
- package/components/HidReaderManagement.vue +1 -0
- package/components/QrTemplate/PrintDialog.vue +23 -6
- package/components/ServiceProviderMain.vue +17 -6
- package/components/WorkOrder/Main.vue +20 -8
- package/composables/useHidAmico.ts +39 -5
- package/package.json +1 -1
- package/pages/require-customer.vue +16 -2
- package/utils/hid-discover.test.ts +102 -0
- package/utils/route-name.test.ts +85 -0
- package/utils/route-name.ts +24 -0
|
@@ -153,6 +153,13 @@ const props = defineProps({
|
|
|
153
153
|
type: Boolean,
|
|
154
154
|
default: false,
|
|
155
155
|
},
|
|
156
|
+
// The site this reader belongs to. Passed down from HidReaderManagement,
|
|
157
|
+
// which already has it as a prop from the page's route. Reader discovery is
|
|
158
|
+
// authorized against it server-side.
|
|
159
|
+
site: {
|
|
160
|
+
type: String,
|
|
161
|
+
default: "",
|
|
162
|
+
},
|
|
156
163
|
});
|
|
157
164
|
|
|
158
165
|
const emit = defineEmits<{
|
|
@@ -236,6 +243,7 @@ async function connectReader() {
|
|
|
236
243
|
baseUrl: draft.baseUrl.trim(),
|
|
237
244
|
username: draft.username.trim(),
|
|
238
245
|
password: draft.password,
|
|
246
|
+
site: props.site,
|
|
239
247
|
});
|
|
240
248
|
const nestedData = response?.data;
|
|
241
249
|
const data = typeof nestedData === "object" && nestedData !== null
|
|
@@ -815,6 +815,10 @@ const props = defineProps<{
|
|
|
815
815
|
start: any;
|
|
816
816
|
}>();
|
|
817
817
|
|
|
818
|
+
// Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
|
|
819
|
+
// into the consuming apps but not reliably into the layer's own components.
|
|
820
|
+
import { pickRouteName } from "../../utils/route-name";
|
|
821
|
+
|
|
818
822
|
const route = useRoute();
|
|
819
823
|
const router = useRouter();
|
|
820
824
|
const emit = defineEmits(["update:close"]);
|
|
@@ -990,12 +994,25 @@ const closeDialog = async (type?: string, tabType?: string) => {
|
|
|
990
994
|
dialogType.value = "generate";
|
|
991
995
|
generatePass.value = true;
|
|
992
996
|
} else {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
997
|
+
// Two faults here. The name is only correct in an app whose keys pages
|
|
998
|
+
// sit at the root - every consumer that renders this dialog nests them
|
|
999
|
+
// under [org]/[site], so `router.push` threw on an unknown name and the
|
|
1000
|
+
// dialog's Generate action died. And `org` / `site` are PATH params on
|
|
1001
|
+
// the destination, so sending `site` as a query left both unfilled.
|
|
1002
|
+
const passAdd = pickRouteName(
|
|
1003
|
+
router,
|
|
1004
|
+
"org-site-keys-visitor-pass-add",
|
|
1005
|
+
"keys-visitor-pass-add"
|
|
1006
|
+
);
|
|
1007
|
+
if (passAdd) {
|
|
1008
|
+
router.push({
|
|
1009
|
+
name: passAdd,
|
|
1010
|
+
params: {
|
|
1011
|
+
org: route.params.org as string,
|
|
1012
|
+
site: route.params.site as string,
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
999
1016
|
}
|
|
1000
1017
|
resetPrintState();
|
|
1001
1018
|
snackbarStartRange.value = false;
|
|
@@ -16,14 +16,16 @@
|
|
|
16
16
|
-->
|
|
17
17
|
<PageHeader title="Service Providers">
|
|
18
18
|
<template #actions>
|
|
19
|
+
<!--
|
|
20
|
+
`showBillingButton` defaults to true, so this button rendered in every
|
|
21
|
+
app that mounts this screen - but only property-management has a
|
|
22
|
+
service-provider-mgmt/billing page, so everywhere else the click threw
|
|
23
|
+
on an unknown route name. Render it only where the page exists.
|
|
24
|
+
-->
|
|
19
25
|
<AppButton
|
|
20
|
-
v-if="showBillingButton"
|
|
26
|
+
v-if="showBillingButton && billingRouteName"
|
|
21
27
|
variant="ghost"
|
|
22
|
-
@click="
|
|
23
|
-
useRouter().push({
|
|
24
|
-
name: 'org-site-service-provider-mgmt-billing',
|
|
25
|
-
})
|
|
26
|
-
"
|
|
28
|
+
@click="useRouter().push({ name: billingRouteName })"
|
|
27
29
|
>
|
|
28
30
|
Billing
|
|
29
31
|
</AppButton>
|
|
@@ -656,6 +658,7 @@ import useUser from "../composables/useUser";
|
|
|
656
658
|
import useVerification from "../composables/useVerification";
|
|
657
659
|
// import useRole from "../composables/useRole";
|
|
658
660
|
import { errorConverter } from "../utils/data";
|
|
661
|
+
import { pickRouteName } from "../utils/route-name";
|
|
659
662
|
|
|
660
663
|
type ListTab = "active" | "pending" | "inactive";
|
|
661
664
|
|
|
@@ -734,6 +737,14 @@ const props = defineProps({
|
|
|
734
737
|
},
|
|
735
738
|
});
|
|
736
739
|
|
|
740
|
+
// Only property-management contributes a service-provider-mgmt/billing page;
|
|
741
|
+
// null in every other consumer, which hides the button instead of throwing.
|
|
742
|
+
const billingRouteName = pickRouteName(
|
|
743
|
+
useRouter(),
|
|
744
|
+
"org-site-service-provider-mgmt-billing",
|
|
745
|
+
"service-provider-mgmt-billing"
|
|
746
|
+
);
|
|
747
|
+
|
|
737
748
|
const headers = [
|
|
738
749
|
{
|
|
739
750
|
title: "Company name",
|
|
@@ -208,6 +208,9 @@
|
|
|
208
208
|
|
|
209
209
|
<script setup lang="ts">
|
|
210
210
|
import { useTheme } from "vuetify";
|
|
211
|
+
// Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
|
|
212
|
+
// into the consuming apps but not reliably into the layer's own components.
|
|
213
|
+
import { pickRouteName } from "../../utils/route-name";
|
|
211
214
|
const emit = defineEmits(["click:create", "update:pagination"]);
|
|
212
215
|
|
|
213
216
|
const props = defineProps({
|
|
@@ -363,14 +366,23 @@ function handleRowClick(row: any) {
|
|
|
363
366
|
const org = useRoute().params.org || "defaultOrg";
|
|
364
367
|
const site = useRoute().params.site || "defaultSite";
|
|
365
368
|
const id = row._id;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
369
|
+
// `work-order-details` is not a route in any consuming app; the detail screen
|
|
370
|
+
// is `pages/[org]/[site]/work-orders/[id].vue` -> `org-site-work-orders-id`.
|
|
371
|
+
const details = pickRouteName(
|
|
372
|
+
useRouter(),
|
|
373
|
+
"org-site-work-orders-id",
|
|
374
|
+
"work-orders-id"
|
|
375
|
+
);
|
|
376
|
+
if (details) {
|
|
377
|
+
useRouter().push({
|
|
378
|
+
name: details,
|
|
379
|
+
params: {
|
|
380
|
+
org,
|
|
381
|
+
site,
|
|
382
|
+
id,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
374
386
|
}
|
|
375
387
|
|
|
376
388
|
function resetWorkOrderForm() {
|
|
@@ -90,6 +90,23 @@ export type HidSipAccountData = {
|
|
|
90
90
|
provisionedAt: string;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* True only for the specific validation error an API that predates core #1878
|
|
95
|
+
* returns when the discover body carries `site`. Deliberately narrow: after
|
|
96
|
+
* #1878 `site` is required, so this message can no longer be produced and the
|
|
97
|
+
* fallback below can never hide a real authorization refusal.
|
|
98
|
+
*/
|
|
99
|
+
export function isUnknownSiteFieldError(error: unknown): boolean {
|
|
100
|
+
const record = typeof error === "object" && error !== null
|
|
101
|
+
? error as Record<string, unknown>
|
|
102
|
+
: {};
|
|
103
|
+
const data = typeof record.data === "object" && record.data !== null
|
|
104
|
+
? record.data as Record<string, unknown>
|
|
105
|
+
: {};
|
|
106
|
+
const message = String(data.message || record.message || "");
|
|
107
|
+
return message.includes("\"site\"") && message.includes("not allowed");
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
export default function useHidAmico() {
|
|
94
111
|
const basePath = "/api/access-management/hid";
|
|
95
112
|
|
|
@@ -260,11 +277,28 @@ export default function useHidAmico() {
|
|
|
260
277
|
);
|
|
261
278
|
}
|
|
262
279
|
|
|
263
|
-
function discoverReader(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
280
|
+
async function discoverReader(
|
|
281
|
+
payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password" | "site">,
|
|
282
|
+
) {
|
|
283
|
+
const post = (body: Record<string, unknown>) =>
|
|
284
|
+
useNuxtApp().$api<Record<string, unknown>>(`${basePath}/readers/discover`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
body,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
return await post(payload as Record<string, unknown>);
|
|
291
|
+
} catch (error: unknown) {
|
|
292
|
+
// The discover endpoint only started accepting (and requiring) `site` in
|
|
293
|
+
// core #1878. An API that predates it validates the body with Joi's
|
|
294
|
+
// default allowUnknown:false and answers `"site" is not allowed`, so the
|
|
295
|
+
// web layer would break the moment it shipped ahead of the API. Retry once
|
|
296
|
+
// without `site` so both API versions work.
|
|
297
|
+
// ponytail: remove this fallback once core #1878 is deployed everywhere.
|
|
298
|
+
if (!isUnknownSiteFieldError(error)) throw error;
|
|
299
|
+
const { site: _site, ...legacy } = payload;
|
|
300
|
+
return await post(legacy as Record<string, unknown>);
|
|
301
|
+
}
|
|
268
302
|
}
|
|
269
303
|
|
|
270
304
|
function getUserPinStatus(readerId: string, hidUserId: string | number) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.2-staging.
|
|
5
|
+
"version": "3.2.2-staging.198",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
</template>
|
|
37
37
|
|
|
38
38
|
<script setup lang="ts">
|
|
39
|
+
// Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
|
|
40
|
+
// into the consuming apps but not reliably into the layer's own components.
|
|
41
|
+
import { pickRouteName } from "../utils/route-name";
|
|
42
|
+
|
|
39
43
|
definePageMeta({
|
|
40
44
|
layout: "plain",
|
|
41
45
|
});
|
|
@@ -53,9 +57,19 @@ authenticate();
|
|
|
53
57
|
const { currentUser } = useLocalAuth();
|
|
54
58
|
|
|
55
59
|
function createCustomer() {
|
|
56
|
-
|
|
60
|
+
// The org app's page is `pages/org/[organization]/customers/add.vue`, whose
|
|
61
|
+
// route name is `org-organization-customers-add` - singular. The plural spelt
|
|
62
|
+
// here matched nothing, so the only way off this dead-end page threw. The
|
|
63
|
+
// `hasRoute` check also keeps a mis-set APP_NAME from taking this branch in an
|
|
64
|
+
// app that has no such page; that app falls through to the cross-app URL.
|
|
65
|
+
const customerAdd = pickRouteName(
|
|
66
|
+
useRouter(),
|
|
67
|
+
"org-organization-customers-add",
|
|
68
|
+
"organizations-customers-add"
|
|
69
|
+
);
|
|
70
|
+
if (APP_NAME.toLowerCase() === "org" && customerAdd) {
|
|
57
71
|
navigateTo({
|
|
58
|
-
name:
|
|
72
|
+
name: customerAdd,
|
|
59
73
|
params: { organization: currentUser.value?.defaultOrg },
|
|
60
74
|
});
|
|
61
75
|
} else {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import useHidAmico, { isUnknownSiteFieldError } from "../composables/useHidAmico.ts";
|
|
8
|
+
|
|
9
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
type Call = { url: string; body: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The composable calls the Nuxt auto-import `useNuxtApp().$api(...)`. Install a
|
|
15
|
+
* stub for it so the request body can be read without a Nuxt app.
|
|
16
|
+
*/
|
|
17
|
+
function stubApi(handler: (call: Call) => unknown) {
|
|
18
|
+
const calls: Call[] = [];
|
|
19
|
+
(globalThis as Record<string, unknown>).useNuxtApp = () => ({
|
|
20
|
+
$api: (url: string, options: { body: Record<string, unknown> }) => {
|
|
21
|
+
const call = { url, body: options.body };
|
|
22
|
+
calls.push(call);
|
|
23
|
+
return Promise.resolve(handler(call));
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return calls;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fetchError(message: string) {
|
|
30
|
+
return Object.assign(new Error("400 Bad Request"), { data: { message } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("discoverReader sends site in the request body", async () => {
|
|
34
|
+
const calls = stubApi(() => ({ data: { deviceId: "D1", portals: [] } }));
|
|
35
|
+
|
|
36
|
+
await useHidAmico().discoverReader({
|
|
37
|
+
baseUrl: "https://reader.example",
|
|
38
|
+
username: "operator",
|
|
39
|
+
password: "secret",
|
|
40
|
+
site: "0123456789abcdef01234567",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(calls.length, 1);
|
|
44
|
+
assert.equal(calls[0].url, "/api/access-management/hid/readers/discover");
|
|
45
|
+
assert.equal(calls[0].body.site, "0123456789abcdef01234567");
|
|
46
|
+
assert.equal(calls[0].body.baseUrl, "https://reader.example");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("discoverReader retries without site when the API predates core #1878", async () => {
|
|
50
|
+
const calls = stubApi((call) => {
|
|
51
|
+
if ("site" in call.body) throw fetchError('"site" is not allowed');
|
|
52
|
+
return { data: { deviceId: "D1", portals: [] } };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const response = await useHidAmico().discoverReader({
|
|
56
|
+
baseUrl: "https://reader.example",
|
|
57
|
+
username: "operator",
|
|
58
|
+
password: "secret",
|
|
59
|
+
site: "0123456789abcdef01234567",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(calls.length, 2);
|
|
63
|
+
assert.equal("site" in calls[1].body, false);
|
|
64
|
+
assert.equal(calls[1].body.baseUrl, "https://reader.example");
|
|
65
|
+
assert.deepEqual(response, { data: { deviceId: "D1", portals: [] } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("discoverReader does not retry a real refusal", async () => {
|
|
69
|
+
const calls = stubApi(() => {
|
|
70
|
+
throw fetchError("You are not authorized to manage readers for this site.");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await assert.rejects(
|
|
74
|
+
useHidAmico().discoverReader({
|
|
75
|
+
baseUrl: "https://reader.example",
|
|
76
|
+
username: "operator",
|
|
77
|
+
password: "secret",
|
|
78
|
+
site: "0123456789abcdef01234567",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(calls.length, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("isUnknownSiteFieldError only matches the unknown-key message", () => {
|
|
85
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is not allowed')), true);
|
|
86
|
+
assert.equal(isUnknownSiteFieldError(new Error('"site" is not allowed')), true);
|
|
87
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"site" is required')), false);
|
|
88
|
+
assert.equal(isUnknownSiteFieldError(fetchError('"baseUrl" is not allowed')), false);
|
|
89
|
+
assert.equal(isUnknownSiteFieldError(fetchError("Unauthorized")), false);
|
|
90
|
+
assert.equal(isUnknownSiteFieldError(null), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the reader form takes site as a prop and puts it in the discover call", () => {
|
|
94
|
+
const source = readFileSync(join(root, "components", "HidReaderForm.vue"), "utf8");
|
|
95
|
+
assert.match(source, /site:\s*\{\s*type:\s*String/);
|
|
96
|
+
assert.match(source, /discoverReader\(\{[\s\S]*?site:\s*props\.site[\s\S]*?\}\)/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("reader management passes its site prop down to the form", () => {
|
|
100
|
+
const source = readFileSync(join(root, "components", "HidReaderManagement.vue"), "utf8");
|
|
101
|
+
assert.match(source, /<HidReaderForm[\s\S]*?:site="site"[\s\S]*?\/>/);
|
|
102
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { pickRouteName } from "./route-name.ts";
|
|
8
|
+
|
|
9
|
+
const routerWith = (...names: string[]) => ({
|
|
10
|
+
hasRoute: (name: string) => names.includes(name),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("picks the first candidate this app actually has", () => {
|
|
14
|
+
const router = routerWith("org-site-keys-visitor-pass-add");
|
|
15
|
+
assert.equal(
|
|
16
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
17
|
+
"org-site-keys-visitor-pass-add"
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("falls through to a later candidate for a flatter app", () => {
|
|
22
|
+
const router = routerWith("keys-visitor-pass-add");
|
|
23
|
+
assert.equal(
|
|
24
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
25
|
+
"keys-visitor-pass-add"
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("candidate order wins when an app has both", () => {
|
|
30
|
+
const router = routerWith("keys-visitor-pass-add", "org-site-keys-visitor-pass-add");
|
|
31
|
+
assert.equal(
|
|
32
|
+
pickRouteName(router, "org-site-keys-visitor-pass-add", "keys-visitor-pass-add"),
|
|
33
|
+
"org-site-keys-visitor-pass-add"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("null when the app has no such page - the caller hides the control", () => {
|
|
38
|
+
assert.equal(pickRouteName(routerWith("org-site-work-orders"), "org-site-work-orders-id"), null);
|
|
39
|
+
assert.equal(pickRouteName(routerWith()), null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The five names two app-side audits found dead. Each is now resolved through
|
|
43
|
+
// `pickRouteName`, so none may reappear as a literal anywhere in the layer.
|
|
44
|
+
// `organizations-create` is deliberately absent: it is real in web-app-org and
|
|
45
|
+
// is only reached there, so it was never dead.
|
|
46
|
+
const DEAD_NAMES = [
|
|
47
|
+
"keys-visitor-pass-add",
|
|
48
|
+
"org-organizations-customers-add",
|
|
49
|
+
"work-order-details",
|
|
50
|
+
"org-site-service-provider-mgmt-billing",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
54
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
const path = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) sourceFiles(path, out);
|
|
57
|
+
else if (/\.(vue|ts)$/.test(entry.name)) out.push(path);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("no dead route name survives as a literal in the layer", () => {
|
|
63
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
64
|
+
const files = ["components", "pages", "layouts", "composables", "middleware", "plugins"]
|
|
65
|
+
.flatMap((d) => sourceFiles(join(root, d)))
|
|
66
|
+
.filter((f) => !f.endsWith(".test.ts"));
|
|
67
|
+
|
|
68
|
+
assert.ok(files.length > 100, `expected to scan the layer, scanned ${files.length} files`);
|
|
69
|
+
|
|
70
|
+
const offenders: string[] = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const source = readFileSync(file, "utf8");
|
|
73
|
+
for (const dead of DEAD_NAMES) {
|
|
74
|
+
// Only a `name:` BINDING counts. The same string also appears as a bare
|
|
75
|
+
// argument in a `pickRouteName(...)` candidate list, which is the fix, not
|
|
76
|
+
// the fault. Plain string matching on purpose - a backslash class inside a
|
|
77
|
+
// template literal silently loses its escape and matches nothing.
|
|
78
|
+
const bound = [`"${dead}"`, `'${dead}'`].some(
|
|
79
|
+
(quoted) => source.includes(`name: ${quoted}`) || source.includes(`name:${quoted}`)
|
|
80
|
+
);
|
|
81
|
+
if (bound) offenders.push(`${file.slice(root.length)} -> ${dead}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
assert.deepEqual(offenders, [], `dead route names still bound:\n${offenders.join("\n")}`);
|
|
85
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the route name the RUNNING app actually has.
|
|
3
|
+
*
|
|
4
|
+
* This layer is extended by apps whose pages sit at different depths, so the
|
|
5
|
+
* same screen has a different Nuxt route name in each of them - the service
|
|
6
|
+
* apps produce `org-site-keys-visitor-pass-add` from
|
|
7
|
+
* `pages/[org]/[site]/keys/visitor/pass/add.vue`, while a flatter app would
|
|
8
|
+
* produce `keys-visitor-pass-add`. A literal name hard-coded in the layer is
|
|
9
|
+
* therefore correct in some consumers and throws in the rest, because
|
|
10
|
+
* `router.push({ name })` on an unknown name is a hard error.
|
|
11
|
+
*
|
|
12
|
+
* Pass the candidates most-specific-first. Returns the first one this app has,
|
|
13
|
+
* or `null` when it has none - and `null` means "this app has no such page",
|
|
14
|
+
* which is the caller's cue to hide the control rather than navigate somewhere
|
|
15
|
+
* wrong.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: vue-router's own `hasRoute`, no name table to keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
export function pickRouteName(
|
|
20
|
+
router: { hasRoute: (name: string) => boolean },
|
|
21
|
+
...candidates: string[]
|
|
22
|
+
): string | null {
|
|
23
|
+
return candidates.find((name) => router.hasRoute(name)) ?? null;
|
|
24
|
+
}
|