@afeefa/vue-app 0.0.53 → 0.0.56

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. package/.afeefa/package/release/version.txt +1 -1
  2. package/package.json +3 -10
  3. package/src/api-resources/ApiActions.js +31 -0
  4. package/src/components/AAlert.vue +1 -1
  5. package/src/components/AAutocomplete.vue +1 -1
  6. package/src/components/ABadge.vue +1 -1
  7. package/src/components/ABreadcrumbs.vue +1 -1
  8. package/src/components/ACheckbox.vue +35 -0
  9. package/src/components/AContextMenu.vue +2 -2
  10. package/src/components/AContextMenuItem.vue +2 -2
  11. package/src/components/ADatePicker.vue +1 -1
  12. package/src/components/ADialog.vue +3 -1
  13. package/src/components/AGrid.vue +2 -1
  14. package/src/components/AIcon.vue +1 -1
  15. package/src/components/AIconButton.vue +1 -1
  16. package/src/components/ALoadingIndicator.vue +1 -1
  17. package/src/components/AModal.vue +1 -1
  18. package/src/components/APagination.vue +1 -1
  19. package/src/components/ARadioGroup.vue +1 -1
  20. package/src/components/ARichTextArea.vue +257 -0
  21. package/src/components/ARow.vue +1 -1
  22. package/src/components/ASaveIndicator.vue +1 -1
  23. package/src/components/ASearchSelect.vue +34 -18
  24. package/src/components/ASelect.vue +1 -1
  25. package/src/components/ATable.vue +1 -1
  26. package/src/components/ATableHeader.vue +1 -1
  27. package/src/components/ATableRow.vue +1 -1
  28. package/src/components/ATextArea.vue +1 -1
  29. package/src/components/ATextField.vue +1 -1
  30. package/src/components/form/EditForm.vue +2 -6
  31. package/src/components/form/EditModal.vue +23 -18
  32. package/src/components/form/FormFieldMixin.js +2 -3
  33. package/src/components/form/fields/FormFieldCheckbox.vue +18 -0
  34. package/src/components/form/fields/FormFieldDate.vue +1 -1
  35. package/src/components/form/fields/FormFieldRadioGroup.vue +1 -1
  36. package/src/components/form/fields/FormFieldRichTextArea.vue +14 -0
  37. package/src/components/form/fields/FormFieldSearchSelect.vue +1 -1
  38. package/src/components/form/fields/FormFieldSelect.vue +1 -1
  39. package/src/components/form/fields/FormFieldSelect2.vue +1 -1
  40. package/src/components/form/fields/FormFieldText.vue +1 -1
  41. package/src/components/form/fields/FormFieldTextArea.vue +1 -1
  42. package/src/components/index.js +4 -0
  43. package/src/components/list/ListFilterMixin.js +1 -1
  44. package/src/components/list/ListFilterRow.vue +1 -1
  45. package/src/components/list/ListViewMixin.js +7 -4
  46. package/src/components/list/filters/ListFilterPage.vue +1 -1
  47. package/src/components/list/filters/ListFilterSearch.vue +1 -1
  48. package/src/components/list/filters/ListFilterSelect.vue +1 -1
  49. package/src/components/mixins/ClickOutsideMixin.js +1 -1
  50. package/src/components/mixins/ComponentWidthMixin.js +1 -1
  51. package/src/components/search-select/SearchSelectFilters.vue +1 -1
  52. package/src/components/search-select/SearchSelectList.vue +13 -3
  53. package/src/components/vue/Component.js +46 -0
  54. package/src/index.js +4 -0
  55. package/src/plugins/api-resources/ApiResourcesPlugin.js +12 -0
  56. package/src/plugins/route-config/RouteConfigPlugin.js +25 -1
  57. package/src/services/escape/CancelOnEscMixin.js +1 -1
  58. package/src/services/position/UsesPositionServiceMixin.js +1 -1
  59. package/src/styles/forms.scss +8 -0
  60. package/src/styles/vue-app.scss +1 -0
  61. package/src-admin/bootstrap.js +2 -5
  62. package/src-admin/components/App.vue +1 -1
  63. package/src-admin/components/NotFound.vue +65 -0
  64. package/src-admin/components/SidebarMenu.vue +1 -1
  65. package/src-admin/components/Splash.vue +34 -34
  66. package/src-admin/components/Start.vue +2 -2
  67. package/src-admin/components/app/AppBarButton.vue +1 -1
  68. package/src-admin/components/app/AppBarButtons.vue +1 -1
  69. package/src-admin/components/app/AppBarTitle.vue +1 -1
  70. package/src-admin/components/app/AppBarTitleContainer.vue +1 -1
  71. package/src-admin/components/controls/SearchSelectFormField.vue +223 -0
  72. package/src-admin/components/detail/DetailProperty.vue +2 -4
  73. package/src-admin/components/list/ListColumnHeader.vue +5 -4
  74. package/src-admin/components/list/ListTitle.vue +1 -1
  75. package/src-admin/components/list/ListView.vue +12 -10
  76. package/src-admin/components/menu/SidebarMenuItem.vue +1 -1
  77. package/src-admin/components/model/ModelCount.vue +1 -1
  78. package/src-admin/components/model/ModelIcon.vue +1 -1
  79. package/src-admin/components/pages/CreatePage.vue +2 -3
  80. package/src-admin/components/pages/DetailPage.vue +4 -4
  81. package/src-admin/components/pages/EditPage.vue +5 -6
  82. package/src-admin/components/pages/EditPageMixin.js +1 -1
  83. package/src-admin/components/pages/ListPage.vue +3 -4
  84. package/src-admin/components/routes/CreateRoute.vue +1 -1
  85. package/src-admin/components/routes/DetailRoute.vue +4 -4
  86. package/src-admin/components/routes/EditRoute.vue +4 -4
  87. package/src-admin/components/routes/ListRoute.vue +4 -4
  88. package/src-admin/config/vuetify.js +3 -1
  89. package/src-admin/models/Model.js +13 -0
  90. package/src-admin/models/ModelAdminConfig.js +20 -0
  91. package/src-components/AMdiIcon.vue +18 -0
  92. package/src/utils/props-helper.js +0 -21
