@rancher/shell 3.0.12-rc.6 → 3.0.12-rc.7

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.
Files changed (30) hide show
  1. package/apis/intf/shell-api/slide-in.ts +46 -17
  2. package/apis/shell/__tests__/slide-in.test.ts +83 -2
  3. package/apis/shell/slide-in.ts +20 -0
  4. package/components/ExplorerProjectsNamespaces.vue +2 -2
  5. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +9 -0
  6. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +5 -1
  7. package/components/SlideInPanelManager.vue +103 -25
  8. package/components/__tests__/SlideInPanelManager.spec.ts +153 -10
  9. package/components/auth/AuthBanner.vue +1 -1
  10. package/core/plugins-loader.js +2 -0
  11. package/models/__tests__/namespace.test.ts +133 -8
  12. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +57 -1
  13. package/models/namespace.js +34 -2
  14. package/models/provisioning.cattle.io.cluster.js +20 -13
  15. package/package.json +1 -1
  16. package/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts +136 -1
  17. package/pages/c/_cluster/explorer/workload-dashboard/composable.ts +69 -1
  18. package/pages/c/_cluster/fleet/index.vue +5 -9
  19. package/pkg/vue.config.js +4 -3
  20. package/plugins/codemirror.js +2 -0
  21. package/plugins/dashboard-store/resource-class.js +3 -6
  22. package/store/__tests__/action-menu.test.ts +622 -0
  23. package/store/__tests__/slideInPanel.test.ts +143 -43
  24. package/store/__tests__/wm.test.ts +503 -0
  25. package/store/aws.js +19 -4
  26. package/store/slideInPanel.ts +15 -3
  27. package/types/shell/index.d.ts +26 -2
  28. package/utils/__tests__/fleet-appco.test.ts +23 -0
  29. package/utils/fleet-appco.ts +6 -1
  30. package/utils/sort.js +1 -1
@@ -1,5 +1,21 @@
1
1
  import { Component } from 'vue';
2
2
 
3
+ /**
4
+ * Standard width presets for the Slide-In panel.
5
+ *
6
+ * - `'default'` — 33% of the viewport width
7
+ * - `'wide'` — 73% of the viewport width
8
+ */
9
+ export type SlideInWidth = 'default' | 'wide';
10
+
11
+ /**
12
+ * Standard height presets for the Slide-In panel.
13
+ *
14
+ * - `'default'` — positioned below the header bar, filling the remaining viewport height
15
+ * - `'full'` — full viewport height (100vh), covering the header and side menu
16
+ */
17
+ export type SlideInHeight = 'default' | 'full';
18
+
3
19
  /**
4
20
  *
5
21
  * Configuration object for opening a Slide-In panel. Here's what a Slide-In looks like in Rancher UI:
@@ -8,45 +24,50 @@ import { Component } from 'vue';
8
24
  export interface SlideInConfig {
9
25
  /**
10
26
  *
11
- * Width of the Slide-In panel in percentage, related to the window width. Defaults to `33%`
27
+ * title for the Slide-In panel.
28
+ * When set, a header bar with the title and a close button is displayed.
29
+ * When omitted, no header is shown.
12
30
  *
13
31
  */
14
- width?: string;
32
+ title?: string;
15
33
  /**
16
34
  *
17
- * Height of the Slide-In panel. Can be percentage or vh. Defaults to (window - header) height.
18
- * Can be set as `33%` or `80vh`
19
- *
20
- */
21
- height?: string;
22
- /**
35
+ * Width preset for the Slide-In panel. Defaults to `'default'` (33%).
23
36
  *
24
- * CSS Top position for the Slide-In panel, string using px, as `0px` or `20px`. Default is right below header height
37
+ * - `'default'` 33% of the viewport width
38
+ * - `'wide'` — 73% of the viewport width
25
39
  *
26
40
  */
