@rancher/shell 3.0.9-rc.2 → 3.0.9-rc.3

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 (36) hide show
  1. package/assets/translations/en-us.yaml +10 -0
  2. package/assets/translations/zh-hans.yaml +13 -0
  3. package/components/ActionMenu.vue +7 -8
  4. package/components/ActionMenuShell.vue +19 -20
  5. package/components/Resource/Detail/Card/Scaler.vue +10 -2
  6. package/components/Resource/Detail/Card/StatusCard/index.vue +4 -1
  7. package/components/ResourceTable.vue +1 -1
  8. package/components/Tabbed/Tab.vue +4 -0
  9. package/components/Tabbed/index.vue +11 -3
  10. package/components/__tests__/ProjectRow.test.ts +102 -15
  11. package/components/form/ResourceQuota/Project.vue +59 -8
  12. package/components/form/ResourceQuota/ProjectRow.vue +116 -21
  13. package/components/form/ResourceQuota/shared.js +42 -18
  14. package/components/formatter/LinkName.vue +3 -2
  15. package/config/product/explorer.js +1 -1
  16. package/config/table-headers.js +9 -7
  17. package/config/types.js +4 -1
  18. package/detail/management.cattle.io.oidcclient.vue +15 -4
  19. package/edit/__tests__/management.cattle.io.project.test.js +137 -0
  20. package/edit/management.cattle.io.project.vue +36 -6
  21. package/edit/monitoring.coreos.com.alertmanagerconfig/index.vue +16 -3
  22. package/initialize/install-plugins.js +0 -2
  23. package/models/management.cattle.io.cluster.js +22 -30
  24. package/models/provisioning.cattle.io.cluster.js +2 -2
  25. package/package.json +1 -1
  26. package/pages/__tests__/diagnostic.test.ts +71 -0
  27. package/pages/c/_cluster/explorer/tools/index.vue +23 -5
  28. package/pages/c/_cluster/monitoring/alertmanagerconfig/_alertmanagerconfigid/receiver.vue +18 -5
  29. package/pages/c/_cluster/uiplugins/index.vue +40 -8
  30. package/pages/diagnostic.vue +17 -3
  31. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +1 -0
  32. package/rancher-components/RcItemCard/RcItemCard.test.ts +16 -6
  33. package/rancher-components/RcItemCard/RcItemCard.vue +13 -23
  34. package/store/__tests__/auth.test.ts +21 -5
  35. package/store/auth.js +6 -3
  36. package/types/shell/index.d.ts +163 -156
@@ -3879,6 +3879,8 @@ istio:
3879
3879
  itemCard:
3880
3880
  ariaLabel:
3881
3881
  clickable: Click on card for {cardTitle}
3882
+ actionMenu:
3883
+ label: '{cardTitle} actions'
3882
3884
  jwt:
3883
3885
  title: JWT Authentication
3884
3886
  actions:
@@ -8855,6 +8857,7 @@ notifications:
8855
8857
  showCheckboxLabel: Show custom login error
8856
8858
  messageLabel: Text to display
8857
8859
  resourceQuota:
8860
+ banner: Limit resource consumption in a project for both standard (e.g. CPU Limit) and custom resource types. For custom resource types you have to provide the resource identifier yourself. Want to learn more about resource quotas? Read our <a href="https://ranchermanager.docs.rancher.com/how-to-guides/advanced-user-guides/manage-projects/manage-project-resource-quotas" target="_blank" rel="noopener noreferrer nofollow">documentation <i class="icon icon-external-link"></i></a><span class="sr-only">Opens in a new tab</span>
8858
8861
  label: Resource Quotas
8859
8862
  headers:
8860
8863
  limit: Limit
@@ -8862,9 +8865,14 @@ resourceQuota:
8862
8865
  projectLimit: Project Limit
8863
8866
  projectResourceAvailability: Project Resource Availability
8864
8867
  resourceType: Resource Type
8868
+ resourceIdentifier: Resource Identifier
8865
8869
  helpText: Configure how much of the resources the namespace as a whole can consume.
8866
8870
  helpTextDetail: The amount of resources the namespace as a whole can consume.
8867
8871
  helpTextHarvester: VMs need to reserve additional memory overhead.
