@bildvitta/quasar-ui-asteroid 3.14.0-beta.2 → 3.14.0-beta.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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-bar/QasAppBar.vue +6 -1
  3. package/src/components/app-bar/QasAppBar.yml +3 -0
  4. package/src/components/app-menu/QasAppMenu.vue +3 -2
  5. package/src/components/app-menu/QasAppMenu.yml +3 -0
  6. package/src/components/app-menu/composables/use-app-user.js +3 -0
  7. package/src/components/app-user/QasAppUser.vue +68 -13
  8. package/src/components/app-user/QasAppUser.yml +3 -0
  9. package/src/components/avatar/QasAvatar.vue +9 -3
  10. package/src/components/avatar/QasAvatar.yml +1 -1
  11. package/src/components/avatar/enums/AvatarColors.js +2 -1
  12. package/src/components/card/QasCard.vue +3 -3
  13. package/src/components/dialog/QasDialog.vue +29 -1
  14. package/src/components/drawer/QasDrawer.vue +117 -0
  15. package/src/components/drawer/QasDrawer.yml +57 -0
  16. package/src/components/infinite-scroll/QasInfiniteScroll.vue +6 -2
  17. package/src/components/infinite-scroll/QasInfiniteScroll.yml +22 -0
  18. package/src/components/layout/QasLayout.vue +83 -52
  19. package/src/components/layout/QasLayout.yml +5 -0
  20. package/src/components/layout/private/PvLayoutNotificationCard.vue +86 -0
  21. package/src/components/layout/private/PvLayoutNotificationsDrawer.vue +141 -0
  22. package/src/components/list-items/QasListItems.vue +33 -5
  23. package/src/components/list-items/QasListItems.yml +5 -0
  24. package/src/composables/index.js +3 -0
  25. package/src/composables/use-notifications.js +114 -0
  26. package/src/css/plugins/notify.scss +40 -2
  27. package/src/helpers/private/has-parent-by-class-name.js +15 -0
  28. package/src/vue-plugin.js +3 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bildvitta/quasar-ui-asteroid",
3
3
  "description": "Asteroid",
4
- "version": "3.14.0-beta.2",
4
+ "version": "3.14.0-beta.3",
5
5
  "author": "Bild & Vitta <systemteam@bild.com.br>",
6
6
  "license": "MIT",
7
7
  "main": "dist/asteroid.cjs.min.js",
@@ -54,7 +54,7 @@ const props = defineProps({
54
54
  }
55
55
  })
56
56
 
57
- const emit = defineEmits(['sign-out', 'toggle-menu'])
57
+ const emit = defineEmits(['sign-out', 'toggle-menu', 'toggle-notifications'])
58
58
 
59
59
  const router = useRouter()
60
60
  const screen = useScreen()
@@ -68,6 +68,7 @@ const defaultAppUserProps = computed(() => {
68
68
  },
69
69
 
70
70
  onSignOut: signOut,
71
+ onToggleNotifications: toggleNotifications,
71
72
  ...props.appUserProps
72
73
  }
73
74
  })
@@ -96,6 +97,10 @@ function signOut () {
96
97
  emit('sign-out')
97
98
  }
98
99
 
100
+ function toggleNotifications () {
101
+ emit('toggle-notifications')
102
+ }
103
+
99
104
  function toggleMenuDrawer () {
100
105
  emit('toggle-menu')
101
106
  }
@@ -39,3 +39,6 @@ events:
39
39
 
40
40
  '@toggle-menu -> function()':
41
41
  desc: Dispara quando o botão de "hambúrguer" é clicado para abrir o menu lateral.
42
+
43
+ '@toggle-notifications -> function()':
44
+ desc: Dispara quando o botão de "Notificações" é clicado.
@@ -146,7 +146,7 @@ const props = defineProps({
146
146
  }
147
147
  })
148
148
 
149
- const emit = defineEmits(['sign-out', 'update:modelValue'])
149
+ const emit = defineEmits(['sign-out', 'update:modelValue', 'toggle-notifications'])
150
150
 
151
151
  const screen = useScreen()
152
152
  const router = useRouter()
@@ -159,7 +159,8 @@ const isMini = ref(screen.isLarge)
159
159
  const composableParams = {
160
160
  props,
161
161
  onMenuUpdate: setHasOpenedMenu,
162
- onSignOut: () => emit('sign-out')
162
+ onSignOut: () => emit('sign-out'),
163
+ onToggleNotifications: () => emit('toggle-notifications')
163
164
  }
164
165
 
165
166
  const { defaultAppUserProps, showAppUser } = useAppUser(composableParams)
@@ -57,6 +57,9 @@ events:
57
57
  '@sign-out -> function()':
58
58
  desc: Dispara quando o botão de "sair" é clicado.
59
59
 
60
+ '@toggle-notifications -> function()':
61
+ desc: Dispara quando o botão de "Notificações" é clicado.
62
+
60
63
  '@update:model-value -> function(value)':
61
64
  desc: Dispara quando o model-value altera, também usado para v-model.
62
65
  params:
@@ -6,6 +6,7 @@ import { computed } from 'vue'
6
6
  * @param {{
7
7
  * props: { appUserProps: {} }
8
8
  * onSignOut: () => void
9
+ * onToggleNotifications: () => void
9
10
  * onMenuUpdate: () => void
10
11
  * }} config
11
12
  */
@@ -14,6 +15,7 @@ export default function useAppUser (config = {}) {
14
15
  props,
15
16
 
16
17
  onSignOut,
18
+ onToggleNotifications,
17
19
  onMenuUpdate
18
20
  } = config
19
21
 
@@ -29,6 +31,7 @@ export default function useAppUser (config = {}) {
29
31
 
30
32
  // eventos
31
33
  onSignOut,
34
+ onToggleNotifications,
32
35
  ...props.appUserProps
33
36
  }
34
37
  })
@@ -3,9 +3,7 @@
3
3
  <div class="relative-position">
4
4
  <qas-avatar :image="props.user.photo" :size="props.avatarSize" :title="userName" />
5
5
 
6
- <q-badge v-if="hasNotifications" color="red" floating>
7
- {{ props.notifications.count }}
8
- </q-badge>
6
+ <qas-avatar v-if="hasNotificationInUserAvatar" v-bind="avatarNotificationCountProps" />
9
7
  </div>