27
- top?: string;
41
+ width?: SlideInWidth;
28
42
  /**
29
43
  *
30
- * title for the Slide-In panel
44
+ * Height preset for the Slide-In panel. Defaults to `'default'` (below header).
45
+ * When set to `'full'`, the panel covers the full viewport height and the z-index
46
+ * is automatically elevated above the side menu.
47
+ *
48
+ * - `'default'` — positioned below the header bar, filling the remaining viewport height
49
+ * - `'full'` — full viewport height (100vh), covering the header and side menu
31
50
  *
32
51
  */
33
- title?: string;
52
+ height?: SlideInHeight;
34
53
  /**
35
54
  *
36
- * Wether Slide-In header is displayed or not
55
+ * When `true`, disables the focus trap on the Slide-In panel.
56
+ * Useful for panels with no focusable elements or custom focus management.
37
57
  *
38
58
  */
39
- showHeader?: boolean;
59
+ disableFocusTrap?: boolean;
40
60
  /**
41
61
  *
42
- * Array of props to watch out for in route, when they change, closes Slide-In
62
+ * Array of route properties to watch. When any of them change, the Slide-In closes.
63
+ * Defaults to `['name', 'params', 'hash', 'query']`.
43
64
  * @ignore
44
65
  *
45
66
  */
46
- closeOnRouteChange?: [string];
67
+ closeOnRouteChange?: string[];
47
68
  /**
48
69
  *
49
- * Return focus selector for focus trap
70
+ * CSS selector for the element that should receive focus when the Slide-In closes.
50
71
  * @ignore
51
72
  *
52
73
  */
@@ -68,6 +89,14 @@ export interface SlideInConfig {
68
89
  props?: {
69
90
  [key: string]: any;
70
91
  };
92
+ /**
93
+ * @deprecated No longer needed — automatically managed based on `panelHeight`. Full deprecation expected in Rancher 2.17.
94
+ */
95
+ top?: string;
96
+ /**
97
+ * @deprecated No longer needed — header visibility is inferred from the presence of `title`. Full deprecation expected in Rancher 2.17.
98
+ */
99
+ showHeader?: boolean;
71
100
  }
72
101
 
