@7365admin1/layer-common 3.2.2-staging.195 → 3.2.2-staging.197

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.
@@ -924,8 +924,18 @@ select option {
924
924
  inset: 0;
925
925
  width: var(--toggle-w);
926
926
  height: var(--toggle-h);
927
- /* Vuetify's own slide. The design moves the thumb, not this box. */
928
- transform: none;
927
+ /*
928
+ * Vuetify's own slide. The design moves the thumb, not this box - but the
929
+ * reset never landed: Vuetify ships it as
930
+ * `.v-locale--is-ltr .v-switch .v-selection-control__input` (THREE classes)
931
+ * and, when checked, `.v-locale--is-ltr .v-switch .v-selection-control--dirty
932
+ * .v-selection-control__input` (FOUR), both of which outrank this two-class
933
+ * rule. So every `.app-switch` in the layer drew its thumb 10px out: measured
934
+ * OFF thumb at `track.x - 8` (design: +2) and ON at `track.x + 28`
935
+ * (design: +18). `!important` rather than stacking four `.app-switch`
936
+ * selectors to out-specify it.
937
+ */
938
+ transform: none !important;
929
939
  }
930
940
 
931
941
  /* The 40px hover/ripple disc is bigger than the whole control in this shape. */
@@ -603,6 +603,18 @@
603
603
  border-radius: var(--r-card);
604
604
  box-shadow: var(--shadow);
605
605
  overflow: hidden;
606
+ /*
607
+ * `.table-card__loading` below is `position: absolute`, and nothing in the
608
+ * chain above it - not `.app-card`, not `.table-card` - established a
609
+ * containing block, so the bar resolved against the VIEWPORT: measured
610
+ * 1440x2 pinned to `top: 0` of the window while its card sat at `y: 61.5`.
611
+ * Every `TableMain` screen in all 11 apps drew its busy bar across the top
612
+ * of the browser instead of the top of the card. This is the anchor it
613
+ * always needed. Safe by construction: the card is already
614
+ * `overflow: hidden`, so nothing inside it could have been anchored to an
615
+ * ancestor OUTSIDE it and still be visible.
616
+ */
617
+ position: relative;
606
618
  }
607
619
 