8872
+ custom: Custom
8873
+ resourceIdentifier:
8874
+ placeholder: e.g. configmaps
8875
+ tooltip: Select 'Custom' from the 'Resource Type' list and enter the resource identifier (e.g. requests.nvidia.com/gpu) to add custom resources quotas.
8868
8876
  configMaps: Config Maps
8869
8877
  limitsCpu: CPU Limit
8870
8878
  limitsMemory: Memory Limit
@@ -8894,6 +8902,8 @@ resourceQuota:
8894
8902
  unitlessPlaceholder: e.g. 10
8895
8903
  add:
8896
8904
  label: Add Resource
8905
+ errors:
8906
+ customTypeRequired: Resource identifier is required
8897
8907
  tooltip:
8898
8908
  reserved: 'Other Namespaces:'
8899
8909
  namespace: 'This Namespace:'
@@ -651,6 +651,10 @@ asyncButton:
651
651
  action: 保存
652
652
  success: 已保存
653
653
  waiting: 正在保存&hellip;
654
+ editVersion:
655
+ action: 保存更改
656
+ success: 已保存
657
+ waiting: 正在保存更改&hellip;
654
658
  enable:
655
659
  action: 启用
656
660
  success: 启用
@@ -874,6 +878,13 @@ catalog:
874
878
  windows: Windows
875
879
  search: 筛选
876
880
  install:
881
+ warning:
882
+ managed: |-
883
+ 警告,{manager} 管理 {name} 应用的部署和升级。不支持升级此应用。<br>
884
+ {version, select,
885
+ null { }
886
+ other { 在大多数情况下,无需用户干预即可确保 {version} 版本与你正在运行的 Rancher 版本兼容。}
887
+ }
877
888
  appReadmeMissing: 此 Chart 没有其他 Chart 信息。
878
889
  appReadmeTitle: Chart 信息(Helm 自述)
879
890
  chart: Chart
@@ -939,6 +950,8 @@ catalog:
939
950
  install { 创建 }
940
951
  upgrade { 升级 }
941
952
  update { 更新 }