73
102
  /**
@@ -38,7 +38,7 @@ describe('slideInApiImpl', () => {
38
38
  it('should open a slide-in panel with a config', () => {
39
39
  const config = {
40
40
  title: 'Test Panel',
41
- width: '50%',
41
+ width: 'default' as const,
42
42
  };
43
43
 
44
44
  // 3. Act
@@ -48,7 +48,7 @@ describe('slideInApiImpl', () => {
48
48
  expect(mockCommit).toHaveBeenCalledTimes(1);
49
49
  expect(mockCommit).toHaveBeenCalledWith('slideInPanel/open', {
50
50
  component: MockComponent,
51
- componentProps: { ...config }, // The implementation spreads the config
51
+ componentProps: { title: 'Test Panel', width: 'default' },
52
52
  });
53
53
  });
54
54
 
@@ -87,4 +87,85 @@ describe('slideInApiImpl', () => {
87
87
  },
88
88
  });
89
89
  });
90
+
91
+ it('should open with preset properties', () => {
92
+ const config = {
93
+ title: 'Preset Panel',
94
+ width: 'wide' as const,
95
+ height: 'full' as const,
96
+ };
97
+
98
+ slideInApi.open(MockComponent, config);
99
+
100
+ expect(mockCommit).toHaveBeenCalledTimes(1);
101
+ expect(mockCommit).toHaveBeenCalledWith('slideInPanel/open', {
102
+ component: MockComponent,
103
+ componentProps: {
104
+ title: 'Preset Panel',
105
+ width: 'wide',
106
+ height: 'full',
107
+ },
108
+ });
109
+ });
110
+
111
+ it('should open with disableFocusTrap option', () => {
112
+ const config = {
113
+ width: 'wide' as const,
114
+ height: 'full' as const,
115
+ disableFocusTrap: true,
116
+ };
117
+
118
+ slideInApi.open(MockComponent, config);
119
+
120
+ expect(mockCommit).toHaveBeenCalledTimes(1);
121
+ expect(mockCommit).toHaveBeenCalledWith('slideInPanel/open', {
122
+ component: MockComponent,
123
+ componentProps: {
124
+ width: 'wide',
125
+ height: 'full',
126
+ disableFocusTrap: true,
127
+ },
128
+ });
129
+ });
130
+
131
+ describe('deprecation warnings', () => {
132
+ const originalEnv = process.env.dev;
133
+ let warnSpy: jest.SpyInstance;
134
+
135
+ beforeEach(() => {
136
+ process.env.dev = 'true';
137
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation();
138
+ });
139
+
140
+ afterEach(() => {
141
+ process.env.dev = originalEnv;
142
+ warnSpy.mockRestore();
143
+ });
144
+
145
+ it('warns when deprecated "top" is used', () => {
146
+ slideInApi.open(MockComponent, { top: '0' });
147
+
148
+ expect(warnSpy).toHaveBeenCalledWith(
149
+ expect.stringContaining('"top" is deprecated')
150
+ );
151
+ });
152
+
153
+ it('warns when deprecated "showHeader" is used', () => {
154
+ slideInApi.open(MockComponent, { showHeader: false });
155
+
156
+ expect(warnSpy).toHaveBeenCalledWith(
157
+ expect.stringContaining('"showHeader" is deprecated')
158
+ );
159
+ });
160
+
161
+ it('does not warn when using preset properties', () => {
162
+ slideInApi.open(MockComponent, {
163
+ title: 'Test',
164
+ width: 'wide' as const,
165
+ height: 'full' as const,
166
+ });
167
+
168
+ expect(warnSpy).not.toHaveBeenCalled();
169
+ });
170
+ });
90
171
  });
@@ -2,6 +2,8 @@ import { Component } from 'vue';
2
2
  import { SlideInApi, SlideInConfig } from '@shell/apis/intf/shell';
3
3
  import { Store } from 'vuex';
4
4
 
5
+ const DEPRECATION_PREFIX = '[SlideIn API]';
6
+
5
7
  export class SlideInApiImpl implements SlideInApi {
6
8
  private store: Store<any>;
7
9
 
@@ -9,6 +11,20 @@ export class SlideInApiImpl implements SlideInApi {
9
11
  this.store = store;
10
12
  }
11
13
 
14
+ private warnDeprecated(config: SlideInConfig): void {
15
+ if (!process.env.dev) {
16
+ return;
17
+ }
18
+
19
+ if (config.top !== undefined) {
20
+ console.warn(`${ DEPRECATION_PREFIX } "top" is deprecated. It is now automatically managed based on "panelHeight". Full deprecation expected in Rancher 2.17.`); // eslint-disable-line no-console
21
+ }
22
+
23
+ if (config.showHeader !== undefined) {
24
+ console.warn(`${ DEPRECATION_PREFIX } "showHeader" is deprecated. Header visibility is now inferred from the presence of "title". Full deprecation expected in Rancher 2.17.`); // eslint-disable-line no-console
25
+ }
26
+ }
27
+
12
28
  /**
13
29
  * Opens a slide in panel in Rancher UI
14
30
  *
@@ -30,6 +46,10 @@ export class SlideInApiImpl implements SlideInApi {
30
46
  public open(component: Component, config?: SlideInConfig): void {
31
47
  const props = config?.props || {};
32
48
 
49
+ if (config) {
50
+ this.warnDeprecated(config);
51
+ }
52
+
33
53
  delete config?.props;
34
54
 
35
55
  this.store.commit('slideInPanel/open', {
@@ -157,7 +157,7 @@ export default {
157
157
  return uniq(ids);
158
158
  },
159
159
  clusterProjects() {
160
- const clusterId = this.currentCluster.id;
160
+ const clusterId = this.currentCluster?.id;
161
161
 
162
162
  // Get the list of projects from the store so that the list
163
163
  // is updated if a new project is created or removed.
@@ -276,7 +276,7 @@ export default {
276
276
  },
277
277
 
278
278
  canSeeProjectlessNamespaces() {
279
- return this.currentCluster.canUpdate;
279
+ return this.currentCluster?.canUpdate;
280
280
  },
281
281
 
282
282
  showMockNotInProjectGroup() {
@@ -59,6 +59,15 @@ describe('composables: IdentifyingFields', () => {
59
59
  expect(result?.value.label).toStrictEqual('component.resource.detail.metadata.identifyingInformation.namespace');
60
60
  expect(result?.value.valueDataTestid).toStrictEqual('masthead-subheader-namespace');
61
61
  });
62
+
63
+ it('should return a plain text namespace row when the resource signals namespaceLocation is unreachable', () => {
64
+ mockStore.getters['cluster/canList'] = () => true;
65
+ const resource = { namespace: 'NAMESPACE', namespaceLocation: null };
66
+ const result = useNamespace(resource);
67
+
68
+ expect(result?.value.valueOverride).toBeUndefined();
69
+ expect(result?.value.value).toStrictEqual(resource.namespace);
70
+ });
62
71
  });
63
72
 
64
73
  describe('useWorkspace', () => {
@@ -27,10 +27,14 @@ export const useNamespace = (resource: any): ComputedRef<Row> | undefined => {
27
27
  const currentStore = store.getters['currentStore'](NAMESPACE);
28
28
  const canList = store.getters[`${ currentStore }/canList`](NAMESPACE);
29
29
 
30
+ // A resource that explicitly returns null from namespaceLocation is signaling that
31
+ // the namespace lives in a cluster the user can't reach (mirrors legacy.vue's hideNamespaceLocation)
32
+ const hasReachableLocation = resourceValue.namespaceLocation !== null;
33
+
30
34
  const label = i18n.t('component.resource.detail.metadata.identifyingInformation.namespace');
31
35
  const value = resourceValue.namespace;
32
36
  const valueDataTestid = 'masthead-subheader-namespace';
33
- const valueOverride = canList ? {
37
+ const valueOverride = canList && hasReachableLocation ? {
34
38
  component: markRaw(defineAsyncComponent(() => import('@shell/components/Resource/Detail/ResourcePopover/index.vue'))),
35
39
  props: {
36
40
  type: NAMESPACE,
@@ -10,6 +10,13 @@ import { useRouter } from 'vue-router';
10
10
 
11
11
  const HEADER_HEIGHT = 55;
12
12
 
13
+ const WIDTH_MAP = {
14
+ default: '33%',
15
+ wide: '73%'
16
+ };
17
+
18
+ const HEIGHT_FULL = 'full';
19
+
13
20
  const slideInPanelManager = useTemplateRef('SlideInPanelManager');
14
21
  const slideInPanelManagerClose = useTemplateRef('SlideInPanelManagerClose');
15
22
 
@@ -19,12 +26,22 @@ const isClosing = computed(() => store.getters['slideInPanel/isClosing']);
19
26
  const currentComponent = computed(() => store.getters['slideInPanel/component']);
20
27
  const currentProps = computed(() => store.getters['slideInPanel/componentProps']);
21
28
 
22
- const panelTop = computed(() => {
23
- // Some components like the ResourceDetailDrawer are designed to take up the full height of the viewport so we want to be able to specify the top.
24
- if (currentProps?.value?.top) {
25
- return currentProps?.value?.top;
29
+ const resolvedHeightMode = computed(() => {
30
+ if (currentProps.value?.height) {
31
+ return currentProps.value.height;
32
+ }
33
+
34
+ // Deprecated: infer from raw top value
35
+ if (currentProps.value?.top === '0') {
36
+ return 'full';
26
37
  }
27
38
 
39
+ return 'default';
40
+ });
41
+
42
+ const isFullHeight = computed(() => resolvedHeightMode.value === HEIGHT_FULL);
43
+
44
+ const defaultTop = computed(() => {
28
45
  const banner = document.getElementById('banner-header');
29
46
  let height = HEADER_HEIGHT;
30
47
 
@@ -35,15 +52,51 @@ const panelTop = computed(() => {
35
52
  return `${ height }px`;
36
53
  });
37
54
 
38
- // Some components like the ResourceDetailDrawer are designed to take up the full height of the viewport so we want to be able to specify the height.
39
- const panelHeight = computed(() => (currentProps?.value?.height) ? (currentProps?.value?.height) : `calc(100vh - ${ panelTop?.value })`);
40
- const panelWidth = computed(() => currentProps?.value?.width || '33%');
41
- const panelRight = computed(() => (isOpen?.value ? '0' : `-${ panelWidth?.value }`));
55
+ const panelTop = computed(() => {
56
+ if (isFullHeight.value) {
57
+ return '0';
58
+ }
59
+
60
+ // Deprecated: explicit top value
61
+ if (currentProps.value?.top) {
62
+ return currentProps.value.top;
63
+ }
64
+
65
+ return defaultTop.value;
66
+ });
67
+
68
+ const panelHeight = computed(() => {
69
+ if (isFullHeight.value) {
70
+ return '100vh';
71
+ }
72
+
73
+ return `calc(100vh - ${ panelTop.value })`;
74
+ });
75
+
76
+ const panelWidth = computed(() => {
77
+ const width = currentProps.value?.width as keyof typeof WIDTH_MAP | undefined;
78
+
79
+ return (width && WIDTH_MAP[width]) || WIDTH_MAP.default;
80
+ });
81
+
82
+ const panelRight = computed(() => (isOpen.value ? '0' : `-${ panelWidth.value }`));
83
+
84
+ const glassZIndex = computed(() => (isFullHeight.value ? 101 : undefined));
85
+ const panelZIndex = computed(() => (isFullHeight.value ? 102 : undefined));
86
+
87
+ const showHeader = computed(() => {
88
+ // Deprecated: explicit showHeader takes precedence for backwards compat
89
+ if (currentProps.value?.showHeader !== undefined) {
90
+ return currentProps.value.showHeader;
91
+ }
92
+
93
+ return !!currentProps.value?.title;
94
+ });
95
+
96
+ const panelTitle = computed(() => currentProps.value?.title || (showHeader.value ? 'Details' : ''));
42
97
 
43
- const showHeader = computed(() => currentProps?.value?.showHeader ?? true);
44
- const panelTitle = showHeader.value ? computed(() => currentProps?.value?.title || 'Details') : null;
45
98
  const closeOnRouteChange = computed(() => {
46
- const propsCloseOnRouteChange = currentProps?.value.closeOnRouteChange;
99
+ const propsCloseOnRouteChange = currentProps.value?.closeOnRouteChange;
47
100
 
48
101
  if (!propsCloseOnRouteChange) {
49
102
  return ['name', 'params', 'hash', 'query'];
@@ -51,27 +104,32 @@ const closeOnRouteChange = computed(() => {
51
104
 
52
105
  return propsCloseOnRouteChange;
53
106
  });
107
+
54
108
  const router = useRouter();
55
109
 
56
110
  watch(
57
111
  /**
58
- * trigger focus trap
112
+ * Focus trap logic
59
113
  */
