@micha.bigler/ui-core-micha 2.34.0 → 2.36.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/WORK_ORDERS.md +17 -3
- package/dev/entries.jsx +79 -0
- package/dev/main.jsx +7 -1
- package/dist/components/MFAComponent.js +7 -6
- package/dist/components/SocialLoginButtons.js +2 -1
- package/dist/components/charts/ChartFrame.js +3 -1
- package/dist/components/charts/exportChart.js +2 -2
- package/dist/i18n/authTranslations.js +24 -0
- package/dist/i18n/sectionNavTranslations.js +20 -0
- package/dist/index.js +3 -1
- package/dist/layout/SectionNav.js +138 -0
- package/dist/pages/AccountPage.js +44 -2
- package/dist/theme/index.js +1 -1
- package/dist/theme/kitSxRegistry.js +8 -1
- package/dist/theme/themeCompleteness.js +218 -14
- package/package.json +1 -1
- package/src/components/MFAComponent.jsx +7 -6
- package/src/components/SocialLoginButtons.jsx +2 -1
- package/src/components/charts/ChartFrame.jsx +7 -1
- package/src/components/charts/exportChart.js +2 -2
- package/src/i18n/authTranslations.ts +24 -0
- package/src/i18n/sectionNavTranslations.ts +20 -0
- package/src/index.js +3 -0
- package/src/layout/SectionNav.jsx +299 -0
- package/src/pages/AccountPage.jsx +55 -14
- package/src/theme/index.js +1 -0
- package/src/theme/kitSxRegistry.js +11 -1
- package/src/theme/themeCompleteness.js +237 -14
- package/tests/AccountPage.test.jsx +193 -0
- package/tests/ChartFrame.test.jsx +5 -0
- package/tests/SectionNav.test.jsx +274 -0
- package/tests/themeCompleteness.test.js +156 -4
- package/work-orders/AUTH-4.md +183 -0
- package/work-orders/I18N-1.md +254 -0
- package/work-orders/SHELL-5.md +345 -0
- package/work-orders/THEME-5.md +341 -0
- package/work-orders/assets/AUTH-4-user-list-card.html +407 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createTheme, getContrastRatio as getMuiContrastRatio } from '@mui/material/styles';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
BASELINE_PALETTE,
|
|
5
|
+
BASELINE_STATIC,
|
|
6
|
+
STATUS_KEYS,
|
|
7
|
+
TYPOGRAPHY_VARIANTS,
|
|
8
|
+
} from './tokens';
|
|
4
9
|
import { KIT_COMPONENT_SX_REGISTRY } from './kitSxRegistry';
|
|
5
10
|
|
|
6
11
|
const MUI_DEFAULT_THEME = createTheme();
|
|
@@ -373,18 +378,28 @@ const SAME_ROOT_STYLE_OVERRIDE_SLOTS = {
|
|
|
373
378
|
MuiChip: ['root', 'outlined'],
|
|
374
379
|
};
|
|
375
380
|
|
|
376
|
-
// MUI's sx prop accepts CSS spacing shorthands
|
|
377
|
-
// styleOverrides' plain CSS-in-JS objects do not
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// expanded to the canonical longhand properties it can affect so the
|
|
381
|
-
// disjointness check compares axes, not raw key spellings; a property
|
|
381
|
+
// MUI's sx prop accepts aliases and CSS spacing shorthands that
|
|
382
|
+
// styleOverrides' plain CSS-in-JS objects do not. Each known alias is
|
|
383
|
+
// expanded to the canonical CSS properties it can affect so the
|
|
384
|
+
// disjointness check compares effects, not raw key spellings; a property
|
|
382
385
|
// outside this table expands to itself.
|
|
383
386
|
const SPACING_SHORTHAND_LONGHANDS = {
|
|
387
|
+
bgcolor: ['backgroundColor'],
|
|
388
|
+
typography: [
|
|
389
|
+
'fontFamily',
|
|
390
|
+
'fontSize',
|
|
391
|
+
'fontStyle',
|
|
392
|
+
'fontWeight',
|
|
393
|
+
'letterSpacing',
|
|
394
|
+
'lineHeight',
|
|
395
|
+
'textTransform',
|
|
396
|
+
],
|
|
384
397
|
p: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
|
|
385
398
|
padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
|
|
386
399
|
px: ['paddingLeft', 'paddingRight'],
|
|
400
|
+
paddingX: ['paddingLeft', 'paddingRight'],
|
|
387
401
|
py: ['paddingTop', 'paddingBottom'],
|
|
402
|
+
paddingY: ['paddingTop', 'paddingBottom'],
|
|
388
403
|
pt: ['paddingTop'],
|
|
389
404
|
pr: ['paddingRight'],
|
|
390
405
|
pb: ['paddingBottom'],
|
|
@@ -392,7 +407,9 @@ const SPACING_SHORTHAND_LONGHANDS = {
|
|
|
392
407
|
m: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
|
|
393
408
|
margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
|
|
394
409
|
mx: ['marginLeft', 'marginRight'],
|
|
410
|
+
marginX: ['marginLeft', 'marginRight'],
|
|
395
411
|
my: ['marginTop', 'marginBottom'],
|
|
412
|
+
marginY: ['marginTop', 'marginBottom'],
|
|
396
413
|
mt: ['marginTop'],
|
|
397
414
|
mr: ['marginRight'],
|
|
398
415
|
mb: ['marginBottom'],
|
|
@@ -415,14 +432,12 @@ function styleOverrideKeys(componentConfig, slots = ['root']) {
|
|
|
415
432
|
|
|
416
433
|
/**
|
|
417
434
|
* Reports top-level property collisions between registered kit sx objects and
|
|
418
|
-
* the baseline MUI component they target.
|
|
419
|
-
*
|
|
420
|
-
* see `SPACING_SHORTHAND_LONGHANDS`.
|
|
435
|
+
* the baseline MUI component they target. Known sx aliases are compared by
|
|
436
|
+
* the CSS properties they affect, not by literal key name.
|
|
421
437
|
*
|
|
422
|
-
* This is deliberately a lower bound on shadowing:
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
* disjoint; it is not proof that no component can shadow the baseline.
|
|
438
|
+
* This is deliberately a lower bound on shadowing: unknown normalisation gaps
|
|
439
|
+
* remain. A clean result proves only that the registered top-level key sets
|
|
440
|
+
* are disjoint; it is not proof that no component can shadow the baseline.
|
|
426
441
|
*/
|
|
427
442
|
export function assertKitSxDisjoint(
|
|
428
443
|
theme,
|
|
@@ -593,6 +608,214 @@ export function reportKitSxBypasses(sources = [], { registry } = {}) {
|
|
|
593
608
|
return { findings };
|
|
594
609
|
}
|
|
595
610
|
|
|
611
|
+
const BASIC_CSS_COLOUR_NAMES = new Set([
|
|
612
|
+
'aqua',
|
|
613
|
+
'black',
|
|
614
|
+
'blue',
|
|
615
|
+
'fuchsia',
|
|
616
|
+
'gray',
|
|
617
|
+
'green',
|
|
618
|
+
'grey',
|
|
619
|
+
'lime',
|
|
620
|
+
'maroon',
|
|
621
|
+
'navy',
|
|
622
|
+
'olive',
|
|
623
|
+
'purple',
|
|
624
|
+
'red',
|
|
625
|
+
'silver',
|
|
626
|
+
'teal',
|
|
627
|
+
'white',
|
|
628
|
+
'yellow',
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
const MUI_DEFAULT_NUMERIC_RAMP_NAMES = new Set([
|
|
632
|
+
'amber',
|
|
633
|
+
'blue',
|
|
634
|
+
'bluegrey',
|
|
635
|
+
'brown',
|
|
636
|
+
'cyan',
|
|
637
|
+
'deeporange',
|
|
638
|
+
'deeppurple',
|
|
639
|
+
'green',
|
|
640
|
+
'grey',
|
|
641
|
+
'indigo',
|
|
642
|
+
'lightblue',
|
|
643
|
+
'lightgreen',
|
|
644
|
+
'lime',
|
|
645
|
+
'orange',
|
|
646
|
+
'pink',
|
|
647
|
+
'purple',
|
|
648
|
+
'red',
|
|
649
|
+
'teal',
|
|
650
|
+
'yellow',
|
|
651
|
+
]);
|
|
652
|
+
|
|
653
|
+
const HEX_COLOUR_PATTERN = /#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})(?![\da-f])/gi;
|
|
654
|
+
const RGB_COLOUR_PATTERN = /rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+(?:\s*,\s*[\d.]+)?\s*\)/gi;
|
|
655
|
+
const NAMED_COLOUR_PATTERN = new RegExp(
|
|
656
|
+
`\\b(?:${[...BASIC_CSS_COLOUR_NAMES].join('|')})\\b`,
|
|
657
|
+
'gi',
|
|
658
|
+
);
|
|
659
|
+
const COLOUR_PROPERTY_PATTERN = /\b(?:background(?:Color)?|bgcolor|border(?:Top|Right|Bottom|Left)?(?:Color)?|boxShadow|caretColor|color|fill|fillStyle|outline(?:Color)?|stroke|strokeStyle|textShadow|WebkitTextFillColor)\s*(?::|=)\s*$/;
|
|
660
|
+
|
|
661
|
+
function colourKey(value) {
|
|
662
|
+
const colour = parseColour(value);
|
|
663
|
+
return colour ? `${colour.r},${colour.g},${colour.b},${colour.a}` : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function collectBaselineColourKeys(...roots) {
|
|
667
|
+
const keys = new Set();
|
|
668
|
+
const visit = (value) => {
|
|
669
|
+
if (typeof value === 'string') {
|
|
670
|
+
for (const match of value.matchAll(HEX_COLOUR_PATTERN)) {
|
|
671
|
+
const key = colourKey(match[0]);
|
|
672
|
+
if (key) keys.add(key);
|
|
673
|
+
}
|
|
674
|
+
for (const match of value.matchAll(RGB_COLOUR_PATTERN)) {
|
|
675
|
+
const key = colourKey(match[0]);
|
|
676
|
+
if (key) keys.add(key);
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (Array.isArray(value)) {
|
|
681
|
+
value.forEach(visit);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (value && typeof value === 'object') Object.values(value).forEach(visit);
|
|
685
|
+
};
|
|
686
|
+
roots.forEach(visit);
|
|
687
|
+
return keys;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function maskComments(source) {
|
|
691
|
+
const characters = [...source];
|
|
692
|
+
let quote = null;
|
|
693
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
694
|
+
const character = characters[index];
|
|
695
|
+
if (quote) {
|
|
696
|
+
if (character === quote && characters[index - 1] !== '\\') quote = null;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (character === '"' || character === "'" || character === '`') {
|
|
700
|
+
quote = character;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (character === '/' && characters[index + 1] === '/') {
|
|
704
|
+
for (; index < characters.length && characters[index] !== '\n'; index += 1) {
|
|
705
|
+
characters[index] = ' ';
|
|
706
|
+
}
|
|
707
|
+
index -= 1;
|
|
708
|
+
} else if (character === '/' && characters[index + 1] === '*') {
|
|
709
|
+
characters[index] = ' ';
|
|
710
|
+
characters[index + 1] = ' ';
|
|
711
|
+
index += 2;
|
|
712
|
+
while (index < characters.length
|
|
713
|
+
&& !(characters[index] === '*' && characters[index + 1] === '/')) {
|
|
714
|
+
if (characters[index] !== '\n' && characters[index] !== '\r') characters[index] = ' ';
|
|
715
|
+
index += 1;
|
|
716
|
+
}
|
|
717
|
+
if (index < characters.length) {
|
|
718
|
+
characters[index] = ' ';
|
|
719
|
+
characters[index + 1] = ' ';
|
|
720
|
+
index += 1;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return characters.join('');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function maskQrPrintDocument(source, path) {
|
|
728
|
+
if (!path.replaceAll('\\', '/').endsWith('/QrSignupManager.jsx')) return source;
|
|
729
|
+
const call = /\bprintWindow\.document\.write\s*\(\s*`/g.exec(source);
|
|
730
|
+
if (!call) return source;
|
|
731
|
+
const start = call.index + call[0].lastIndexOf('`');
|
|
732
|
+
let end = start + 1;
|
|
733
|
+
while (end < source.length) {
|
|
734
|
+
if (source[end] === '`' && source[end - 1] !== '\\') break;
|
|
735
|
+
end += 1;
|
|
736
|
+
}
|
|
737
|
+
if (end >= source.length) return source;
|
|
738
|
+
|
|
739
|
+
// The generated print document is standalone HTML rendered outside the
|
|
740
|
+
// React tree, where no application theme is available. Mask the region as
|
|
741
|
+
// one unit instead of accumulating permanent per-colour exemptions.
|
|
742
|
+
return source.slice(0, start)
|
|
743
|
+
+ source.slice(start, end + 1).replace(/[^\r\n]/g, ' ')
|
|
744
|
+
+ source.slice(end + 1);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function normalizedSources(sources) {
|
|
748
|
+
return sources.map((entry, index) =>
|
|
749
|
+
typeof entry === 'string'
|
|
750
|
+
? { path: `source-${index + 1}`, source: entry }
|
|
751
|
+
: { path: entry.path || `source-${index + 1}`, source: entry.source || '' },
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Reports colour literals in component source that do not come from the
|
|
757
|
+
* baseline palette, plus report-only uses of MUI's untouched numeric ramps.
|
|
758
|
+
*/
|
|
759
|
+
export function reportOffPaletteColours(
|
|
760
|
+
sources = [],
|
|
761
|
+
{ palette = BASELINE_PALETTE, baselineStatic = BASELINE_STATIC } = {},
|
|
762
|
+
) {
|
|
763
|
+
const allowedColours = collectBaselineColourKeys(palette, baselineStatic);
|
|
764
|
+
const findings = [];
|
|
765
|
+
const stringPattern = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g;
|
|
766
|
+
|
|
767
|
+
for (const { path, source } of normalizedSources(sources)) {
|
|
768
|
+
const normalizedPath = path.replaceAll('\\', '/');
|
|
769
|
+
if (/(^|\/)src\/theme\//.test(normalizedPath)) continue;
|
|
770
|
+
const scanSource = maskComments(maskQrPrintDocument(source, normalizedPath));
|
|
771
|
+
|
|
772
|
+
for (const match of scanSource.matchAll(stringPattern)) {
|
|
773
|
+
const value = match[2].trim();
|
|
774
|
+
const line = source.slice(0, match.index).split(/\r?\n/).length;
|
|
775
|
+
const surface = `${path}:${line}.colour`;
|
|
776
|
+
|
|
777
|
+
for (const hex of value.matchAll(HEX_COLOUR_PATTERN)) {
|
|
778
|
+
if (!allowedColours.has(colourKey(hex[0]))) {
|
|
779
|
+
findings.push({
|
|
780
|
+
surface,
|
|
781
|
+
reason: `Off-palette hex colour "${hex[0]}" is not derived from the baseline palette.`,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
for (const rgb of value.matchAll(RGB_COLOUR_PATTERN)) {
|
|
787
|
+
if (!allowedColours.has(colourKey(rgb[0]))) {
|
|
788
|
+
findings.push({
|
|
789
|
+
surface,
|
|
790
|
+
reason: `Off-palette rgb colour "${rgb[0]}" is not derived from the baseline palette.`,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const before = scanSource.slice(Math.max(0, match.index - 40), match.index);
|
|
796
|
+
const isColourProperty = COLOUR_PROPERTY_PATTERN.test(before);
|
|
797
|
+
const numericRamp = value.match(/^([a-z]+)\.(\d+)$/i);
|
|
798
|
+
if (isColourProperty && !numericRamp) {
|
|
799
|
+
for (const namedColour of value.matchAll(NAMED_COLOUR_PATTERN)) {
|
|
800
|
+
findings.push({
|
|
801
|
+
surface,
|
|
802
|
+
reason: `Named CSS colour "${namedColour[0]}" bypasses the baseline palette.`,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (numericRamp && MUI_DEFAULT_NUMERIC_RAMP_NAMES.has(numericRamp[1].toLowerCase())) {
|
|
808
|
+
findings.push({
|
|
809
|
+
surface,
|
|
810
|
+
reason: `Report only: "${value}" uses MUI's untouched numeric palette ramp, not a baseline token.`,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return { findings };
|
|
817
|
+
}
|
|
818
|
+
|
|
596
819
|
/** Reports adoption signals as numbers; it never changes completeness. */
|
|
597
820
|
export function reportThemeAdoption(sources = []) {
|
|
598
821
|
const normalized = sources.map((entry, index) =>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
7
|
+
|
|
8
|
+
vi.mock('react-i18next', () => ({
|
|
9
|
+
useTranslation: () => ({
|
|
10
|
+
t: (key, fallback) => ({
|
|
11
|
+
'Account.TITLE': 'Account & Administration',
|
|
12
|
+
'Account.PAGE_TITLE': 'Account',
|
|
13
|
+
'Account.TAB_PROFILE': 'Profile',
|
|
14
|
+
'Account.TAB_SECURITY': 'Security',
|
|
15
|
+
'Account.TAB_USERS': 'Users',
|
|
16
|
+
'Account.TAB_INVITE': 'Invite',
|
|
17
|
+
'Account.TAB_SUPPORT': 'Support',
|
|
18
|
+
'Account.GROUP_MY_ACCOUNT': 'My account',
|
|
19
|
+
'Account.GROUP_MANAGEMENT': 'Management',
|
|
20
|
+
'Account.GROUP_HELP': 'Help',
|
|
21
|
+
'Account.GROUP_MORE': 'More',
|
|
22
|
+
'SectionNav.TITLE': 'Switch section',
|
|
23
|
+
'SectionNav.TRIGGER_EYEBROW': 'Section',
|
|
24
|
+
'SectionNav.LAST_OPENED': 'Last opened',
|
|
25
|
+
}[key] ?? fallback ?? key),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('../src/auth/authApi', () => ({
|
|
30
|
+
fetchAuthPolicy: vi.fn(() => new Promise(() => {})),
|
|
31
|
+
updateUserProfile: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('../src/components/ProfileComponent', () => ({
|
|
35
|
+
ProfileComponent: () => <div>Profile content</div>,
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('../src/components/SecurityComponent', () => ({
|
|
38
|
+
SecurityComponent: () => <div>Security content</div>,
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('../src/components/UserListComponent', () => ({
|
|
41
|
+
UserListComponent: () => <div>Users content</div>,
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('../src/components/UserInviteComponent', () => ({ UserInviteComponent: () => null }));
|
|
44
|
+
vi.mock('../src/components/AccessCodeManager', () => ({ AccessCodeManager: () => null }));
|
|
45
|
+
vi.mock('../src/components/AllowedEmailDomainsManager', () => ({ AllowedEmailDomainsManager: () => null }));
|
|
46
|
+
vi.mock('../src/components/RegistrationMethodsManager', () => ({ RegistrationMethodsManager: () => null }));
|
|
47
|
+
vi.mock('../src/components/AuthFactorRequirementCard', () => ({ AuthFactorRequirementCard: () => null }));
|
|
48
|
+
vi.mock('../src/components/AccessCodeSingleUseToggle', () => ({ AccessCodeSingleUseToggle: () => null }));
|
|
49
|
+
vi.mock('../src/components/QrSignupManager', () => ({ QrSignupManager: () => null }));
|
|
50
|
+
vi.mock('../src/components/QrSignupValidityManager', () => ({ QrSignupValidityManager: () => null }));
|
|
51
|
+
vi.mock('../src/components/SupportRecoveryRequestsTab', () => ({
|
|
52
|
+
SupportRecoveryRequestsTab: () => <div>Support content</div>,
|
|
53
|
+
}));
|
|
54
|
+
vi.mock('../src/components/BulkInviteCsvTab', () => ({ BulkInviteCsvTab: () => null }));
|
|
55
|
+
|
|
56
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
57
|
+
import { AccountPage } from '../src/pages/AccountPage';
|
|
58
|
+
|
|
59
|
+
const theme = createTheme();
|
|
60
|
+
const superuser = {
|
|
61
|
+
email: 'admin@example.com',
|
|
62
|
+
is_superuser: true,
|
|
63
|
+
ui_permissions: {},
|
|
64
|
+
available_roles: [],
|
|
65
|
+
};
|
|
66
|
+
const notificationsTab = {
|
|
67
|
+
value: 'notifications',
|
|
68
|
+
label: 'Notifications',
|
|
69
|
+
render: () => <div>Notifications content</div>,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function setViewportWidth(width) {
|
|
73
|
+
window.matchMedia = vi.fn().mockImplementation((query) => {
|
|
74
|
+
const maxWidth = query.match(/max-width:(\d+(?:\.\d+)?)px/)?.[1];
|
|
75
|
+
const minWidth = query.match(/min-width:(\d+(?:\.\d+)?)px/)?.[1];
|
|
76
|
+
const matches = maxWidth
|
|
77
|
+
? width <= Number(maxWidth)
|
|
78
|
+
: minWidth
|
|
79
|
+
? width >= Number(minWidth)
|
|
80
|
+
: false;
|
|
81
|
+
return {
|
|
82
|
+
matches,
|
|
83
|
+
media: query,
|
|
84
|
+
onchange: null,
|
|
85
|
+
addEventListener: vi.fn(),
|
|
86
|
+
removeEventListener: vi.fn(),
|
|
87
|
+
addListener: vi.fn(),
|
|
88
|
+
removeListener: vi.fn(),
|
|
89
|
+
dispatchEvent: vi.fn(),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function LocationProbe() {
|
|
95
|
+
const location = useLocation();
|
|
96
|
+
return <output data-testid="location-search">{location.search}</output>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderAccount({ user = superuser, route = '/account', extraTabs = [notificationsTab] } = {}) {
|
|
100
|
+
return render(
|
|
101
|
+
<ThemeProvider theme={theme}>
|
|
102
|
+
<AuthContext.Provider value={{ user, login: vi.fn(), loading: false }}>
|
|
103
|
+
<MemoryRouter initialEntries={[route]}>
|
|
104
|
+
<Routes>
|
|
105
|
+
<Route
|
|
106
|
+
path="/account"
|
|
107
|
+
element={(
|
|
108
|
+
<>
|
|
109
|
+
<AccountPage extraTabs={extraTabs} />
|
|
110
|
+
<LocationProbe />
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
/>
|
|
114
|
+
</Routes>
|
|
115
|
+
</MemoryRouter>
|
|
116
|
+
</AuthContext.Provider>
|
|
117
|
+
</ThemeProvider>,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('AccountPage section navigation', () => {
|
|
122
|
+
beforeEach(() => setViewportWidth(375));
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
cleanup();
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('makes all six cockpit-shaped sections reachable at 375px without a horizontal strip', () => {
|
|
130
|
+
const { container } = renderAccount();
|
|
131
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
132
|
+
|
|
133
|
+
expect(container.querySelector('.MuiTabs-root')).toBeNull();
|
|
134
|
+
expect(getComputedStyle(trigger).width).toBe('100%');
|
|
135
|
+
fireEvent.click(trigger);
|
|
136
|
+
|
|
137
|
+
const sectionButtons = ['Profile', 'Security', 'Users', 'Invite', 'Support', 'Notifications']
|
|
138
|
+
.map((label) => screen.getByRole('button', { name: label }));
|
|
139
|
+
expect(sectionButtons).toHaveLength(6);
|
|
140
|
+
sectionButtons.forEach((item) => {
|
|
141
|
+
expect(getComputedStyle(item).position).not.toBe('absolute');
|
|
142
|
+
});
|
|
143
|
+
expect(document.documentElement.scrollWidth - document.documentElement.clientWidth).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('keeps the ?tab= contract and updates it when a drawer item is selected', () => {
|
|
147
|
+
renderAccount({ route: '/account?tab=notifications' });
|
|
148
|
+
|
|
149
|
+
expect(screen.getByText('Notifications content')).toBeTruthy();
|
|
150
|
+
const trigger = screen.getByRole('button', { name: /Section Notifications/i });
|
|
151
|
+
fireEvent.click(trigger);
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'Security' }));
|
|
153
|
+
|
|
154
|
+
expect(screen.getByText('Security content')).toBeTruthy();
|
|
155
|
+
expect(screen.getByTestId('location-search').textContent).toBe('?tab=security');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps permission-gated sections absent and falls an unpermitted query back to profile', () => {
|
|
159
|
+
renderAccount({
|
|
160
|
+
user: {
|
|
161
|
+
email: 'person@example.com',
|
|
162
|
+
is_superuser: false,
|
|
163
|
+
ui_permissions: {},
|
|
164
|
+
available_roles: [],
|
|
165
|
+
},
|
|
166
|
+
route: '/account?tab=users',
|
|
167
|
+
extraTabs: [],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(screen.getByText('Profile content')).toBeTruthy();
|
|
171
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
172
|
+
fireEvent.click(trigger);
|
|
173
|
+
expect(screen.getByRole('button', { name: 'Profile' })).toBeTruthy();
|
|
174
|
+
expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
|
|
175
|
+
expect(screen.queryByRole('button', { name: 'Users' })).toBeNull();
|
|
176
|
+
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
|
|
177
|
+
expect(screen.queryByRole('button', { name: 'Support' })).toBeNull();
|
|
178
|
+
expect(screen.queryByText('Management')).toBeNull();
|
|
179
|
+
expect(screen.queryByText('Help')).toBeNull();
|
|
180
|
+
expect(screen.queryByText('More')).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uses the desktop grouped grid at 1280px with no tab strip left behind', () => {
|
|
184
|
+
setViewportWidth(1280);
|
|
185
|
+
const { container } = renderAccount();
|
|
186
|
+
const grid = container.querySelector('[data-section-nav-mode="desktop"]');
|
|
187
|
+
|
|
188
|
+
expect(grid).toBeTruthy();
|
|
189
|
+
expect(getComputedStyle(grid).gridTemplateColumns).toBe('280px minmax(0, 1fr)');
|
|
190
|
+
expect(container.querySelector('.MuiTabs-root')).toBeNull();
|
|
191
|
+
expect(container.querySelector('nav')).toBeTruthy();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -154,6 +154,11 @@ describe('ChartFrame', () => {
|
|
|
154
154
|
|
|
155
155
|
fireEvent.click(screen.getByRole('button', { name: 'Export PNG' }));
|
|
156
156
|
await vi.waitFor(() => expect(chartExport.exportChartPng).toHaveBeenCalledOnce());
|
|
157
|
+
expect(chartExport.exportChartPng).toHaveBeenCalledWith(
|
|
158
|
+
expect.any(HTMLElement),
|
|
159
|
+
'chart.png',
|
|
160
|
+
'#fff',
|
|
161
|
+
);
|
|
157
162
|
await vi.waitFor(() => expect(onExportPng).toHaveBeenCalledOnce());
|
|
158
163
|
});
|
|
159
164
|
|