@@ -1 +1 @@
1
- 0.0.53
1
+ 0.0.56
package/package.json CHANGED
@@ -1,20 +1,13 @@
1
1
  {
2
2
  "name": "@afeefa/vue-app",
3
- "version": "0.0.53",
3
+ "version": "0.0.56",
4
4
  "description": "",
5
5
  "author": "Afeefa Kollektiv <kollektiv@afeefa.de>",
6
6
  "license": "MIT",
7
- "scripts": {
8
- "typings": "tsc"
9
- },
10
7
  "devDependencies": {
11
- "core-js": "^3.9.1",
12
- "typescript": "^4.2.3",
13
- "vue": "^2.6.12",
8
+ "core-js": "^3.21.1",
9
+ "vue": "^2.6.14",
14
10
  "vue-class-component": "^7.2.6",
15
11
  "vue-property-decorator": "^9.1.2"
16
- },
17
- "dependencies": {
18
- "moment": "^2.29.1"
19
12
  }
20
13
  }
@@ -228,14 +228,38 @@ export class RemoveAction {
228
228
  }
229
229
 
230
230
  export class ListAction {
231
+ action = null
231
232
  request = null
233
+ params = {}
234
+ fields = {}
235
+ filters = {}
232
236
  events = true
233
237
 
238
+ setAction (action) {
239
+ this.action = action
240
+ return this
241
+ }
242
+
234
243
  setRequest (request) {
235
244
  this.request = request
236
245
  return this
237
246
  }
238
247
 
248
+ setParams (params) {
249
+ this.params = params
250
+ return this
251
+ }
252
+
253
+ setFields (fields) {
254
+ this.fields = fields
255
+ return this
256
+ }
257
+
258
+ setFilters (filters) {
259
+ this.filters = filters
260
+ return this
261
+ }
262
+
239
263
  noEvents (noEvents) {
240
264
  this.events = noEvents === undefined ? false : !noEvents
241
265
  return this
@@ -246,6 +270,13 @@ export class ListAction {
246
270
  eventBus.dispatch(new LoadingEvent(LoadingEvent.START_LOADING))
247
271
  }
248
272
 
273
+ if (!this.request) {
274
+ this.request = this.action.createRequest()
275
+ .params(this.params)
276
+ .fields(this.fields)
277
+ .filters(this.filters)
278
+ }
279
+
249
280
  const result = await this.request.send()
250
281
 
251
282
  if (result.error) {
@@ -35,7 +35,7 @@
35
35
  </template>
36
36
 
37
37
  <script>
38
- import { Component, Vue } from 'vue-property-decorator'
38
+ import { Component, Vue } from '@a-vue'
39
39
  import { AlertEvent } from './alert/AlertEvent'
40
40
 
41
41
  @Component
@@ -18,7 +18,7 @@
18
18
 
19
19
 
20
20
  <script>
21
- import { Component, Vue, Watch } from 'vue-property-decorator'
21
+ import { Component, Vue, Watch } from '@a-vue'
22
22
  import { Model } from '@afeefa/api-resources-client'
23
23
  import { debounce } from '@a-vue/utils/debounce'
24
24
 
@@ -7,7 +7,7 @@
7
7
 
8
8
 
9
9
  <script>
10
- import { Component, Vue, Watch } from 'vue-property-decorator'
10
+ import { Component, Vue, Watch } from '@a-vue'
11
11
 
12
12
  @Component({
13
13
  props: ['content']
@@ -19,7 +19,7 @@
19
19
 
20
20
 
21
21
  <script>
22
- import { Component, Vue, Watch } from 'vue-property-decorator'
22
+ import { Component, Vue, Watch } from '@a-vue'
23
23
  import { SaveEvent } from './save-indicator/SaveEvent'
24
24
  import { routeConfigPlugin } from '@a-vue/plugins/route-config/RouteConfigPlugin'
25
25
 
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <v-checkbox
3
+ ref="checkbox"
4
+ v-bind="$attrs"
5
+ :inputValue="$attrs.value"
6
+ @change="$emit('input', $event || false)"
7
+ />
8
+ </template>
9
+
10
+
11
+ <script>
12
+ import { Component, Vue } from '@a-vue'
13
+
14
+ @Component({
15
+ props: ['validator']
16
+ })
17
+ export default class ACheckbox extends Vue {
18
+ created () {
19
+ this.init()
20
+ }
21
+
22
+ async init () {
23
+ if (this.validator) {
24
+ this.$nextTick(() => {
25
+ this.$refs.checkbox.validate(true)
26
+ })
27
+ }
28
+ }
29
+
30
+ get validationRules () {
31
+ const label = this.$attrs.label
32
+ return (this.validator && this.validator.getRules(label)) || []
33
+ }
34
+ }
35
+ </script>
@@ -25,7 +25,7 @@
25
25
  </template>
26
26
 
27
27
  <script>
28
- import { Component, Mixins, Watch } from 'vue-property-decorator'
28
+ import { Component, Mixins, Watch } from '@a-vue'
29
29
  import { UsesPositionServiceMixin } from '../services/position/UsesPositionServiceMixin'
30
30
  import { Positions, PositionConfig } from '../services/PositionService'
31
31
  import { randomCssClass } from '../utils/random'
@@ -140,7 +140,7 @@ export default class AContextMenu extends Mixins(UsesPositionServiceMixin) {
140
140
  .popUpContent {
141
141
  min-height: 2.2rem;
142
142
  position: absolute;
143
- z-index: 200;
143
+ z-index: 400;
144
144
  display: block;
145
145
  background-color: white;
146
146
  padding: 0.5rem;
@@ -8,7 +8,7 @@
8
8
  </template>
9
9
 
10
10
  <script>
11
- import { Component, Vue } from 'vue-property-decorator'
11
+ import { Component, Vue } from '@a-vue'
12
12
 
13
13
  @Component({
14
14
  props: ['to']
@@ -29,7 +29,7 @@ export default class AContextMenuItem extends Vue {
29
29
  this.contextMenu.close()
30
30
  if (this.to) {
31
31
  this.$router.push(this.to)
32
- .catch(() => null) // prevent duplicated navigation
32
+ .catch(() => null) // prevent duplicated navigation warning
33
33
  } else {
34
34
  this.$emit('click')
35
35
  }
@@ -29,7 +29,7 @@
29
29
 
30
30
 
31
31
  <script>
32
- import { Component, Mixins, Watch } from 'vue-property-decorator'
32
+ import { Component, Mixins, Watch } from '@a-vue'
33
33
  import { formatDate } from '@a-vue/utils/format-date'
34
34
  import { ComponentWidthMixin } from './mixins/ComponentWidthMixin'
35
35
 
@@ -54,7 +54,7 @@
54
54
  </template>
55
55
 
56
56
  <script>
57
- import { Component, Mixins, Watch } from 'vue-property-decorator'
57
+ import { Component, Mixins, Watch } from '@a-vue'
58
58
  import { DialogEvent } from './dialog/DialogEvent'
59
59
  import { PositionConfig } from '../services/PositionService'
60
60
  import { UsesPositionServiceMixin } from '../services/position/UsesPositionServiceMixin'
@@ -132,6 +132,8 @@ export default class ADialog extends Mixins(UsesPositionServiceMixin) {
132
132
  if (!Array.isArray(anchor)) {
133
133
  if (typeof anchor === 'string') {
134
134
  anchor = [document.documentElement, anchor]
135
+ } else if (typeof anchor === 'object') { // dom element or vue ref
136
+ anchor = [anchor]
135
137
  } else {
136
138
  anchor = [document.documentElement]
137
139
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
 
8
8
  <script>
9
- import { Component, Vue } from 'vue-property-decorator'
9
+ import { Component, Vue } from '@a-vue'
10
10
 
11
11
  @Component({
12
12
  props: ['fullWidth', 'gap', 'hGap', 'vGap', 'cols', 'even', 'breakMobile']
@@ -91,6 +91,7 @@ export default class AGrid extends Vue {
91
91
  grid-template-columns: repeat(5, 1fr);
92
92
  }
93
93
  }
94
+
94
95
  &.breakMobile {
95
96
  @media (max-width: 900px), (orientation : portrait) {
96
97
  grid-template-columns: 1fr;
@@ -10,7 +10,7 @@
10
10
 
11
11
 
12
12
  <script>
13
- import { Component, Vue } from 'vue-property-decorator'
13
+ import { Component, Vue } from '@a-vue'
14
14
 
15
15
  @Component({
16
16
  props: ['button']
@@ -16,7 +16,7 @@
16
16
 
17
17
 
18
18
  <script>
19
- import { Component, Vue } from 'vue-property-decorator'
19
+ import { Component, Vue } from '@a-vue'
20
20
 
21
21
  @Component({
22
22
  props: ['icon', 'text']
@@ -9,7 +9,7 @@
9
9
  </template>
10
10
 
11
11
  <script>
12
- import { Component, Vue, Watch } from 'vue-property-decorator'
12
+ import { Component, Vue, Watch } from '@a-vue'
13
13
 
14
14
  @Component({
15
15
  props: ['isLoading']
@@ -33,7 +33,7 @@
33
33
  </template>
34
34
 
35
35
  <script>
36
- import { Component, Mixins, Watch } from 'vue-property-decorator'
36
+ import { Component, Mixins, Watch } from '@a-vue'
37
37
  import { PositionConfig } from '../services/PositionService'
38
38
  import { UsesPositionServiceMixin } from '@a-vue/services/position/UsesPositionServiceMixin'
39
39
  import { randomCssClass } from '../utils/random'
@@ -13,7 +13,7 @@
13
13
 
14
14
 
15
15
  <script>
16
- import { Component, Vue } from 'vue-property-decorator'
16
+ import { Component, Vue } from '@a-vue'
17
17
 
18
18
  @Component({
19
19
  props: ['content']
@@ -18,7 +18,7 @@
18
18
 
19
19
 
20
20
  <script>
21
- import { Component, Vue, Watch } from 'vue-property-decorator'
21
+ import { Component, Vue, Watch } from '@a-vue'
22
22
 
23
23
  @Component({
24
24
  props: ['options', 'validator']
@@ -0,0 +1,257 @@
1
+ <template>
2
+ <div :class="['a-rich-text-editor a-text-input', {'a-text-input-focused': focus}]">
3
+ <div
4
+ v-if="editor"
5
+ class="menu-bar"
6
+ >
7
+ <v-btn
8
+ small
9
+ :class="['menu-button', {'is-active': focus && editor.isActive('bold')}]"
10
+ @click="editor.chain().focus().toggleBold().run()"
11
+ >
12
+ <v-icon>{{ boldIcon }}</v-icon>
13
+ </v-btn>
14
+
15
+ <v-btn
16
+ small
17
+ :class="['menu-button', {'is-active': focus && editor.isActive('italic')}]"
18
+ @click="editor.chain().focus().toggleItalic().run()"
19
+ >
20
+ <v-icon>{{ italicIcon }}</v-icon>
21
+ </v-btn>
22
+
23
+ <v-btn
24
+ small
25
+ :class="['menu-button', 'strike', {'is-active': focus && editor.isActive('strike')}]"
26
+ @click="editor.chain().focus().toggleStrike().run()"
27
+ >
28
+ <v-icon>{{ strikeIcon }}</v-icon>
29
+ </v-btn>
30
+
31
+ <v-btn
32
+ small
33
+ :class="['menu-button', {'is-active': focus && editor.isActive('heading', {level: 1})}]"
34
+ @click="editor.chain().focus().toggleHeading({level: 1}).run()"
35
+ >
36
+ <v-icon>{{ h1Icon }}</v-icon>
37
+ </v-btn>
38
+
39
+ <v-btn
40
+ small
41
+ :class="['menu-button', {'is-active': focus && editor.isActive('heading', {level: 2})}]"
42
+ @click="editor.chain().focus().toggleHeading({level: 2}).run()"
43
+ >
44
+ <v-icon>{{ h2Icon }}</v-icon>
45
+ </v-btn>
46
+
47
+ <v-btn
48
+ small
49
+ :class="['menu-button', {'is-active': focus && editor.isActive('bulletList')}]"
50
+ @click="editor.chain().focus().toggleBulletList().run()"
51
+ >
52
+ <v-icon>{{ ulIcon }}</v-icon>
53
+ </v-btn>
54
+
55
+ <v-btn
56
+ small
57
+ :class="['menu-button', {'is-active': focus && editor.isActive('orderedList')}]"
58
+ @click="editor.chain().focus().toggleOrderedList().run()"
59
+ >
60
+ <v-icon>{{ olIcon }}</v-icon>
61
+ </v-btn>
62
+
63
+ <v-btn
64
+ small
65
+ :class="['menu-button', {'is-active': focus && editor.isActive('blockquote')}]"
66
+ @click="editor.chain().focus().toggleBlockquote().run()"
67
+ >
68
+ <v-icon>{{ commentIcon }}</v-icon>
69
+ </v-btn>
70
+
71
+ <v-btn
72
+ small
73
+ class="menu-button"
74
+ @click="editor.chain().focus().undo().run()"
75
+ >
76
+ <v-icon>{{ undoIcon }}</v-icon>
77
+ </v-btn>
78
+
79
+ <v-btn
80
+ small
81
+ class="menu-button"
82
+ @click="editor.chain().focus().redo().run()"
83
+ >
84
+ <v-icon>{{ redoIcon }}</v-icon>
85
+ </v-btn>
86
+ </div>
87
+
88
+ <editor-content
89
+ :editor="editor"
90
+ :class="['a-rich-text-editor', {focus}]"
91
+ />
92
+ </div>
93
+ </template>
94
+
95
+
96
+ <script>
97
+ import { Component, Vue, Watch } from '@a-vue'
98
+ import { Editor, EditorContent } from '@tiptap/vue-2'
99
+ import StarterKit from '@tiptap/starter-kit'
100
+ import {
101
+ mdiFormatBold,
102
+ mdiFormatItalic,
103
+ mdiFormatStrikethroughVariant,
104
+ mdiFormatHeader1,
105
+ mdiFormatHeader2,
106
+ mdiFormatListBulleted,
107
+ mdiFormatListNumbered,
108
+ mdiFormatQuoteClose,
109
+ mdiRotateLeft,
110
+ mdiRotateRight
111
+ } from '@mdi/js'
112
+
113
+ @Component({
114
+ props: ['value', 'validator'],
115
+ components: {
116
+ EditorContent
117
+ }
118
+ })
119
+ export default class ARichTextArea extends Vue {
120
+ editor = null
121
+ internalValue = null
122
+ focus = false
123
+
124
+ boldIcon = mdiFormatBold
125
+ italicIcon = mdiFormatItalic
126
+ strikeIcon = mdiFormatStrikethroughVariant
127
+ h1Icon = mdiFormatHeader1
128
+ h2Icon = mdiFormatHeader2
129
+ ulIcon = mdiFormatListBulleted
130
+ olIcon = mdiFormatListNumbered
131
+ commentIcon = mdiFormatQuoteClose
132
+ undoIcon = mdiRotateLeft
133
+ redoIcon = mdiRotateRight
134
+
135
+ created () {
136
+ this.internalValue = this.value
137
+ }
138
+
139
+ mounted () {
140
+ if (this.validator) {
141
+ this.$refs.input.validate(true)
142
+ }
143
+
144
+ this.editor = new Editor({
145
+ content: this.internalValue,
146
+ extensions: [
147
+ StarterKit
148
+ ],
149
+ onUpdate: () => {
150
+ this.$emit('input', this.editor.getHTML())
151
+ },
152
+ onFocus: ({ editor, event }) => {
153
+ this.focus = true
154
+ },
155
+ onBlur: ({ editor, event }) => {
156
+ this.focus = false
157
+ }
158
+ })
159
+
160
+ this.editor.commands.setContent(this.internalValue, false)
161
+ }
162
+
163
+ beforeDestroy () {
164
+ this.editor.destroy()
165
+ }
166
+
167
+ @Watch('value')
168
+ valueChanged () {
169
+ this.internalValue = this.value
170
+
171
+ const isSame = this.editor.getHTML() === this.internalValue
172
+ if (!isSame) {
173
+ this.editor.commands.setContent(this.internalValue, false)
174
+ }
175
+ }
176
+
177
+ get validationRules () {
178
+ const label = this.$attrs.label
179
+ return (this.validator && this.validator.getRules(label)) || []
180
+ }
181
+
182
+ get counter () {
183
+ if (!this.validator) {
184
+ return false
185
+ }
186
+ return this.validator.getParams().max || false
187
+ }
188
+ }
189
+ </script>
190
+
191
+
192
+ <style lang="scss" scoped>
193
+ .v-input:not(.v-input--is-focused) ::v-deep .v-counter {
194
+ display: none;
195
+ }
196
+
197
+ .a-rich-text-editor {
198
+ ::v-deep .ProseMirror {
199
+ &-focused {
200
+ outline: none;
201
+ }
202
+ }
203
+ }
204
+
205
+ .menu-bar {
206
+ margin: -.2rem 0 .5rem -.2rem;
207
+ }
208
+
209
+ .menu-button {
210
+ padding: 0 !important;
211
+ width: 30px !important;
212
+ height: 32px !important;
213
+ min-width: unset !important;
214
+ text-align: center;
215
+ font-size: 1rem;
216
+ background: white !important;
217
+ border: none;
218
+ box-shadow: none;
219
+ border-radius: 0;
220
+
221
+ ::v-deep .v-icon {
222
+ font-size: 20px;
223
+ width: 20px;
224
+ height: 20px;
225
+ }
226
+
227
+ &.strike {
228
+ ::v-deep .v-icon {
229
+ width: 15px;
230
+ }
231
+ }
232
+
233
+ svg {
234
+ width: unset;
235
+ }
236
+
237
+ &.is-active {
238
+ background: #ECECEC !important;
239
+ }
240
+ }
241
+
242
+ ::v-deep .ProseMirror {
243
+ min-height: 200px;
244
+
245
+ > :last-child {
246
+ margin: 0;
247
+ }
248
+
249
+ li p {
250
+ margin: 0;
251
+ }
252
+
253
+ ul {
254
+ margin: 16px 0;
255
+ }
256
+ }
257
+ </style>
@@ -6,7 +6,7 @@
6
6
 
7
7
 
8
8
  <script>
9
- import { Component, Vue } from 'vue-property-decorator'
9
+ import { Component, Vue } from '@a-vue'
10
10
 
11
11
  @Component({
12
12
  props: ['fullWidth', 'gap', 'start', 'stretch', 'center', 'vertical', 'right']
@@ -14,7 +14,7 @@
14
14
 
15
15
 
16
16
  <script>
17
- import { Component, Vue } from 'vue-property-decorator'
17
+ import { Component, Vue } from '@a-vue'
18
18
  import { SaveEvent } from './save-indicator/SaveEvent'
19
19
 
20
20
  @Component