60
- () => isOpen?.value,
114
+ () => isOpen.value,
61
115
  (neu, old) => {
62
116
  if (neu && neu !== old) {
63
- const opts:any = {
117
+ if (currentProps.value?.disableFocusTrap) {
118
+ return;
119
+ }
120
+
121
+ const panelEl = slideInPanelManager.value as HTMLElement;
122
+ const closeEl = slideInPanelManagerClose.value;
123
+
124
+ const opts: any = {
64
125
  ...DEFAULT_FOCUS_TRAP_OPTS,
65
- // putting the initial focus on the first element that is not conditionally displayed
66
- initialFocus: slideInPanelManagerClose.value
126
+ initialFocus: closeEl || panelEl,
127
+ fallbackFocus: panelEl
67
128
  };
68
129
 
69
- const returnFocusSelector = currentProps?.value?.returnFocusSelector;
130
+ const returnFocusSelector = currentProps.value?.returnFocusSelector;
70
131
 
71
132
  if (returnFocusSelector) {
72
- /**
73
- * will return focus to the first iterable node of this container select
74
- */
75
133
  opts.setReturnFocus = () => {
76
134
  if (returnFocusSelector && !document.querySelector(returnFocusSelector)) {
77
135
  console.warn('SlideInPanelManager: cannot find elem with "returnFocusSelector", returning focus to main view'); // eslint-disable-line no-console
@@ -85,13 +143,13 @@ watch(
85
143
 
86
144
  useWatcherBasedSetupFocusTrapWithDestroyIncluded(
87
145
  () => {
88
- if (currentProps?.value?.focusTrapWatcherBasedVariable) {
146
+ if (currentProps.value?.focusTrapWatcherBasedVariable) {
89
147
  return currentProps.value.focusTrapWatcherBasedVariable;
90
148
  }
91
149
 
92
- return isOpen?.value && !isClosing?.value;
150
+ return isOpen.value && !isClosing.value;
93
151
  },
94
- slideInPanelManager.value as HTMLElement,
152
+ panelEl,
95
153
  opts,
96
154
  false
97
155
  );
@@ -102,7 +160,7 @@ watch(
102
160
  watch(
103
161
  () => router?.currentRoute?.value,
104
162
  (newValue, oldValue) => {
105
- if (!isOpen?.value) {
163
+ if (!isOpen.value) {
106
164
  return;
107
165
  }
108
166
 
@@ -137,6 +195,7 @@ function closePanel() {
137
195
  <div
138
196
  id="slide-in-panel-manager"
139
197
  ref="SlideInPanelManager"
198
+ tabindex="-1"
140
199
  @keydown.escape="closePanel"
141
200
  >
142
201
  <div
@@ -144,6 +203,7 @@ function closePanel() {
144
203
  data-testid="slide-in-glass"
145
204
  class="slide-in-glass"
146
205
  :class="{ 'slide-in-glass-open': isOpen }"
206
+ :style="{ zIndex: glassZIndex }"
147
207
  @click="closePanel"
148
208
  />
149
209
  <aside
@@ -154,6 +214,7 @@ function closePanel() {
154
214
  right: panelRight,
155
215
  top: panelTop,
156
216
  height: panelHeight,
217
+ zIndex: panelZIndex,
157
218
  }"
158
219
  >
159
220
  <div
@@ -167,6 +228,8 @@ function closePanel() {
167
228
  ref="SlideInPanelManagerClose"
168
229
  class="icon icon-close"
169
230
  data-testid="slide-in-close"
231
+ role="button"
232
+ :aria-label="t('generic.close')"
170
233
  :tabindex="isOpen ? 0 : -1"
171
234
  @click="closePanel"
172
235
  @keypress.enter="closePanel"
@@ -220,7 +283,7 @@ function closePanel() {
220
283
  .header {
221
284
  display: flex;
222
285
  align-items: center;
223
- padding: 4px;
286
+ padding: 4px 10px;
224
287
  border-bottom: 1px solid var(--border);
225
288
 
226
289
  .title {
@@ -229,11 +292,26 @@ function closePanel() {
229
292
  }
230
293
 
231
294
  .icon-close {
295
+ padding: 8px;
296
+ border-radius: 4px;
297
+ opacity: 0.7;
232
298
  cursor: pointer;
299
+
300
+ &:hover {
301
+ background-color: var(--primary);
302
+ color: var(--primary-text);
303
+ opacity: 1;
304
+ }
305
+
306
+ &:focus-visible {
307
+ @include focus-outline;
308
+ outline-offset: 2px;
309
+ }
233
310
  }
234
311
  }
235
312
 
236
313
  .main-panel {
314
+ flex: 1;
237
315
  padding: 10px;
238
316
  overflow: auto;
239
317
  }