@7365admin1/layer-common 3.2.2-staging.194 → 3.2.2-staging.196
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/assets/css/primitives.css +12 -2
- package/assets/css/screens.css +12 -0
- package/components/RolePermissionFormCreate.vue +1 -2
- package/components/RolePermissionFormPreviewUpdate.vue +1 -2
- package/components/RolePermissionMain.vue +20 -0
- package/components/TableMain.vue +24 -1
- package/package.json +1 -1
- package/utils/role.test.ts +69 -0
- package/utils/role.ts +10 -0
- package/utils/table-card.test.ts +84 -0
|
@@ -924,8 +924,18 @@ select option {
|
|
|
924
924
|
inset: 0;
|
|
925
925
|
width: var(--toggle-w);
|
|
926
926
|
height: var(--toggle-h);
|
|
927
|
-
/*
|
|
928
|
-
|
|
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. */
|
package/assets/css/screens.css
CHANGED
|
@@ -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
|
/**
|
|
@@ -47,10 +47,9 @@
|
|
|
47
47
|
<v-switch
|
|
48
48
|
:model-value="isCategoryEnabled(String(resourceKey))"
|
|
49
49
|
:disabled="!edit"
|
|
50
|
-
|
|
50
|
+
class="app-switch"
|
|
51
51
|
density="compact"
|
|
52
52
|
hide-details
|
|
53
|
-
inset
|
|
54
53
|
@update:model-value="
|
|
55
54
|
toggleCategory(String(resourceKey), $event)
|
|
56
55
|
"
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
:permissions="props.permissions"
|
|
85
85
|
:org="props.orgId"
|
|
86
86
|
:site-id="props.siteId"
|
|
87
|
+
:site-state="siteFieldShown"
|
|
87
88
|
:web-only-resources="props.webOnlyResources"
|
|
88
89
|
@success="success()"
|
|
89
90
|
:type="props.type"
|
|
@@ -184,6 +185,9 @@
|
|
|
184
185
|
</template>
|
|
185
186
|
|
|
186
187
|
<script setup lang="ts">
|
|
188
|
+
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
189
|
+
import { showSiteField } from "../utils/role";
|
|
190
|
+
|
|
187
191
|
const props = defineProps({
|
|
188
192
|
orgId: {
|
|
189
193
|
type: String,
|
|
@@ -197,6 +201,18 @@ const props = defineProps({
|
|
|
197
201
|
type: String,
|
|
198
202
|
default: "",
|
|
199
203
|
},
|
|
204
|
+
/*
|
|
205
|
+
* Whether the Create-role dialog shows its (read-only) Site field.
|
|
206
|
+
* The dialog defaulted this to `true` but this component never declared or
|
|
207
|
+
* forwarded it, and its root is a `<div>`, so a fallthrough `site-state`
|
|
208
|
+
* attribute landed on that div instead of the dialog - no consuming app
|
|
209
|
+
* could switch the field off. Admin roles are never site-scoped, so the
|
|
210
|
+
* field drew permanently disabled and empty there (QA, admin PR #18).
|
|
211
|
+
*/
|
|
212
|
+
siteState: {
|
|
213
|
+
type: Boolean,
|
|
214
|
+
default: true,
|
|
215
|
+
},
|
|
200
216
|
type: {
|
|
201
217
|
type: String,
|
|
202
218
|
required: true,
|
|
@@ -296,6 +312,10 @@ function tableRowClickHandler(_: any, data: any) {
|
|
|
296
312
|
|
|
297
313
|
const createDialog = ref(false);
|
|
298
314
|
|
|
315
|
+
const siteFieldShown = computed(() =>
|
|
316
|
+
showSiteField(props.siteState, props.type)
|
|
317
|
+
);
|
|
318
|
+
|
|
299
319
|
function success() {
|
|
300
320
|
createDialog.value = false;
|
|
301
321
|
getRoles();
|
package/components/TableMain.vue
CHANGED
|
@@ -129,7 +129,30 @@
|
|
|
129
129
|
</template>
|
|
130
130
|
|
|
131
131
|
<template #left>
|
|
132
|
-
|
|
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" />
|
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.196",
|
|
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.",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
|
|
5
|
+
import { showSiteField } from "./role.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* QA on the admin app (PR #18) reported "no site options" on Create Role. The
|
|
9
|
+
* Site field is read-only by design - it shows the site a role belongs to -
|
|
10
|
+
* but an admin role has no site, so it drew permanently disabled and empty.
|
|
11
|
+
* `RolePermissionFormCreate` defaulted `siteState` to `true` and
|
|
12
|
+
* `RolePermissionMain` neither declared nor forwarded it, and its root is a
|
|
13
|
+
* `<div>`, so a fallthrough attribute could not reach the dialog either: no
|
|
14
|
+
* consuming app could switch the field off.
|
|
15
|
+
*/
|
|
16
|
+
test("an admin role never shows the site field", () => {
|
|
17
|
+
assert.equal(showSiteField(true, "admin"), false);
|
|
18
|
+
assert.equal(showSiteField(false, "admin"), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("every other role type keeps what the app asked for", () => {
|
|
22
|
+
for (const type of [
|
|
23
|
+
"app",
|
|
24
|
+
"organization",
|
|
25
|
+
"security_agency",
|
|
26
|
+
"cleaning_services",
|
|
27
|
+
]) {
|
|
28
|
+
assert.equal(showSiteField(true, type), true, type);
|
|
29
|
+
assert.equal(showSiteField(false, type), false, type);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("RolePermissionMain declares site-state and forwards it to the dialog", () => {
|
|
34
|
+
const src = readFileSync(
|
|
35
|
+
new URL("../components/RolePermissionMain.vue", import.meta.url),
|
|
36
|
+
"utf8"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
assert.match(src, /siteState:\s*\{/, "site-state must be a declared prop");
|
|
40
|
+
assert.match(
|
|
41
|
+
src,
|
|
42
|
+
/:site-state="siteFieldShown"/,
|
|
43
|
+
"the dialog must receive the resolved value, not a fallthrough attribute"
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The category expand toggle carried no class, so it fell back to raw Vuetify
|
|
49
|
+
* `inset` styling: a white thumb on a white card, measured 1.02:1 light /
|
|
50
|
+
* 1.04:1 dark against the dialog surface. `.app-switch` (primitives.css) is
|
|
51
|
+
* this layer's switch shape and is what makes the thumb visible. `inset` is
|
|
52
|
+
* removed with it - Vuetify's inset dimensions beat the `.app-switch` rules on
|
|
53
|
+
* source order and collapse the control to zero width.
|
|
54
|
+
*/
|
|
55
|
+
for (const file of [
|
|
56
|
+
"RolePermissionFormCreate.vue",
|
|
57
|
+
"RolePermissionFormPreviewUpdate.vue",
|
|
58
|
+
]) {
|
|
59
|
+
test(`${file} styles its category toggle with .app-switch`, () => {
|
|
60
|
+
const src = readFileSync(
|
|
61
|
+
new URL(`../components/${file}`, import.meta.url),
|
|
62
|
+
"utf8"
|
|
63
|
+
);
|
|
64
|
+
const sw = src.slice(src.indexOf("<v-switch"), src.indexOf("/>", src.indexOf("<v-switch")));
|
|
65
|
+
|
|
66
|
+
assert.ok(sw.includes('class="app-switch"'), "missing .app-switch");
|
|
67
|
+
assert.ok(!/\binset\b/.test(sw), "inset fights the .app-switch dimensions");
|
|
68
|
+
});
|
|
69
|
+
}
|
package/utils/role.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the Create-role dialog should show its (read-only) Site field.
|
|
3
|
+
*
|
|
4
|
+
* An admin role is org-wide, never site-scoped, so there is no site to show -
|
|
5
|
+
* the field could only ever render empty and permanently disabled, which is
|
|
6
|
+
* what QA reported on the admin app. Every other role type keeps whatever the
|
|
7
|
+
* consuming app asked for.
|
|
8
|
+
*/
|
|
9
|
+
export const showSiteField = (siteState: boolean, type: string): boolean =>
|
|
10
|
+
siteState && type !== "admin";
|
|
@@ -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
|
+
});
|