10
8
 
11
9
  <div class="ellipsis qas-app-user__data">
@@ -43,20 +41,16 @@
43
41
  </q-item-section>
44
42
  </q-item>
45
43
 
46
- <q-item v-if="hasNotifications" v-close-popup class="qas-app-user__menu-item" clickable>
47
- <q-item-section avatar>
44
+ <q-item v-if="isNotificationsEnabled" v-close-popup class="qas-app-user__menu-item" clickable @click="toggleNotificationsDrawer">
45
+ <q-item-section avatar class="relative-position">
48
46
  <q-icon name="sym_r_notifications" />
47
+
48
+ <qas-avatar v-if="hasUnreadNotifications" class="qas-app-user__notification-avatar--icon" v-bind="avatarNotificationCountProps" />
49
49
  </q-item-section>
50
50
 
51
51
  <q-item-section>
52
52
  Notificações
53
53
  </q-item-section>
54
-
55
- <q-item-section side>
56
- <q-badge color="red">
57
- {{ props.notifications.count }}
58
- </q-badge>
59
- </q-item-section>
60
54
  </q-item>
61
55
 
62
56
  <q-item v-close-popup class="qas-app-user__menu-item" clickable @click="signOut">
@@ -77,6 +71,7 @@
77
71
  <script setup>
78
72
  import QasAvatar from '../avatar/QasAvatar.vue'
79
73
 
74
+ import useNotifications from '../../composables/use-notifications'
80
75
  import { NotifySuccess, NotifyError } from '../../plugins'
81
76
 
82
77
  import { ref, computed, watch, inject } from 'vue'
@@ -111,14 +106,19 @@ const props = defineProps({
111
106
  }
112
107
  })
113
108
 
114
- const emit = defineEmits(['sign-out'])
109
+ const emit = defineEmits(['sign-out', 'toggle-notifications'])
115
110
 
116
111
  // vindo direto do boot api.js
117
112
  const axios = inject('axios')
118
113
 
114
+ const { isNotificationsEnabled, unreadNotificationsCount } = useNotifications()
115
+
119
116
  const companiesModel = ref('')
120
117
  const loading = ref(false)
121
118
 