953
+ editVersion { 更新 }
954
+ downgrade { 降级 }
942
955
  } 这个 {existing, select,
943
956
  true { 应用 }
944
957
  false { Chart}
@@ -11,7 +11,7 @@ const SHOW = 'show';
11
11
  export default {
12
12
  name: 'ActionMenu',
13
13
 
14
- emits: ['close'],
14
+ emits: ['close', 'action-invoked'],
15
15
 
16
16
  components: { IconOrSvg },
17
17
  props: {
@@ -217,15 +217,14 @@ export default {
217
217
  // If the state of this component is controlled
218
218
  // by props instead of Vuex, we assume you wouldn't want
219
219
  // the mutation to have a dependency on Vuex either.
220
- // So in that case we use events to execute actions instead.
221
- // If an action list item is clicked, this
222
- // component emits that event, then we assume the parent
223
- // component will execute the action.
224
- this.$emit(action.action, {
225
- action,
220
+ // The parent component should handle the action based on the action property
221
+ // in the 'action-invoked' event payload.
222
+ this.$emit('action-invoked', {
223
+ action: action.action,
224
+ actionData: action,
226
225
  event,
227
226
  ...args,
228
- route: this.$route
227
+ route: this.$route
229
228
  });
230
229
  } else {
231
230
  // If the state of this component is controlled
@@ -30,7 +30,15 @@ const openChanged = (event: boolean) => {
30
30
  }
31
31
  };
32
32
 
33
- const emit = defineEmits<{(event: string, payload: any): void;(event: 'action-invoked'): void;}>();
33
+ export interface ActionMenuSelection {
34
+ action: string;
35
+ actionData: any;
36
+ event: MouseEvent;
37
+ route: ReturnType<typeof useRoute>;
38
+ [key: string]: any;
39
+ }
40
+
41
+ const emit = defineEmits<{(event: 'action-invoked', payload?: ActionMenuSelection): void;}>();
34
42
  const route = useRoute();
35
43
 
36
44
  const execute = (action: any, event: MouseEvent, args?: any) => {
@@ -38,7 +46,15 @@ const execute = (action: any, event: MouseEvent, args?: any) => {
38
46
  return;
39
47
  }
40
48
 
41
- emit('action-invoked');
49
+ const payload: ActionMenuSelection = {
50
+ action: action.action,
51
+ actionData: action,
52
+ event,
53
+ ...args,
54
+ route,
55
+ };
56
+
57
+ emit('action-invoked', payload);
42
58
 
43
59
  // this will come from extensions...
44
60
  if (action.invoke) {
@@ -56,24 +72,7 @@ const execute = (action: any, event: MouseEvent, args?: any) => {
56
72
  fn.apply(this, [opts, resources]);
57
73
  }
58
74
  }
59
- } else if (props.customActions) {
60
- // If the state of this component is controlled
61
- // by props instead of Vuex, we assume you wouldn't want
62
- // the mutation to have a dependency on Vuex either.
63
- // So in that case we use events to execute actions instead.
64
- // If an action list item is clicked, this
65
- // component emits that event, then we assume the parent
66
- // component will execute the action.
67
- emit(
68
- action.action,
69
- {
70
- action,
71
- event,
72
- ...args,
73
- route,
74
- }
75
- );
76
- } else {
75
+ } else if (!props.customActions) {
77
76
  // If the state of this component is controlled
78
77
  // by Vuex, mutate the store when an action is clicked.
79
78
  const opts = { alt: isAlternate(event) };
@@ -19,22 +19,30 @@ const i18n = useI18n(store);
19
19
  </script>
20
20
 
21
21
  <template>
22
- <div class="scaler">
22
+ <div
23
+ class="scaler"
24
+ data-testid="scaler"
25
+ >
23
26
  <button
24
27
  class="decrease"
25
28
  :aria-label="i18n.t('component.resource.detail.card.scaler.ariaLabel.decrease', {resourceName: props.ariaResourceName})"
26
29
  :disabled="!!props.min && (props.value <= props.min)"
30
+ data-testid="scaler-decrease"
27
31
  @click="() => emit('decrease', props.value - 1)"
28
32
  >
29
33
  <i class="icon icon-sm icon-minus" />
30
34
  </button>
31
- <div class="value">
35
+ <div
36
+ class="value"
37
+ data-testid="scaler-value"
38
+ >
32
39
  {{ props.value }}
33
40
  </div>
34
41
  <button
35
42
  class="increase"
36
43
  :aria-label="i18n.t('component.resource.detail.card.scaler.ariaLabel.increase', {resourceName: props.ariaResourceName})"
37
44
  :disabled="!!props.max && (props.value >= props.max)"
45
+ data-testid="scaler-increase"
38
46
  @click="() => emit('increase', props.value + 1)"
39
47
  >
40
48
  <i class="icon icon-sm icon-plus" />
@@ -83,7 +83,10 @@ const rows = computed(() => {
83
83
  </script>
84
84
 
85
85
  <template>
86
- <Card :title="title">
86
+ <Card
87
+ :title="title"
88
+ data-testid="resource-detail-status-card"
89
+ >
87
90
  <template
88
91
  v-if="props.showScaling"
89
92
  #heading-action
@@ -427,7 +427,7 @@ export default {
427
427
  },
428
428
 
429
429
  _applicableExtensionTableHooks() {
430
- if (this.$store.$plugin?.getUIConfig) {
430
+ if (this.$store.$extension?.getUIConfig) {
431
431
  const extensionTableHooks = getApplicableExtensionEnhancements(this, ExtensionPoint.TABLE, TableLocation.RESOURCE, this.$route);
432
432
 
433
433
  return extensionTableHooks;
@@ -15,6 +15,10 @@ export default {
15
15
  default: null,
16
16
  type: String
17
17
  },
18
+ labelIcon: {
19
+ type: String,
20
+ default: null
21
+ },
18
22
  name: {
19
23
  required: true,
20
24
  type: String
@@ -207,7 +207,7 @@ export default {
207
207
  return TabLocation.OTHER;
208
208
  }
209
209
  },
210
- hasIcon(tab) {
210
+ hasErrorIcon(tab) {
211
211
  return tab.displayAlertIcon || (tab.error && !tab.active);
212
212
  },
213
213
  hashChange() {
@@ -346,6 +346,10 @@ export default {
346
346
  @click.prevent="select(tab.name, $event)"
347
347
  @keyup.enter.space="select(tab.name, $event)"
348
348
  >
349
+ <i
350
+ v-if="tab.labelIcon"
351
+ :class="`tab-label-icon icon ${tab.labelIcon}`"
352
+ />
349
353
  <span>
350
354
  {{ tab.labelDisplay }}
351
355
  </span>
@@ -354,7 +358,7 @@ export default {
354
358
  class="tab-badge"
355
359
  >{{ tab.badge }}</span>
356
360
  <i
357
- v-if="hasIcon(tab)"
361
+ v-if="hasErrorIcon(tab)"
358
362
  v-clean-tooltip="t('validation.tab')"
359
363
  class="conditions-alert-icon icon-error"
360
364
  />
@@ -514,11 +518,15 @@ export default {
514
518
  }
515
519
 
516
520
  &.error {
517
- & A > i {
521
+ & A > .icon-error {
518
522
  color: var(--error);
519
523
  }
520
524
  }
521
525
 
526
+ .tab-label-icon {
527
+ margin-right: 8px;
528
+ }
529
+
522
530
  .tab-badge {
523
531
  margin-left: 5px;
524
532
  background-color: var(--link);
@@ -1,31 +1,39 @@
1
1
  import ProjectRow from '@shell/components/form/ResourceQuota/ProjectRow.vue';
2
- import { RANCHER_TYPES } from '@shell/components/form/ResourceQuota/shared';
2
+ import { RANCHER_TYPES, TYPES } from '@shell/components/form/ResourceQuota/shared';
3
3
  import { shallowMount } from '@vue/test-utils';
4
4
 
5
- const CONFIGMAP_STRING = RANCHER_TYPES[0].value;
5
+ const CONFIGMAP_STRING = TYPES.CONFIG_MAPS;
6
6
 
7
7
  describe('component: ProjectRow.vue', () => {
8
- const wrapper = shallowMount(ProjectRow,
9
- {
10
- props: {
11
- mode: 'edit',
12
- types: RANCHER_TYPES,
13
- type: CONFIGMAP_STRING,
14
- value: {
15
- spec: {
16
- namespaceDefaultResourceQuota: { limit: {} },
17
- resourceQuota: { limit: {} }
18
- }
8
+ const defaultMountOptions = {
9
+ props: {
10
+ mode: 'edit',
11
+ types: RANCHER_TYPES,
12
+ type: CONFIGMAP_STRING,
13
+ index: 0,
14
+ value: {
15
+ spec: {
16
+ namespaceDefaultResourceQuota: { limit: {} },
17
+ resourceQuota: { limit: {} }
19
18
  }
20
19
  }
21
- });
20
+ }
21
+ };
22
22
 
23
23
  it('should render the correct input fields and set the correct computed values, based on the provided data', () => {
24
+ const wrapper = shallowMount(
25
+ ProjectRow,
26
+ { ...defaultMountOptions }
27
+ );
28
+
24
29
  const typeInput = wrapper.find(`[data-testid="projectrow-type-input"]`);
30
+ const customTypeInput = wrapper.find(`[data-testid="projectrow-custom-type-input"]`);
25
31
  const projectQuotaInput = wrapper.find(`[data-testid="projectrow-project-quota-input"]`);
26
32
  const namespaceQuotaInput = wrapper.find(`[data-testid="projectrow-namespace-quota-input"]`);
27
33
 
28
34
  expect(typeInput.exists()).toBe(true);
35
+ expect(customTypeInput.exists()).toBe(true);
36
+ expect(customTypeInput.attributes().disabled).toBe('true');
29
37
  expect(projectQuotaInput.exists()).toBe(true);
30
38
  expect(namespaceQuotaInput.exists()).toBe(true);
31
39
  expect(wrapper.vm.resourceQuotaLimit).toStrictEqual({});
@@ -33,6 +41,11 @@ describe('component: ProjectRow.vue', () => {
33
41
  });
34
42
 
35
43
  it('triggering "updateQuotaLimit" should trigger Vue.set with the correct data', () => {
44
+ const wrapper = shallowMount(
45
+ ProjectRow,
46
+ { ...defaultMountOptions }
47
+ );
48
+
36
49
  wrapper.vm.updateQuotaLimit('resourceQuota', CONFIGMAP_STRING, 10);
37
50
 
38
51
  expect(wrapper.vm.value).toStrictEqual({
@@ -44,6 +57,11 @@ describe('component: ProjectRow.vue', () => {
44
57
  });
45
58
 
46
59
  it('triggering "updateType" with the same type that existed should clear limits and trigger emit', () => {
60
+ const wrapper = shallowMount(
61
+ ProjectRow,
62
+ { ...defaultMountOptions }
63
+ );
64
+
47
65
  wrapper.vm.updateType(CONFIGMAP_STRING);
48
66
 
49
67
  expect(wrapper.vm.value).toStrictEqual({
@@ -54,6 +72,75 @@ describe('component: ProjectRow.vue', () => {
54
72
  });
55
73
 
56
74
  expect(wrapper.emitted('type-change')).toBeTruthy();
57
- expect(wrapper.emitted('type-change')[0]).toStrictEqual([CONFIGMAP_STRING]);
75
+ expect(wrapper.emitted('type-change')[0]).toStrictEqual([{ index: 0, type: CONFIGMAP_STRING }]);
76
+ });
77
+
78
+ it('should update standard resource types', async() => {
79
+ const wrapper = shallowMount(
80
+ ProjectRow,
81
+ { ...defaultMountOptions }
82
+ );
83
+
84
+ expect(wrapper.vm.isCustom).toBe(false);
85
+
86
+ await wrapper.vm.updateQuotaLimit('resourceQuota', 'limitsCpu', '100m');
87
+ await wrapper.vm.updateQuotaLimit('namespaceDefaultResourceQuota', 'limitsCpu', '50m');
88
+
89
+ expect(wrapper.vm.value.spec.resourceQuota.limit.limitsCpu).toBe('100m');
90
+ expect(wrapper.vm.value.spec.namespaceDefaultResourceQuota.limit.limitsCpu).toBe('50m');
91
+ expect(wrapper.vm.value.spec.resourceQuota.limit.extended).toBeUndefined();
92
+ });
93
+
94
+ it('should switch to a custom resource type', async() => {
95
+ const wrapper = shallowMount(
96
+ ProjectRow,
97
+ { ...defaultMountOptions }
98
+ );
99
+
100
+ await wrapper.vm.updateType(TYPES.EXTENDED);
101
+
102
+ expect(wrapper.emitted('type-change')).toHaveLength(1);
103
+ expect(wrapper.emitted('type-change')[0][0]).toStrictEqual({ index: 0, type: TYPES.EXTENDED });
104
+ });
105
+
106
+ it('should update custom resource types', async() => {
107
+ const wrapper = shallowMount(
108
+ ProjectRow,
109
+ {
110
+ ...defaultMountOptions,
111
+ props: {
112
+ ...defaultMountOptions.props,
113
+ type: TYPES.EXTENDED
114
+ }
115
+ }
116
+ );
117
+
118
+ expect(wrapper.vm.isCustom).toBe(true);
119
+
120
+ const customTypeInput = wrapper.find(`[data-testid="projectrow-custom-type-input"]`);
121
+
122
+ expect(customTypeInput.attributes().disabled).toBe('false');
123
+ await wrapper.vm.updateCustomType('custom.resource/foo');
124
+
125
+ expect(wrapper.vm.customType).toBe('custom.resource/foo');
126
+
127
+ await wrapper.vm.updateQuotaLimit('resourceQuota', 'custom.resource/foo', 1);
128
+ await wrapper.vm.updateQuotaLimit('namespaceDefaultResourceQuota', 'custom.resource/foo', 2);
129
+
130
+ expect(wrapper.vm.value.spec.resourceQuota.limit.extended['custom.resource/foo']).toBe(1);
131
+ expect(wrapper.vm.value.spec.namespaceDefaultResourceQuota.limit.extended['custom.resource/foo']).toBe(2);
132
+ });
133
+
134
+ it('should handle custom resource types with periods', () => {
135
+ const wrapper = shallowMount(ProjectRow, {
136
+ ...defaultMountOptions,
137
+ props: {
138
+ ...defaultMountOptions.props,
139
+ type: 'extended.requests.nvidia.com/gpu'
140
+ }
141
+ });
142
+
143
+ expect(wrapper.vm.isCustom).toBe(true);
144
+ expect(wrapper.vm.customType).toBe('requests.nvidia.com/gpu');
58
145
  });
59
146
  });
@@ -1,12 +1,17 @@
1
1
  <script>
2
2
  import ArrayList from '@shell/components/form/ArrayList';
3
3
  import Row from './ProjectRow';
4
- import { QUOTA_COMPUTED } from './shared';
4
+ import { QUOTA_COMPUTED, TYPES } from './shared';
5
+ import Banner from '@components/Banner/Banner.vue';
5
6
 
6
7
  export default {
7
8
  emits: ['remove', 'input'],
8
9
 
9
- components: { ArrayList, Row },
10
+ components: {
11
+ ArrayList,
12
+ Row,
13
+ Banner,
14
+ },
10
15
 
11
16
  props: {
12
17
  mode: {
@@ -36,18 +41,35 @@ export default {
36
41
  this.value.spec['namespaceDefaultResourceQuota'] = this.value.spec.namespaceDefaultResourceQuota || { limit: {} };
37
42
  this.value.spec['resourceQuota'] = this.value.spec.resourceQuota || { limit: {} };
38
43
 
39
- this.typeValues = Object.keys(this.value.spec.resourceQuota.limit);
44
+ const limit = this.value.spec.resourceQuota.limit;
45
+ const extendedKeys = Object.keys(limit.extended || {});
46
+
47
+ this.typeValues = Object.keys(limit).flatMap((k) => {
48
+ if (k !== TYPES.EXTENDED) {
49
+ return k;
50
+ }
51
+
52
+ return extendedKeys.map((ek) => `extended.${ ek }`);
53
+ });
40
54
  },
41
55
 
42
56
  computed: { ...QUOTA_COMPUTED },
43
57
 
44
58
  methods: {
45
- updateType(i, type) {
46
- this.typeValues[i] = type;
59
+ updateType(event) {
60
+ const { index, type } = event;
61
+
62
+ this.typeValues[index] = type;
47
63
  },
48
64
  remainingTypes(currentType) {
49
65
  return this.mappedTypes
50
- .filter((mappedType) => !this.typeValues.includes(mappedType.value) || mappedType.value === currentType);
66
+ .filter((mappedType) => {
67
+ if (mappedType.value === TYPES.EXTENDED) {
68
+ return true;
69
+ }
70
+
71
+ return !this.typeValues.includes(mappedType.value) || mappedType.value === currentType;
72
+ });
51
73
  },
52
74
  emitRemove(data) {
53
75
  this.$emit('remove', data.row?.value);
@@ -57,9 +79,33 @@ export default {
57
79
  </script>
58
80
  <template>
59
81
  <div>
82
+ <Banner
83
+ color="info"
84
+ label-key="resourceQuota.banner"
85
+ class="mb-20"
86
+ />
60
87
  <div class="headers mb-10">
61
88
  <div class="mr-10">
62
- <label>{{ t('resourceQuota.headers.resourceType') }}</label>
89
+ <label>
90
+ {{ t('resourceQuota.headers.resourceType') }}
91
+ <span
92
+ class="required mr-5"
93
+ aria-hidden="true"
94
+ >*</span>
95
+ </label>
96
+ </div>
97
+ <div class="mr-20">
98
+ <label>
99
+ {{ t('resourceQuota.headers.resourceIdentifier') }}
100
+ <span
101
+ class="required mr-5"
102
+ aria-hidden="true"
103
+ >*</span>
104
+ <i
105
+ v-clean-tooltip="t('resourceQuota.resourceIdentifier.tooltip')"
106
+ class="icon icon-info"
107
+ />
108
+ </label>
63
109
  </div>
64
110
  <div class="mr-20">
65
111
  <label>{{ t('resourceQuota.headers.projectLimit') }}</label>
@@ -83,8 +129,9 @@ export default {
83
129
  :mode="mode"
84
130
  :types="remainingTypes(typeValues[props.i])"
85
131
  :type="typeValues[props.i]"
132
+ :index="props.i"
86
133
  @input="$emit('input', $event)"
87
- @type-change="updateType(props.i, $event)"
134
+ @type-change="updateType($event)"
88
135
  />
89
136
  </template>
90
137
  </ArrayList>
@@ -104,4 +151,8 @@ export default {
104
151
  width: 100%;
105
152
  }
106
153
  }
154
+
155
+ .required {
156
+ color: var(--error);
157
+ }
107
158
  </style>