608
620
  /**
@@ -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
- router.push({
994
- name: "keys-visitor-pass-add",
995
- query: {
996
- site: route.params.site as string,
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",
@@ -129,7 +129,30 @@
129
129
  </template>
130
130
 
131
131
  <template #left>
132
- <v-btn fab icon density="comfortable" variant="text" @click="emits('refresh')">
132
+ <!--
133
+ THE REFRESH BUTTON HAD NO BUSY STATE.
134
+
135
+ It emitted `refresh` and then looked identical for the whole
136
+ request. When the reload returned the same rows, NOTHING on the
137
+ screen changed - so there was no way to tell whether the click
138
+ had registered, which is exactly what QA reported on Work Orders
139
+ and on the Duty Officer Book (both fire the request; neither
140
+ showed it). `loading` is the caller's own in-flight flag, the
141
+ same one the bar above already reads: while it is true the button
142
+ is disabled, so the click is acknowledged and a second one cannot
143
+ stack another request on the first.
144
+
145
+ A caller that passes no `loading` renders exactly what it renders
146
+ today (the prop defaults to false).
147
+ -->
148
+ <v-btn
149
+ fab
150
+ icon
151
+ density="comfortable"
152
+ variant="text"
153
+ :disabled="loading"
154
+ @click="emits('refresh')"
155
+ >
133
156
  <v-icon>mdi-refresh</v-icon>
134
157
  </v-btn>
135
158
  <slot name="prepend-additional" />
@@ -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
- useRouter().push({
367
- name: "work-order-details",
368
- params: {
369
- org,
370
- site,
371
- id,
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() {
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.195",
5
+ "version": "3.2.2-staging.197",
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
- if (APP_NAME.toLowerCase() === "org") {
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: "org-organizations-customers-add",
72
+ name: customerAdd,
59
73
  params: { organization: currentUser.value?.defaultOrg },
60
74
  });
61
75
  } else {
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * REGRESSION GUARDS FOR THE TABLE CARD'S BUSY SIGNAL.
3
+ *
4
+ * Both faults here are a CSS declaration and a template binding, not a pure
5
+ * function, and this repo has no component test runner (adding one is a
6
+ * dependency change). So these assert the SOURCE CONTRACT: the exact
7
+ * declarations whose absence caused the defect. They fail the moment someone
8
+ * deletes the anchor or the disabled binding again.
9
+ *
10
+ * The behavioural proof is the driven browser measurement recorded on the PR -
11
+ * bar 1440x2 at `top: 0` of the WINDOW before, 1406x2 at the card's own top
12
+ * edge after; refresh button `false -> false -> false` before,
13
+ * `false -> true -> false` after.
14
+ */
15
+ import { describe, it } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const read = (rel: string) =>
21
+ readFileSync(fileURLToPath(new URL(`../${rel}`, import.meta.url)), "utf8");
22
+
23
+ /** The declaration block for a selector, comments stripped. */
24
+ const ruleFor = (css: string, selector: string) => {
25
+ const at = css.indexOf(`\n${selector} {`);
26
+ assert.notEqual(at, -1, `no rule for "${selector}"`);
27
+ const open = css.indexOf("{", at);
28
+ const close = css.indexOf("}", open);
29
+ return css
30
+ .slice(open + 1, close)
31
+ .replace(/\/\*[\s\S]*?\*\//g, "")
32
+ .trim();
33
+ };
34
+
35
+ describe("the loading bar's containing block", () => {
36
+ const screens = read("assets/css/screens.css");
37
+
38
+ it("anchors .table-card__loading to its card, not the viewport", () => {
39
+ // The bar is absolute. Without this the nearest positioned ancestor is
40
+ // the initial containing block, i.e. the browser window: the bar was
41
+ // measured 1440x2 at top:0 while its card sat 61.5px lower.
42
+ assert.match(ruleFor(screens, ".table-card"), /position:\s*relative/);
43
+ });
44
+
45
+ it("still positions the bar out of flow, so a load causes no 2px jump", () => {
46
+ assert.match(ruleFor(screens, ".table-card__loading"), /position:\s*absolute/);
47
+ });
48
+ });
49
+
50
+ describe("TableMain's refresh button", () => {
51
+ const sfc = read("components/TableMain.vue");
52
+ // The one `v-btn` carrying the refresh emit.
53
+ const refreshBtn = (() => {
54
+ const at = sfc.indexOf(`@click="emits('refresh')"`);
55
+ assert.notEqual(at, -1, "no refresh button in TableMain");
56
+ const open = sfc.lastIndexOf("<v-btn", at);
57
+ return sfc.slice(open, at);
58
+ })();
59
+
60
+ it("is disabled while the caller's request is in flight", () => {
61
+ // Without this, clicking refresh on a list that comes back identical
62
+ // changed zero pixels - the Work Orders / Duty Officer Book report.
63
+ assert.match(refreshBtn, /:disabled="loading"/);
64
+ });
65
+
66
+ it("reads the same `loading` prop the bar reads, so the two cannot diverge", () => {
67
+ assert.match(sfc, /v-if="loading"[\s\S]{0,200}table-card__loading/);
68
+ assert.match(sfc, /loading:\s*\{\s*type:\s*Boolean,\s*default:\s*false,?\s*\}/);
69
+ });
70
+ });
71
+
72
+ describe(".app-switch's thumb", () => {
73
+ const primitives = read("assets/css/primitives.css");
74
+
75
+ it("beats Vuetify's 3- and 4-class slide on the input", () => {
76
+ // `.v-locale--is-ltr .v-switch .v-selection-control__input` and its
77
+ // `--dirty` twin outrank a 2-class rule, so the plain reset never
78
+ // applied and every thumb sat 10px out of its track.
79
+ assert.match(
80
+ ruleFor(primitives, ".app-switch .v-selection-control__input"),
81
+ /transform:\s*none\s*!important/,
82
+ );
83
+ });
84
+ });