119
+ const { avatarNotificationCountProps } = useAvatarNotifications()
120
+
121
+ // computed
122
122
  const defaultCompanyProps = computed(() => {
123
123
  return {
124
124
  loading: loading.value,
@@ -131,8 +131,10 @@ const defaultCompanyProps = computed(() => {
131
131
  })
132
132
 
133
133
  const hasCompaniesSelect = computed(() => !!props.companyProps.options?.length)
134
- const hasNotifications = computed(() => !!Object.keys(props.notifications).length)
134
+ const hasUnreadNotifications = computed(() => unreadNotificationsCount.value > 0)
135
+ const hasNotificationInUserAvatar = computed(() => isNotificationsEnabled && hasUnreadNotifications.value)
135
136
 
137
+ const unreadNotificationsToString = computed(() => String(unreadNotificationsCount.value))
136
138
  const userName = computed(() => props.user.name || props.user.givenName)
137
139
 
138
140
  // watch
@@ -140,6 +142,41 @@ watch(() => props.companyProps.modelValue, value => {
140
142
  companiesModel.value = value
141
143
  }, { immediate: true })
142
144
 
145
+ // composable
146
+ function useAvatarNotifications () {
147
+ const hasAnimated = ref(false)
148
+
149
+ watch(() => unreadNotificationsCount.value, () => {
150
+ hasAnimated.value = true
151
+
152
+ setTimeout(() => {
153
+ hasAnimated.value = false
154
+ }, 1000)
155
+ })
156
+
157
+ const avatarNotificationCountProps = computed(() => {
158
+ const classes = [
159
+ 'qas-app-user__notification-avatar',
160
+ 'animated',
161
+ {
162
+ rubberBand: hasAnimated.value
163
+ }
164
+ ]
165
+
166
+ return {
167
+ class: classes,
168
+ color: 'red-14',
169
+ size: 'xs',
170
+ title: unreadNotificationsToString.value,
171
+ useCropTitle: false
172
+ }
173
+ })
174
+
175
+ return {
176
+ avatarNotificationCountProps
177
+ }
178
+ }
179
+
143
180
  // métodos
144
181
  function signOut () {
145
182
  emit('sign-out')
@@ -169,10 +206,28 @@ function onMenuHide () {
169
206
  companiesModel.value = props.companyProps.modelValue
170
207
  }
171
208
  }
209
+
210
+ function toggleNotificationsDrawer () {
211
+ emit('toggle-notifications')
212
+ }
172
213
  </script>
173
214
 
174
215
  <style lang="scss">
175
216
  .qas-app-user {
217
+ &__notification-avatar {
218
+ animation-duration: 1s;
219
+ position: absolute;
220
+ top: 0;
221
+
222
+ &:not(&--icon) {
223
+ right: -4px;
224
+ }
225
+
226
+ &--icon {
227
+ right: 4px;
228
+ }
229
+ }
230
+
176
231
  &__data {
177
232
  line-height: 1.1;
178
233
  }
@@ -43,6 +43,9 @@ events:
43
43
  '@sign-out -> function()':
44
44
  desc: Dispara quando o botão de "sair" é clicado.
45
45
 
46
+ '@toggle-notifications -> function()':
47
+ desc: Dispara quando o botão de "Notificações" é clicado.
48
+
46
49
  selectors:
47
50
  app-user:
48
51
  desc: Seletor do componente.
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <q-avatar class="text-bold" v-bind="attributes">
3
3
  <q-img v-if="hasImage" :alt="props.title" :ratio="1" spinner-color="primary" spinner-size="16px" :src="props.image" @error="onImageLoadedError" />
4
- <template v-else-if="hasTitle">{{ firstLetter }}</template>
4
+ <template v-else-if="hasTitle">{{ label }}</template>
5
5
  <q-icon v-else :name="props.icon" />
6
6
  </q-avatar>
7
7
  </template>
@@ -45,6 +45,11 @@ const props = defineProps({
45
45
  title: {
46
46
  default: '',
47
47
  type: String
48
+ },
49
+
50
+ useCropTitle: {
51
+ type: Boolean,
52
+ default: true
48
53
  }
49
54
  })
50
55
 
@@ -64,7 +69,8 @@ const attributes = computed(() => {
64
69
  const colors = {
65
70
  [AvatarColors.Primary]: 'white',
66
71
  [AvatarColors.SecondaryContrast]: 'primary',
67
- [AvatarColors.Grey4]: 'grey-8'
72
+ [AvatarColors.Grey4]: 'grey-8',
73
+ [AvatarColors.Red14]: 'white'
68
74
  }
69
75
 
70
76
  return {
@@ -75,7 +81,7 @@ const attributes = computed(() => {
75
81
  }
76
82
  })
77
83
 
78
- const firstLetter = computed(() => props.title[0].toUpperCase())
84
+ const label = computed(() => props.useCropTitle ? props.title[0]?.toUpperCase?.() : props.title)
79
85
 
80
86
  const hasImage = computed(() => !hasImageError.value && !!props.image)
81
87
  const hasTitle = computed(() => !!props.title)
@@ -10,7 +10,7 @@ props:
10
10
  color:
11
11
  desc: Cor de fundo (background-color).
12
12
  type: String
13
- examples: ['primary', 'secondary-contrast', 'grey-4']
13
+ examples: ['primary', 'secondary-contrast', 'grey-4', 'red-14']
14
14
 
15
15
  size:
16
16
  desc: Tamanho do avatar.
@@ -5,5 +5,6 @@
5
5
  export const AvatarColors = {
6
6
  Primary: 'primary',
7
7
  SecondaryContrast: 'secondary-contrast',
8
- Grey4: 'grey-4'
8
+ Grey4: 'grey-4',
9
+ Red14: 'red-14'
9
10
  }
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="col-12 col-lg-3 col-md-4 col-sm-6">
3
3
  <q-card class="border-radius-lg column full-height overflow-hidden" :class="cardClasses">
4
- <header v-if="props.useHeader" class="overflow-hidden relative-position w-full">
4
+ <header v-if="props.useHeader" class="full-width overflow-hidden relative-position">
5
5
  <slot name="header">
6
6
  <q-carousel v-model="slideImage" animated class="cursor-pointer" height="205px" infinite :navigation="hasImages" navigation-icon="sym_r_fiber_manual_record" swipeable>
7
7
  <template #navigation-icon="{ active, btnProps, onClick }">
@@ -17,8 +17,8 @@
17
17
  </slot>
18
18
  </header>
19
19
 
20
- <q-card-section class="col-grow column justify-between w-full">
21
- <div class="w-full" :class="gutterClass">
20
+ <q-card-section class="col-grow column full-width justify-between">
21
+ <div class="full-width" :class="gutterClass">
22
22
  <slot />
23
23
  </div>
24
24
  </q-card-section>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <q-dialog ref="dialogRef" class="qas-dialog" data-cy="dialog" :persistent="props.persistent" v-bind="dialogProps" @update:model-value="updateModelValue">
2
+ <q-dialog ref="dialogRef" class="qas-dialog" :class="classes" data-cy="dialog" v-bind="dialogProps" :persistent="props.persistent" @update:model-value="updateModelValue">
3
3
  <div class="bg-white q-pa-lg" :style="style">
4
4
  <header v-if="hasHeader" class="q-mb-lg">
5
5
  <slot name="header">
@@ -136,6 +136,21 @@ const { defaultCancel, hasCancel } = useCancel(composablesParams)
136
136
  const { defaultOk, hasOk, onOk } = useOk(composablesParams)
137
137
  const { descriptionComponent, mainComponent } = useDynamicComponents({ ...composablesParams, onOk, hasOk })
138
138
 
139
+ /**
140
+ * Classes criadas para serem utilizadas quando usado com a prop "position", pois
141
+ * o comportamento do dialog muda, e não é possível usar em conjunto com a prop
142
+ * "useFullMaxWidth", então foi necessário uma trativa.
143
+ */
144
+ const classes = computed(() => {
145
+ const isRightPosition = attrs.position === 'right'
146
+ const isLeftPosition = attrs.position === 'left'
147
+
148
+ return {
149
+ 'qas-dialog--right': isRightPosition,
150
+ 'qas-dialog--left': isLeftPosition
151
+ }
152
+ })
153
+
139
154
  const dialogProps = computed(() => {
140
155
  return {
141
156
  ...(!props.usePlugin && { modelValue: props.modelValue }),
@@ -183,5 +198,18 @@ function updateModelValue (value) {
183
198
  .q-dialog__inner > div {
184
199
  box-shadow: $shadow-2;
185
200
  }
201
+
202
+ &--right {
203
+ .q-dialog__inner {
204
+ width: 100%;
205
+ justify-content: end;
206
+ }
207
+ }
208
+
209
+ &--left {
210
+ .q-dialog__inner {
211
+ width: 100%;
212
+ }
213
+ }
186
214
  }
187
215
  </style>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <qas-dialog class="qas-drawer" v-bind="attributes" @update:model-value="onUpdateModelValue">
3
+ <template #header>
4
+ <div class="items-center justify-between row">
5
+ <span data-cy="drawer-title">
6
+ <slot name="title">
7
+ <h3 v-if="props.title" class="text-h3">
8
+ {{ props.title }}
9
+ </h3>
10
+ </slot>
11
+ </span>
12
+
13
+ <qas-btn v-close-popup class="z-max" color="grey-10" data-cy="drawer-close-btn" icon="sym_r_close" variant="tertiary" @click="emit('update:modelValue', false)" />
14
+ </div>
15
+ </template>
16
+
17
+ <template #description>
18
+ <div>
19
+ <div class="relative-position" data-cy="drawer-default">
20
+ <slot />
21
+ </div>
22
+
23
+ <div v-if="props.loading" class="qas-drawer__loading" :style="loadingStyle">
24
+ <div class="full-height relative-position">
25
+ <q-inner-loading :showing="props.loading">
26
+ <q-spinner color="grey" size="2em" />
27
+ </q-inner-loading>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
32
+ </qas-dialog>
33
+ </template>
34
+
35
+ <script setup>
36
+ import useScreen from '../../composables/use-screen.js'
37
+
38
+ import { computed, useAttrs } from 'vue'
39
+
40
+ defineOptions({
41
+ name: 'QasDrawer',
42
+ inheritAttrs: false
43
+ })
44
+
45
+ const props = defineProps({
46
+ dialogProps: {
47
+ type: Object,
48
+ default: () => ({})
49
+ },
50
+
51
+ maxWidth: {
52
+ type: String,
53
+ default: ''
54
+ },
55
+
56
+ position: {
57
+ type: String,
58
+ default: 'left',
59
+ validator: value => ['left', 'right'].includes(value)
60
+ },
61
+
62
+ title: {
63
+ type: String,
64
+ default: ''
65
+ },
66
+
67
+ loading: {
68
+ type: Boolean
69
+ }
70
+ })
71
+
72
+ const emit = defineEmits(['update:modelValue'])
73
+
74
+ const attrs = useAttrs()
75
+ const screen = useScreen()
76
+
77
+ const loadingStyle = computed(() => {
78
+ return {
79
+ right: `calc(100% - ${props.maxWidth})`
80
+ }
81
+ })
82
+
83
+ const attributes = computed(() => {
84
+ const maxWidth = screen.isSmall ? '100%' : props.maxWidth
85
+
86
+ const { modelValue } = attrs
87
+
88
+ return {
89
+ persistent: false,
90
+ modelValue,
91
+
92
+ ...props.dialogProps,
93
+
94
+ cancel: false,
95
+ maxWidth,
96
+ maximized: true,
97
+ ok: false,
98
+ position: props.position,
99
+ useFullMaxWidth: true
100
+ }
101
+ })
102
+
103
+ function onUpdateModelValue (value) {
104
+ emit('update:modelValue', value)
105
+ }
106
+ </script>
107
+
108
+ <style lang="scss">
109
+ .qas-drawer {
110
+ &__loading {
111
+ height: 100vh;
112
+ left: 0;
113
+ position: absolute;
114
+ top: 0;
115
+ }
116
+ }
117
+ </style>
@@ -0,0 +1,57 @@
1
+ type: component
2
+
3
+ meta:
4
+ desc: Componente de drawer que implementa o QasDialog.
5
+
6
+ props:
7
+ dialog-props:
8
+ desc: Repassa propriedades para o QasDialog.
9
+ default: {}
10
+ type: Object
11
+
12
+ max-width:
13
+ desc: Tamanho máximo do dialog.
14
+ type: String
15
+
16
+ model-value:
17
+ desc: Model do componente, abre ou fecha o drawer.
18
+ type: Boolean
19
+ examples: [v-model="value"]
20
+ model: true
21
+
22
+ position:
23
+ desc: 'Posição do drawer, sendo possível apenas 2 opções: [left, right].'
24
+ default: left
25
+ type: String
26
+
27
+ title:
28
+ desc: Titulo do drawer.
29
+ type: String
30
+
31
+ slots:
32
+ default:
33
+ desc: Slot para conteúdo principal.
34
+
35
+ title:
36
+ desc: Slot para o titulo.
37
+
38
+ events:
39
+ '@update:model-value -> function (value)':
40
+ desc: Dispara toda vez que o model é atualizado, também utilizado para v-model.
41
+ params:
42
+ value:
43
+ desc: Novo valor do v-model
44
+ type: Boolean
45
+
46
+ selectors:
47
+ drawer-close-btn:
48
+ desc: Seletor do botão de fechar.
49
+ examples: ['data-cy="drawer-close-btn"']
50
+
51
+ drawer-default:
52
+ desc: Seletor do botão conteúdo do slot default.
53
+ examples: ['data-cy="drawer-default"']
54
+
55
+ drawer-title:
56
+ desc: Seletor do titulo.
57
+ examples: ['data-cy="drawer-title"']
@@ -62,7 +62,7 @@ const props = defineProps({
62
62
 
63
63
  defineExpose({ refresh, remove })
64
64
 
65
- const emit = defineEmits(['update:list'])
65
+ const emit = defineEmits(['update:list', 'fetch-success', 'fetch-error'])
66
66
 
67
67
  const axios = inject('axios')
68
68
 
@@ -135,10 +135,14 @@ async function fetchList () {
135
135
  * após buscar uma vez e retornar uma lista vazia.
136
136
  */
137
137
  hasMadeFirstFetch.value = true
138
- } catch {
138
+
139
+ emit('fetch-success', { list: newList, offset: offset.value, count: count.value })
140
+ } catch (error) {
139
141
  NotifyError('Ops… Não conseguimos acessar as informações. Por favor, tente novamente em alguns minutos.')
140
142
 
141
143
  hasFetchingError.value = true
144
+
145
+ emit('fetch-error', error)
142
146
  } finally {
143
147
  isFetching.value = false
144
148
  }
@@ -44,6 +44,28 @@ events:
44
44
  desc: Novo valor do list
45
45
  type: Array
46
46
 
47
+ '@fetch-error -> function (error)':
48
+ desc: Dispara toda vez que ocorre algum erro ao fazer nova busca na API.
49
+ params:
50
+ error:
51
+ count: Erro enviado da API.
52
+ type: Object
53
+
54
+ '@fetch-success -> function ({ count, list, offset })':
55
+ desc: Dispara toda vez que é feito uma nova busca com sucesso na API.
56
+ params:
57
+ count:
58
+ count: Tamanho máximo de itens.
59
+ type: Number
60
+
61
+ list:
62
+ desc: Lista do endpoint.
63
+ type: Array
64
+
65
+ offset:
66
+ desc: valor atual que indica a posição inicial ao recuperar resultados da lista.
67
+ type: Number
68
+
47
69
  slots:
48
70
  default:
49
71
  desc: slot para exibir a lista na qual o componente fez a busca.
@@ -1,11 +1,11 @@
1
1
  <template>
2
2
  <q-layout view="hHh Lpr lff">
3
3
  <slot v-if="$qas.screen.untilLarge" name="app-bar">
4
- <qas-app-bar v-bind="appBarProps" @sign-out="signOut" @toggle-menu="toggleMenuDrawer" />
4
+ <qas-app-bar v-bind="appBarProps" @sign-out="signOut" @toggle-menu="toggleMenuDrawer" @toggle-notifications="toggleNotificationsDrawer" />
5
5
  </slot>
6
6
 
7
7
  <slot name="app-menu">
8
- <qas-app-menu :model-value="showMenuDrawer" v-bind="defaultAppMenuProps" @sign-out="signOut" @update:model-value="updateMenuDrawer" />
8
+ <qas-app-menu :model-value="showMenuDrawer" v-bind="defaultAppMenuProps" @sign-out="signOut" @toggle-notifications="toggleNotificationsDrawer" @update:model-value="updateMenuDrawer" />
9
9
  </slot>
10
10
 
11
11
  <slot>
@@ -17,72 +17,103 @@
17
17
  </slot>
18
18
 
19
19
  <q-ajax-bar color="primary" position="bottom" size="2px" />
20
+
21
+ <pv-layout-notifications-drawer v-if="isNotificationsEnabled" v-model="notificationsDrawer" />
20
22
  </q-layout>
21
23
  </template>
22
24
 
23
- <script>
25
+ <script setup>
26
+ import PvLayoutNotificationsDrawer from './private/PvLayoutNotificationsDrawer.vue'
24
27
  import QasAppBar from '../app-bar/QasAppBar.vue'
25
28
  import QasAppMenu from '../app-menu/QasAppMenu.vue'
26
29
 
27
- export default {
28
- name: 'QasLayout',
30
+ import useScreen from '../../composables/use-screen'
31
+ import useNotifications from '../../composables/use-notifications'
29
32
 
30
- components: {
31
- QasAppBar,
32
- QasAppMenu
33
- },
33
+ import { computed, ref, watch } from 'vue'
34
34
 
35
- props: {
36
- appBarProps: {
37
- default: () => ({}),
38
- type: Object
39
- },
40
-
41
- appMenuProps: {
42
- default: () => ({}),
43
- type: Object
44
- },
45
-
46
- modelValue: {
47
- default: true,
48
- type: Boolean
49
- }
50
- },
35
+ defineOptions({ name: 'QasLayout' })
51
36
 
52
- emits: ['sign-out', 'update:modelValue'],
37
+ const props = defineProps({
38
+ appBarProps: {
39
+ default: () => ({}),
40
+ type: Object
41
+ },
53
42
 
54
- data () {
55
- return {
56
- menuDrawer: false
57
- }
43
+ appMenuProps: {
44
+ default: () => ({}),
45
+ type: Object
58
46
  },
59
47
 
60
- computed: {
61
- defaultAppMenuProps () {
62
- return {
63
- ...this.appBarProps,
64
- ...this.appMenuProps
65
- }
66
- },
67
-
68
- showMenuDrawer () {
69
- return !this.$qas.screen.untilLarge || this.menuDrawer
70
- }
48
+ initialUnreadNotificationsCount: {
49
+ type: Number,
50
+ default: 0
71
51
  },
72
52
 
73
- methods: {
74
- signOut () {
75
- this.$emit('sign-out')
76
- },
53
+ modelValue: {
54
+ default: true,
55
+ type: Boolean
56
+ }
57
+ })
58
+
59
+ const emit = defineEmits(['sign-out', 'update:modelValue'])
60
+
61
+ // expondo método "toggleNotificationsDrawer" para fora do componente.
62
+ defineExpose({ toggleNotificationsDrawer })
63
+
64
+ const screen = useScreen()
65
+
66
+ const { isNotificationsEnabled, setUnreadNotificationsCount } = useNotifications()
67
+
68
+ const menuDrawer = ref(false)
69
+ const notificationsDrawer = ref(false)
70
+
71
+ /**
72
+ * Como está sendo utilizado em um watcher com a propriedade 'immediate: true',
73
+ * é necessário criar a variável antes de atribuí-la ao watcher, para assim conseguir pará-lo.
74
+ */
75
+ let unreadNotificationsCountWatcher = () => {}
77
76
 
78
- toggleMenuDrawer () {
79
- this.updateMenuDrawer(!this.menuDrawer)
80
- },
77
+ // computed
78
+ const defaultAppMenuProps = computed(() => {
79
+ return {
80
+ ...props.appBarProps,
81
+ ...props.appMenuProps
82
+ }
83
+ })
84
+
85
+ const showMenuDrawer = computed(() => !screen.untilLarge || menuDrawer.value)
86
+
87
+ /**
88
+ * A propriedade "initialUnreadNotificationsCount" é escutada apenas uma vez,
89
+ * quando ela é iniciada, seta o "unreadNotificationsCount" do composable,
90
+ * após isto quem controla é o QasLayout.
91
+ */
92
+ unreadNotificationsCountWatcher = watch(() => props.initialUnreadNotificationsCount, value => {
93
+ if (value) {
94
+ setUnreadNotificationsCount(value)
81
95
 
82
- updateMenuDrawer (value) {
83
- this.menuDrawer = value
84
- this.$emit('update:modelValue', value)
85
- }
96
+ // finaliza o watcher
97
+ unreadNotificationsCountWatcher()
86
98
  }
99
+ }, { immediate: true })
100
+
101
+ // functions
102
+ function signOut () {
103
+ emit('sign-out')
104
+ }
105
+
106
+ function toggleMenuDrawer () {
107
+ updateMenuDrawer(!menuDrawer.value)
108
+ }
109
+
110
+ function updateMenuDrawer (value) {
111
+ menuDrawer.value = value
112
+
113
+ emit('update:modelValue', value)
114
+ }
115
+
116
+ function toggleNotificationsDrawer () {
117
+ notificationsDrawer.value = !notificationsDrawer.value
87
118
  }
88
119
  </script>
@@ -14,6 +14,11 @@ props:
14
14
  default: { animation: 500 }
15
15
  type: Object
16
16
 
17
+ initial-unread-notifications-count:
18
+ desc: Propriedade para indicar quantas notificações não lidas existem.
19
+ default: 0
20
+ type: Number
21
+
17
22
  model-value:
18
23
  desc: Model do componente, responsável por abrir/fechar menu lateral.
19
24
  default: true
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <div class="items-center justify-between no-wrap row">
3
+ <div class="items-center row">
4
+ <div class="q-mr-sm">
5
+ <q-icon :color="iconColor" name="sym_r_info" size="md" />
6
+ </div>
7
+
8
+ <div>
9
+ <span class="text-caption text-grey-6">
10
+ {{ dateLabel }}
11
+ </span>
12
+
13
+ <div class="items-center q-mt-xs row">
14
+ <h6 class="text-subtitle1" :class="titleClass">
15
+ {{ props.notification.title }}
16
+ </h6>
17
+
18
+ <div v-if="hasBadge" class="q-ml-sm">
19
+ <qas-badge color="indigo-1" label="Nova" text-color="grey-10" />
20
+ </div>
21
+ </div>
22
+
23
+ <div class="q-mt-xs text-body1 text-grey-8">
24
+ {{ props.notification.message }}
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <qas-btn v-if="props.notification.link" v-bind="buttonProps" />
30
+ </div>
31
+ </template>
32
+
33
+ <script setup>
34
+ import { dateTime } from '../../../helpers/filters'
35
+
36
+ import { computed } from 'vue'
37
+ import { date } from 'quasar'
38
+
39
+ defineOptions({ name: 'PvLayoutNotificationCard' })
40
+
41
+ const props = defineProps({
42
+ notification: {
43
+ type: Object,
44
+ default: () => ({})
45
+ }
46
+ })
47
+
48
+ const markedAsRead = computed(() => props.notification.isRead)
49
+
50
+ const iconColor = computed(() => markedAsRead.value ? 'grey-8' : 'primary')
51
+ const titleClass = computed(() => markedAsRead.value ? 'text-grey-8' : 'text-grey-10')
52
+
53
+ const isRecentNotification = computed(() => {
54
+ const currentDate = new Date().toISOString()
55
+
56
+ return date.getDateDiff(currentDate, props.notification.createdAt, 'minutes') < 10
57
+ })
58
+
59
+ const hasBadge = computed(() => isRecentNotification.value && !markedAsRead.value)
60
+
61
+ const dateLabel = computed(() => {
62
+ return isRecentNotification.value
63
+ ? 'Agora mesmo'
64
+ : dateTime(props.notification.createdAt, 'dd/MM/yyyy HH:mm')
65
+ })
66
+
67
+ const buttonProps = computed(() => {
68
+ const { link } = props.notification
69
+
70
+ const urlFromLink = new URL(link)
71
+
72
+ const isExternalURL = urlFromLink.host !== location.host
73
+
74
+ return {
75
+ icon: 'sym_r_chevron_right',
76
+ color: markedAsRead.value ? 'grey-10' : 'primary',
77
+
78
+ /**
79
+ * Se for uma url externa, diferente da URL que ele esta acessando no momento,
80
+ * é adicionado um "href" porque é um link para outro modulo, caso seja para o mesmo modulo,
81
+ * é adicionado um "to" para não recarregar a pagina.
82
+ */
83
+ ...(isExternalURL ? { href: link } : { to: urlFromLink.pathname })
84
+ }
85
+ })
86
+ </script>
@@ -0,0 +1,141 @@
1
+ <template>
2
+ <qas-drawer v-bind="drawerProps" v-model="model">
3
+ <div class="fixed-position">
4
+ <div class="justify-end row">
5
+ <qas-btn class="q-mb-xl" :disable="isAllNotificationsRead" icon="sym_r_check_circle" label="Marcar todas como lida" @click="markAsRead" />
6
+ </div>
7
+
8
+ <qas-infinite-scroll v-model:list="notifications" v-bind="infiniteScrollProps">
9
+ <qas-list-items :list="notifications" :use-box="false" :use-clickable-item="false" :use-section-actions="false">
10
+ <template #item-section="{ item }">
11
+ <pv-layout-notification-card :notification="item" />
12
+ </template>
13
+ </qas-list-items>
14
+ </qas-infinite-scroll>
15
+ </div>
16
+ </qas-drawer>
17
+ </template>
18
+
19
+ <script setup>
20
+ import PvLayoutNotificationCard from './PvLayoutNotificationCard.vue'
21
+ import QasDrawer from '../../drawer/QasDrawer.vue'
22
+ import QasInfiniteScroll from '../../infinite-scroll/QasInfiniteScroll.vue'
23
+
24
+ import useNotifications, { onNotificationReceived } from '../../../composables/use-notifications'
25
+
26
+ import { promiseHandler } from '../../../helpers'
27
+
28
+ import { computed, ref, inject, onMounted } from 'vue'
29
+
30
+ defineOptions({ name: 'PvLayoutNotificationsDrawer' })
31
+
32
+ const props = defineProps({
33
+ model: {
34
+ type: Boolean
35
+ }
36
+ })
37
+
38
+ const emit = defineEmits(['update:modelValue'])
39
+
40
+ const axios = inject('axios')
41
+
42
+ const { setUnreadNotificationsCount } = useNotifications()
43
+
44
+ const hasMadeFirstFetch = ref(false)
45
+ const isMarkingNotificationsAsRead = ref(false)
46
+
47
+ const notifications = ref([])
48
+
49
+ onMounted(() => {
50
+ const notificationsUtilsChannel = new BroadcastChannel('notifications--utils')
51
+
52
+ notificationsUtilsChannel.onmessage = ({ data }) => {
53
+ if (data.type === 'markAllAsRead') onMarkAsReadSuccess()
54
+ }
55
+ })
56
+
57
+ /**
58
+ * Quando o usuário ainda não abriu a central de notificações, a primeira vez que
59
+ * ele abrir vai obter esses dados via API, mesmo que ele tenha recebido a notificação
60
+ * em real time, após ele ter aberto as notificações pelo menos uma vez, todas
61
+ * notificações recebidas em real time serão incrementadas na central de
62
+ * notificação sem a necessidade de chamar a API e resetar a paginação (feita por scroll).
63
+ */
64
+ onNotificationReceived(notification => {
65
+ if (!hasMadeFirstFetch.value) return
66
+
67
+ notifications.value.unshift(notification)
68
+ })
69
+
70
+ const infiniteScrollProps = {
71
+ limitPerPage: 30,
72
+ // os "165px" são referentes ao cabeçalho.
73
+ maxHeight: 'calc(100vh - 165px)',
74
+ url: 'users/me/notifications',
75
+ onFetchSuccess
76
+ }
77
+
78
+ // computed
79
+ const model = computed({
80
+ get () {
81
+ return props.model
82
+ },
83
+
84
+ set (value) {
85
+ emit('update:modelValue', value)
86
+ }
87
+ })
88
+
89
+ const drawerProps = computed(() => {
90
+ return {
91
+ loading: isMarkingNotificationsAsRead.value,
92
+ maxWidth: '60%',
93
+ title: 'Notificações'
94
+ }
95
+ })
96
+
97
+ /**
98
+ * Se todas notificações estiverem lidas, então desabilitar o botão de "Marcar todas como lida"
99
+ */
100
+ const isAllNotificationsRead = computed(() => !notifications.value.some(notification => !notification?.isRead))
101
+
102
+ // functions
103
+ async function markAsRead () {
104
+ const { data } = await promiseHandler(
105
+ axios.patch('/users/me/notifications', { markAllAsRead: true }),
106
+ {
107
+ useLoading: false,
108
+ errorMessage: 'Falha ao marcar todas notificações como lida. Por favor, tente novamente em alguns minutos.',
109
+ onLoading: isLoading => {
110
+ isMarkingNotificationsAsRead.value = isLoading
111
+ }
112
+ }
113
+ )
114
+
115
+ if (data) {
116
+ const notificationsUtilsChannel = new BroadcastChannel('notifications--utils')
117
+
118
+ notificationsUtilsChannel.postMessage({ type: 'markAllAsRead' })
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Ao marcar todas notificações como lida, é necessário percorrer todo o array
124
+ * de "notifications" e setar a prop "isRead" como "true", para não precisar chamar
125
+ * novamente a API para atualizar estes dados.
126
+ */
127
+ function onMarkAsReadSuccess () {
128
+ notifications.value.forEach(notification => {
129
+ notification.isRead = true
130
+ })
131
+
132
+ /**
133
+ * Zera o contador de notificações, responsável pelo ícone no QasAppUser.
134
+ */
135
+ setUnreadNotificationsCount(0)
136
+ }
137
+
138
+ function onFetchSuccess () {
139
+ hasMadeFirstFetch.value = true
140
+ }
141
+ </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <qas-box class="qas-list-items">
2
+ <component :is="component" class="qas-list-items" :class="classes">
3
3
  <q-list separator>
4
4
  <q-item v-for="(item, index) in props.list" :key="index" v-ripple :clickable="props.useClickableItem" @click="onClick({ item, index }, true)">
5
5
  <slot :index="index" :item="item" name="item">
@@ -15,13 +15,14 @@
15
15
  </slot>
16
16
  </q-item>
17
17
  </q-list>
18
- </qas-box>
18
+ </component>
19
19
  </template>
20
20
 
21
21
  <script setup>
22
- import QasBox from '../box/QasBox.vue'
23
22
  import QasBtn from '../btn/QasBtn.vue'
24
23
 
24
+ import { computed } from 'vue'
25
+
25
26
  defineOptions({ name: 'QasListItems' })
26
27
 
27
28
  const props = defineProps({
@@ -35,6 +36,11 @@ const props = defineProps({
35
36
  type: Array
36
37
  },
37
38
 
39
+ useBox: {
40
+ type: Boolean,
41
+ default: true
42
+ },
43
+
38
44
  useClickableItem: {
39
45
  type: Boolean
40
46
  },
@@ -47,6 +53,10 @@ const props = defineProps({
47
53
 
48
54
  const emit = defineEmits(['click-item'])
49
55
 
56
+ const classes = computed(() => ({ 'qas-list-items--no-click': !props.useClickableItem }))
57
+
58
+ const component = computed(() => props.useBox ? 'qas-box' : 'div')
59
+
50
60
  function onClick ({ item, index }, fromItem) {
51
61
  /**
52
62
  * se o click veio do q-item e "useClickableItem" for "false", ou
@@ -62,8 +72,26 @@ function onClick ({ item, index }, fromItem) {
62
72
 
63
73
  <style lang="scss">
64
74
  .qas-list-items {
65
- .q-list > .q-item {
66
- padding: 24px 16px;
75
+ &--no-click {
76
+ .q-item {
77
+ .q-ripple {
78
+ display: none;
79
+ }
80
+ }
81
+ }
82
+
83
+ .q-list {
84
+ & > .q-item {
85
+ padding: var(--qas-spacing-lg) 0;
86
+ }
87
+
88
+ & > .q-item:last-child {
89
+ padding-bottom: 0;
90
+ }
91
+
92
+ & > .q-item:first-child {
93
+ padding-top: 0;
94
+ }
67
95
  }
68
96
  }
69
97
  </style>
@@ -14,6 +14,11 @@ props:
14
14
  default: []
15
15
  type: Array
16
16
 
17
+ use-box:
18
+ desc: Controla se o vai ser um QasBox ou div.
19
+ type: Boolean
20
+ default: true
21
+
17
22
  use-clickable-item:
18
23
  desc: Controla se o item inteiro é clicável ou somente o button dentro do item.
19
24
  type: Boolean
@@ -3,3 +3,6 @@ export { default as useForm } from './use-form.js'
3
3
  export { default as useHistory } from './use-history.js'
4
4
  export { default as useQueryCache } from './use-query-cache.js'
5
5
  export { default as useScreen } from './use-screen.js'
6
+ export { default as useNotifications } from './use-notifications.js'
7
+
8
+ export * from './use-notifications.js'
@@ -0,0 +1,114 @@
1
+ import asteroidConfig from 'asteroid-config'
2
+ import hasParentByClassName from '../helpers/private/has-parent-by-class-name'
3
+
4
+ import { Notify } from 'quasar'
5
+ import { ref } from 'vue'
6
+
7
+ const callbackFunctions = {
8
+ onNotificationReceived: []
9
+ }
10
+
11
+ export function onNotificationReceived (callbackFn) {
12
+ callbackFunctions.onNotificationReceived.push(callbackFn)
13
+ }
14
+
15
+ const unreadNotificationsCount = ref(0)
16
+
17
+ export default function () {
18
+ const isNotificationsEnabled = asteroidConfig.framework.featureToggle?.useNotifications
19
+
20
+ function triggerNotification (notification) {
21
+ callbackFunctions.onNotificationReceived.forEach(fn => fn((notification)))
22
+ }
23
+
24
+ function setUnreadNotificationsCount (value) {
25
+ unreadNotificationsCount.value = value
26
+ }
27
+
28
+ function incrementUnreadNotificationsCount () {
29
+ unreadNotificationsCount.value++
30
+ }
31
+
32
+ /**
33
+ * @param {{
34
+ * message: string
35
+ * title: string
36
+ * link?: string
37
+ * }} config
38
+ */
39
+ function sendNotify (config = {}) {
40
+ const { link } = config
41
+
42
+ const classes = ['bg-white', 'boot-notification', 'q-py-sm', 'text-grey-8']
43
+
44
+ if (link) classes.push(...['boot-notification--has-link', 'cursor-pointer'])
45
+
46
+ const closeNotify = Notify.create({
47
+ actions: [{
48
+ icon: 'sym_r_close',
49
+ class: 'boot-notification__close-button qas-btn qas-btn--tertiary qas-btn--tertiary-grey-10'
50
+ }],
51
+
52
+ attrs: {
53
+ onClick: event => onNotifyClick({ event, link, closeNotify })
54
+ },
55
+
56
+ classes: classes.join(' '),
57
+ html: true,
58
+ icon: 'sym_r_info',
59
+ iconColor: 'primary',
60
+ message: getHTMLMessage(config),
61
+ multiLine: true,
62
+ position: 'bottom-right',
63
+ timeout: 30000
64
+ })
65
+
66
+ /**
67
+ * Função que é chamada quando o usuário clica na notificação, se a notificação
68
+ * tem link, então ele vai ser redirecionado para o link em uma nova aba, caso
69
+ * não tenha link, nada acontece.
70
+ */
71
+ function onNotifyClick ({ event, link, closeNotify }) {
72
+ if (!link) return
73
+
74
+ if (hasParentByClassName('boot-notification__close-button', event.srcElement)) return
75
+
76
+ window.open(link)
77
+
78
+ closeNotify()
79
+ }
80
+
81
+ /**
82
+ * Função que retorna um HTML para seguir estilos propostos pelo design.
83
+ */
84
+ function getHTMLMessage ({ message, title, icon = 'info' } = {}) {
85
+ return (`
86
+ <div>
87
+ <header class="row items-center">
88
+ <h5 class="text-grey-10 text-h5 boot-notification__title">
89
+ ${title}
90
+ </h5>
91
+ </header>
92
+
93
+ <div class="q-mt-sm text-grey-8 text-body1">
94
+ ${message}
95
+ </div>
96
+ </div>
97
+ `)
98
+ }
99
+ }
100
+
101
+ return {
102
+ // const
103
+ isNotificationsEnabled,
104
+
105
+ // computed
106
+ unreadNotificationsCount,
107
+
108
+ // functions
109
+ incrementUnreadNotificationsCount,
110
+ sendNotify,
111
+ setUnreadNotificationsCount,
112
+ triggerNotification
113
+ }
114
+ }
@@ -1,6 +1,8 @@
1
1
  .q-notification {
2
- margin-top: 80px;
3
- max-width: 560px;
2
+ &:not(.boot-notification).qas-notification {
3
+ margin-top: 80px;
4
+ max-width: 560px;
5
+ }
4
6
 
5
7
  &.qas-notification {
6
8
  &--error::before,
@@ -56,6 +58,42 @@
56
58
  }
57
59
  }
58
60
 
61
+ &.boot-notification {
62
+ // classe do quasar adc fixo 10px de margin-bottom
63
+ margin-top: 0;
64
+ margin-bottom: calc(var(--qas-spacing-xl) - 10px);
65
+ max-width: 480px;
66
+ width: calc(100% - 20px);
67
+
68
+ &--has-link {
69
+ transition: var(--qas-generic-transition);
70
+
71
+ &:not(:has(.boot-notification__close-button:hover)):hover .boot-notification__title {
72
+ color: var(--q-primary) !important;
73
+ }
74
+ }
75
+
76
+ .boot-notification__close-button {
77
+ position: absolute;
78
+ right: calc(var(--qas-spacing-sm) * -1);
79
+ top: 0;
80
+ }
81
+
82
+ .q-notification__wrapper {
83
+ position: relative;
84
+
85
+ &::before {
86
+ background-color: var(--q-primary);
87
+ bottom: calc((var(--qas-spacing-sm)) * -1);
88
+ content: '';
89
+ left: calc((var(--qas-spacing-md)) * -1);
90
+ position: absolute;
91
+ top: calc((var(--qas-spacing-sm)) * -1);
92
+ width: 4px;
93
+ }
94
+ }
95
+ }
96
+
59
97
  @media (max-width: $breakpoint-xs) {
60
98
  margin-top: 40px;
61
99
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Função que retorna se o elemento passado é filho do elemento especificado
3
+ * pelo nome da classe.
4
+ */
5
+ export default function (className, element) {
6
+ while (element.parentElement) {
7
+ const hasParentElement = element.parentElement.classList.contains(className)
8
+
9
+ if (hasParentElement) return true
10
+
11
+ element = element.parentElement
12
+ }
13
+
14
+ return false
15
+ }
package/src/vue-plugin.js CHANGED
@@ -19,6 +19,7 @@ import QasDebugger from './components/debugger/QasDebugger.vue'
19
19
  import QasDelete from './components/delete/QasDelete.vue'
20
20
  import QasDialog from './components/dialog/QasDialog.vue'
21
21
  import QasDialogRouter from './components/dialog-router/QasDialogRouter.vue'
22
+ import QasDrawer from './components/drawer/QasDrawer.vue'
22
23
  import QasEmptyResultText from './components/empty-result-text/QasEmptyResultText.vue'
23
24
  import QasField from './components/field/QasField.vue'
24
25
  import QasFilters from './components/filters/QasFilters.vue'
@@ -105,6 +106,7 @@ async function install (app) {
105
106
  app.component('QasDelete', QasDelete)
106
107
  app.component('QasDialog', QasDialog)
107
108
  app.component('QasDialogRouter', QasDialogRouter)
109
+ app.component('QasDrawer', QasDrawer)
108
110
  app.component('QasEmptyResultText', QasEmptyResultText)
109
111
  app.component('QasField', QasField)
110
112
  app.component('QasFilters', QasFilters)
@@ -193,6 +195,7 @@ export {
193
195
  QasDelete,
194
196
  QasDialog,
195
197
  QasDialogRouter,
198
+ QasDrawer,
196
199
  QasEmptyResultText,
197
200
  QasField,
198
201
  QasFilters,