quasar-ui-danx 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quasar-ui-danx",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "author": "Dan <dan@flytedesk.com>",
5
5
  "description": "DanX Vue / Quasar component library",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "vite-svg-loader": "^5.1.0",
44
44
  "vue": "^3.4.21",
45
45
  "vue-eslint-parser": "^9.4.2",
46
- "vue-router": "^4.0.0"
46
+ "vue-router": "^4.3.2"
47
47
  },
48
48
  "dependencies": {
49
49
  "@heroicons/vue": "v1",
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  class="dx-action-table overflow-hidden"
4
- :class="{'dx-no-data': !hasData}"
4
+ :class="{'dx-no-data': !hasData, 'dx-is-loading': loadingList || loadingSummary, 'dx-is-loading-list': loadingList}"
5
5
  >
6
6
  <ActionVnode />
7
7
  <QTable
@@ -9,7 +9,7 @@
9
9
  :selected="selectedRows"
10
10
  :pagination="pagination"
11
11
  :columns="tableColumns"
12
- :loading="loadingList"
12
+ :loading="loadingList || loadingSummary"
13
13
  :rows="pagedItems?.data || []"
14
14
  :binary-state-sort="false"
15
15
  selection="multiple"
@@ -1,5 +1,10 @@
1
1
  <template>
2
2
  <div>
3
+ <FieldLabel
4
+ v-if="label"
5
+ :label="label"
6
+ class="mb-1 block"
7
+ />
3
8
  <QSelect
4
9
  ref="selectField"
5
10
  v-bind="$props"
@@ -15,6 +20,7 @@
15
20
  option-label="label"
16
21
  option-value="value"
17
22
  placeholder=""
23
+ label=""
18
24
  :input-class="{'is-hidden': !isShowing, [inputClass]: true}"
19
25
  class="max-w-full dx-select-field"
20
26
  @filter="onFilter"
@@ -68,6 +74,7 @@
68
74
  import { ChevronDownIcon as DropDownIcon } from "@heroicons/vue/outline";
69
75
  import { QSelect, QSelectProps } from "quasar";
70
76
  import { computed, isRef, nextTick, ref } from "vue";
77
+ import FieldLabel from "./FieldLabel";
71
78
 
72
79
  export interface Props extends QSelectProps {
73
80
  modelValue?: any;
@@ -1,12 +1,14 @@
1
1
  <template>
2
2
  <div>
3
- <div v-if="readonly">
4
- <LabelValueBlock
5
- :label="label || field?.label || 'Text'"
6
- :value="modelValue"
7
- />
8
- </div>
9
- <template v-else>
3
+ <FieldLabel
4
+ :field="field"
5
+ :label="label"
6
+ :show-name="showName"
7
+ :class="labelClass"
8
+ :value="readonly ? modelValue : ''"
9
+ class="mb-1 block"
10
+ />
11
+ <template v-if="!readonly">
10
12
  <QInput
11
13
  :placeholder="field?.placeholder"
12
14
  outlined
@@ -24,16 +26,7 @@
24
26
  :debounce="debounce"
25
27
  @keydown.enter="$emit('submit')"
26
28
  @update:model-value="$emit('update:model-value', $event)"
27
- >
28
- <template #label>
29
- <FieldLabel
30
- :field="field"
31
- :label="label"
32
- :show-name="showName"
33
- :class="labelClass"
34
- />
35
- </template>
36
- </QInput>
29
+ />
37
30
  <MaxLengthCounter
38
31
  :length="modelValue?.length || 0"
39
32
  :max-length="field?.maxLength"
@@ -46,7 +39,6 @@
46
39
  import { TextFieldProps } from "../../../../types";
47
40
  import MaxLengthCounter from "../Utilities/MaxLengthCounter";
48
41
  import FieldLabel from "./FieldLabel";
49
- import LabelValueBlock from "./LabelValueBlock";
50
42
 
51
43
  defineEmits(["update:model-value", "submit"]);
52
44
  withDefaults(defineProps<TextFieldProps>(), {
@@ -8,6 +8,7 @@
8
8
  :actions="actions?.filter(a => a.batch)"
9
9
  :action-target="controller.selectedRows.value"
10
10
  :exporter="controller.exportList"
11
+ :loading="controller.isLoadingList.value || controller.isLoadingSummary.value"
11
12
  @refresh="controller.refreshAll"
12
13
  >
13
14
  <template #default>
@@ -9,24 +9,28 @@
9
9
  :class="{'has-selection': selectedCount}"
10
10
  >
11
11
  <div class="flex flex-nowrap items-center">
12
- <div
13
- v-if="selectedCount"
14
- class="flex items-center"
15
- >
16
- <ClearIcon
17
- class="w-6 mr-3"
18
- @click="$emit('clear')"
12
+ <div class="relative">
13
+ <QSpinner
14
+ v-if="loading"
15
+ class="absolute top-0 left-0"
16
+ size="18"
19
17
  />
20
- {{ fNumber(selectedCount) }} {{ selectedLabel }}
18
+ <div
19
+ :class="{'opacity-0': loading}"
20
+ class="flex items-center nowrap"
21
+ >
22
+ <ClearIcon
23
+ v-if="selectedCount"
24
+ class="w-6 mr-3 cursor-pointer"
25
+ @click="$emit('clear')"
26
+ />
27
+
28
+ {{ fNumber(selectedCount || itemCount) }}
29
+ </div>
21
30
  </div>
22
- <div v-else-if="itemCount">
23
- {{ fNumber(itemCount) }} {{ label }}
31
+ <div class="ml-2">
32
+ {{ selectedCount ? selectedLabel : label }}
24
33
  </div>
25
- <QSpinner
26
- v-if="loading"
27
- class="ml-3"
28
- size="18"
29
- />
30
34
  </div>
31
35
  </QTd>
32
36
  <QTd
@@ -48,49 +52,49 @@ import { fNumber } from "../../helpers";
48
52
 
49
53
  defineEmits(["clear"]);
50
54
  const props = defineProps({
51
- loading: Boolean,
52
- label: {
53
- type: String,
54
- default: "Rows"
55
- },
56
- selectedLabel: {
57
- type: String,
58
- default: "Selected"
59
- },
60
- selectedCount: {
61
- type: Number,
62
- default: 0
63
- },
64
- itemCount: {
65
- type: Number,
66
- default: 0
67
- },
68
- summary: {
69
- type: Object,
70
- default: null
71
- },
72
- columns: {
73
- type: Array,
74
- required: true
75
- },
76
- stickyColspan: {
77
- type: Number,
78
- default: 2
79
- }
55
+ loading: Boolean,
56
+ label: {
57
+ type: String,
58
+ default: "Rows"
59
+ },
60
+ selectedLabel: {
61
+ type: String,
62
+ default: "Selected"
63
+ },
64
+ selectedCount: {
65
+ type: Number,
66
+ default: 0
67
+ },
68
+ itemCount: {
69
+ type: Number,
70
+ default: 0
71
+ },
72
+ summary: {
73
+ type: Object,
74
+ default: null
75
+ },
76
+ columns: {
77
+ type: Array,
78
+ required: true
79
+ },
80
+ stickyColspan: {
81
+ type: Number,
82
+ default: 2
83
+ }
80
84
  });
81
85
 
82
86
  const summaryColumns = computed(() => {
83
- // The sticky columns are where we display the selection count and should not be included in the summary columns
84
- return props.columns.slice(props.stickyColspan - 1);
87
+ // The sticky columns are where we display the selection count and should not be included in the summary columns
88
+ return props.columns.slice(props.stickyColspan - 1);
85
89
  });
86
90
 
87
91
  function formatValue(column) {
88
- const value = props.summary[column.name];
89
- if (value === undefined) return "";
92
+ const value = props.summary[column.name];
93
+ if (value === undefined) return "";
90
94
 
91
- if (column.format) {
92
- return column.format(value);
93
- }
94
- return value;
95
+ if (column.format) {
96
+ return column.format(value);
97
+ }
98
+ return value;
95
99
  }
96
100
  </script>
@@ -1,5 +1,5 @@
1
1
  import { computed, Ref, ref, shallowRef, watch } from "vue";
2
- import { RouteLocationNormalizedLoaded, RouteParams, Router, useRoute, useRouter } from "vue-router";
2
+ import { RouteLocationNormalizedLoaded, RouteParams, Router } from "vue-router";
3
3
  import { getItem, setItem, storeObject, waitForRef } from "../../helpers";
4
4
  import {
5
5
  ActionController,
@@ -7,6 +7,7 @@ import {
7
7
  AnyObject,
8
8
  FilterGroup,
9
9
  ListControlsFilter,
10
+ ListControlsInitializeOptions,
10
11
  ListControlsOptions,
11
12
  ListControlsPagination,
12
13
  PagedItems
@@ -15,8 +16,8 @@ import { getFilterFromUrl } from "./listHelpers";
15
16
 
16
17
  export function useListControls(name: string, options: ListControlsOptions): ActionController {
17
18
  let isInitialized = false;
18
- let vueRoute: RouteLocationNormalizedLoaded | null = null;
19
- let vueRouter: Router | null = null;
19
+ let vueRoute: RouteLocationNormalizedLoaded | null | undefined;
20
+ let vueRouter: Router | null | undefined;
20
21
  const PAGE_SETTINGS_KEY = `dx-${name}-pager`;
21
22
  const pagedItems = shallowRef<PagedItems | null>(null);
22
23
  const activeFilter = ref<ListControlsFilter>({});
@@ -74,8 +75,12 @@ export function useListControls(name: string, options: ListControlsOptions): Act
74
75
  async function loadList() {
75
76
  if (!isInitialized) return;
76
77
  isLoadingList.value = true;
77
- setPagedItems(await options.routes.list(pager.value));
78
- isLoadingList.value = false;
78
+ try {
79
+ setPagedItems(await options.routes.list(pager.value));
80
+ isLoadingList.value = false;
81
+ } catch (e) {
82
+ // Fail silently
83
+ }
79
84
  }
80
85
 
81
86
  async function loadSummary() {
@@ -86,8 +91,12 @@ export function useListControls(name: string, options: ListControlsOptions): Act
86
91
  if (selectedRows.value.length) {
87
92
  summaryFilter.id = selectedRows.value.map((row) => row.id);
88
93
  }
89
- summary.value = await options.routes.summary(summaryFilter);
90
- isLoadingSummary.value = false;
94
+ try {
95
+ summary.value = await options.routes.summary(summaryFilter);
96
+ isLoadingSummary.value = false;
97
+ } catch (e) {
98
+ // Fail silently
99
+ }
91
100
  }
92
101
 
93
102
  async function loadListAndSummary() {
@@ -109,8 +118,12 @@ export function useListControls(name: string, options: ListControlsOptions): Act
109
118
  async function loadFieldOptions() {
110
119
  if (!options.routes.fieldOptions || !isInitialized) return;
111
120
  isLoadingFilters.value = true;
112
- fieldOptions.value = await options.routes.fieldOptions(activeFilter.value) || {};
113
- isLoadingFilters.value = false;
121
+ try {
122
+ fieldOptions.value = await options.routes.fieldOptions(activeFilter.value) || {};
123
+ isLoadingFilters.value = false;
124
+ } catch (e) {
125
+ // Fail silently
126
+ }
114
127
  }
115
128
 
116
129
  /**
@@ -196,18 +209,22 @@ export function useListControls(name: string, options: ListControlsOptions): Act
196
209
  async function loadMore(index: number, perPage: number | undefined = undefined) {
197
210
  if (!options.routes.more) return false;
198
211
 
199
- const newItems = await options.routes.more({
200
- page: index + 1,
201
- perPage,
202
- filter: { ...activeFilter.value, ...globalFilter.value }
203
- });
204
-
205
- if (newItems && newItems.length > 0) {
206
- setPagedItems({
207
- data: [...(pagedItems.value?.data || []), ...newItems],
208
- meta: { total: pagedItems.value?.meta?.total || 0 }
212
+ try {
213
+ const newItems = await options.routes.more({
214
+ page: index + 1,
215
+ perPage,
216
+ filter: { ...activeFilter.value, ...globalFilter.value }
209
217
  });
210
- return true;
218
+
219
+ if (newItems && newItems.length > 0) {
220
+ setPagedItems({
221
+ data: [...(pagedItems.value?.data || []), ...newItems],
222
+ meta: { total: pagedItems.value?.meta?.total || 0 }
223
+ });
224
+ return true;
225
+ }
226
+ } catch (e) {
227
+ // Fail silently
211
228
  }
212
229
 
213
230
  return false;
@@ -282,14 +299,18 @@ export function useListControls(name: string, options: ListControlsOptions): Act
282
299
  async function getActiveItemDetails() {
283
300
  if (!activeItem.value || !options.routes.details) return;
284
301
 
285
- const result = await options.routes.details(activeItem.value);
302
+ try {
303
+ const result = await options.routes.details(activeItem.value);
286
304
 
287
- if (!result || !result.__type || !result.id) {
288
- return console.error("Invalid response from details route: All responses must include a __type and id field. result =", result);
289
- }
305
+ if (!result || !result.__type || !result.id) {
306
+ return console.error("Invalid response from details route: All responses must include a __type and id field. result =", result);
307
+ }
290
308
 
291
- // Reassign the active item to the store object to ensure reactivity
292
- activeItem.value = storeObject(result);
309
+ // Reassign the active item to the store object to ensure reactivity
310
+ activeItem.value = storeObject(result);
311
+ } catch (e) {
312
+ // Fail silently
313
+ }
293
314
  }
294
315
 
295
316
  // Whenever the active item changes, fill the additional item details
@@ -313,12 +334,8 @@ export function useListControls(name: string, options: ListControlsOptions): Act
313
334
  activePanel.value = panel;
314
335
 
315
336
  // Push vue router change /:id/:panel
316
- if (vueRoute && vueRouter && item?.id) {
317
- vueRouter.push({
318
- name: Array.isArray(vueRoute.name) ? vueRoute.name[0] : vueRoute.name,
319
- params: { id: item.id, panel },
320
- replace: true
321
- });
337
+ if (item?.id) {
338
+ updateRouteParams({ id: item.id, panel });
322
339
  }
323
340
  }
324
341
 
@@ -329,7 +346,7 @@ export function useListControls(name: string, options: ListControlsOptions): Act
329
346
  activeItem.value = item ? storeObject(item) : item;
330
347
 
331
348
  if (!item?.id) {
332
- vueRouter?.push({ name: vueRoute?.name || "home" });
349
+ updateRouteParams({});
333
350
  }
334
351
  }
335
352
 
@@ -380,23 +397,20 @@ export function useListControls(name: string, options: ListControlsOptions): Act
380
397
  }
381
398
 
382
399
  async function exportList(filter?: ListControlsFilter) {
383
- return options.routes.export(filter);
400
+ options.routes.export && await options.routes.export(filter);
384
401
  }
385
402
 
386
403
  // Initialize the list actions and load settings, lists, summaries, filter fields, etc.
387
- function initialize() {
404
+ function initialize(initOptions?: ListControlsInitializeOptions) {
405
+ vueRouter = initOptions?.vueRouter;
406
+ vueRoute = initOptions?.vueRouter?.currentRoute.value;
388
407
  isInitialized = true;
389
408
  loadSettings();
390
409
 
391
- // Setup Vue Router handling
392
- vueRoute = useRoute();
393
- vueRouter = useRouter();
394
-
395
- console.log("init listControl", name, options, "vueRoute", vueRoute);
396
410
  /**
397
411
  * Watch the id params in the route and set the active item to the item with the given id.
398
412
  */
399
- if (options.routes.details && vueRoute) {
413
+ if (options.routes.details && vueRoute && vueRouter) {
400
414
  const { params, meta } = vueRoute;
401
415
 
402
416
  const controlRouteName = vueRoute.name;
@@ -410,6 +424,18 @@ export function useListControls(name: string, options: ListControlsOptions): Act
410
424
  }
411
425
  }
412
426
 
427
+ /**
428
+ * Updates the URL bar and route to the given params.
429
+ */
430
+ function updateRouteParams(params: AnyObject) {
431
+ if (vueRouter && vueRoute) {
432
+ vueRouter.push({
433
+ name: (Array.isArray(vueRoute.name) ? vueRoute.name[0] : vueRoute.name) || "home",
434
+ params
435
+ });
436
+ }
437
+ }
438
+
413
439
  function setPanelFromRoute(params: RouteParams, meta: AnyObject) {
414
440
  const id = Array.isArray(params?.id) ? params.id[0] : params?.id;
415
441
  if (id && meta.type) {
@@ -0,0 +1,20 @@
1
+ let appRefreshed = false;
2
+
3
+ export function refreshApplication(callback: () => void) {
4
+ // Only allow refreshing the application once
5
+ if (appRefreshed) return;
6
+ appRefreshed = true;
7
+
8
+ // Create a hidden iframe
9
+ const iframe = document.createElement("iframe");
10
+ iframe.style.display = "none";
11
+ iframe.src = window.location.href;
12
+ document.body.appendChild(iframe);
13
+
14
+ // Listen for the iframe to finish loading
15
+ iframe.onload = () => {
16
+ // Remove the iframe
17
+ document.body.removeChild(iframe);
18
+ callback();
19
+ };
20
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./actions";
2
+ export * from "./app";
2
3
  export * from "./array";
3
4
  export * from "./compatibility";
4
5
  export * from "./date";
@@ -1,46 +1,75 @@
1
1
  import { Ref } from "vue";
2
2
  import { danxOptions } from "../config";
3
- import { AnyObject } from "../types";
3
+ import { HttpResponse, RequestApi } from "../types";
4
4
 
5
5
  /**
6
6
  * A simple request helper that wraps the fetch API
7
7
  * to make GET and POST requests easier w/ JSON payloads
8
8
  */
9
- export const request = {
10
- url(url: string) {
9
+ export const request: RequestApi = {
10
+ abortControllers: {},
11
+
12
+ url(url) {
11
13
  if (url.startsWith("http")) {
12
14
  return url;
13
15
  }
14
16
  return (danxOptions.value.request?.baseUrl || "").replace(/\/$/, "") + "/" + url;
15
17
  },
16
18
 
17
- async call(url: string, options: RequestInit): Promise<object> {
18
- try {
19
- const response = await fetch(request.url(url), options);
20
- const result = await response.json();
21
-
22
- if (response.status === 401) {
23
- const onUnauthorized = danxOptions.value.request?.onUnauthorized;
24
- return onUnauthorized ? onUnauthorized(response) : {
25
- error: true,
26
- message: "Unauthorized"
27
- };
19
+ async call(url, options) {
20
+ options = options || {};
21
+ const abortKey = options?.abortOn !== undefined ? options.abortOn : url;
22
+ const timestamp = new Date().getTime();
23
+
24
+ if (abortKey) {
25
+ const abort = new AbortController();
26
+ const previousAbort = request.abortControllers[abortKey];
27
+ // If there is already an abort controller set for this key, abort it
28
+ if (previousAbort) {
29
+ previousAbort.abort.abort("Request was aborted due to a newer request being made");
28
30
  }
29
31
 
30
- if (response.status > 400) {
31
- if (result.exception && !result.error) {
32
- result.error = true;
33
- }
32
+ // Set the new abort controller for this key
33
+ request.abortControllers[abortKey] = { abort, timestamp };
34
+ options.signal = abort.signal;
35
+ }
36
+
37
+ const response = await fetch(request.url(url), options);
38
+
39
+ // Verify the app version of the client and server are matching
40
+ checkAppVersion(response);
41
+
42
+ // handle the case where the request was aborted too late, and we need to abort the response via timestamp check
43
+ if (abortKey) {
44
+ // If the request was aborted too late, but there was still another request that was made after the current,
45
+ // then abort the current request with an abort flag
46
+ if (timestamp < request.abortControllers[abortKey].timestamp) {
47
+ throw new Error("Request was aborted due to a newer request being made: " + timestamp + " < " + request.abortControllers[abortKey].timestamp);
34
48
  }
35
49
 
36
- return result;
37
- } catch (error: any) {
38
- return {
39
- error: error.message || "An error occurred fetching the data"
50
+ // Otherwise, the current is the most recent request, so we can delete the abort controller
51
+ delete request.abortControllers[abortKey];
52
+ }
53
+
54
+ const result = await response.json();
55
+
56
+ if (response.status === 401) {
57
+ const onUnauthorized = danxOptions.value.request?.onUnauthorized;
58
+ return onUnauthorized ? onUnauthorized(response) : {
59
+ error: true,
60
+ message: "Unauthorized"
40
61
  };
41
62
  }
63
+
64
+ if (response.status > 400) {
65
+ if (result.exception && !result.error) {
66
+ result.error = true;
67
+ }
68
+ }
69
+
70
+ return result;
42
71
  },
43
- async get(url: string, options: RequestInit = {}): Promise<object> {
72
+ async get(url, options) {
44
73
  return await request.call(url, {
45
74
  method: "get",
46
75
  headers: {
@@ -52,10 +81,10 @@ export const request = {
52
81
  });
53
82
  },
54
83
 
55
- async post(url: string, data: AnyObject = {}, options: RequestInit = {}) {
56
- return request.call(url, {
84
+ async post(url, data, options) {
85
+ return await request.call(url, {
57
86
  method: "post",
58
- body: JSON.stringify(data),
87
+ body: data && JSON.stringify(data),
59
88
  headers: {
60
89
  Accept: "application/json",
61
90
  "Content-Type": "application/json",
@@ -66,6 +95,23 @@ export const request = {
66
95
  }
67
96
  };
68
97
 
98
+ /**
99
+ * Checks the app version of the client and server to see if they match.
100
+ * If they do not match, the onAppVersionMismatch callback is called
101
+ */
102
+ function checkAppVersion(response: HttpResponse) {
103
+ const requestOptions = danxOptions.value.request;
104
+ if (!requestOptions || !requestOptions.headers || !requestOptions.onAppVersionMismatch) {
105
+ return;
106
+ }
107
+
108
+ const clientAppVersion = requestOptions.headers["X-App-Version"] || "";
109
+ const serverAppVersion = response.headers.get("X-App-Version");
110
+ if (clientAppVersion && clientAppVersion !== serverAppVersion) {
111
+ requestOptions.onAppVersionMismatch(serverAppVersion);
112
+ }
113
+ }
114
+
69
115
  /**
70
116
  * Fetches a resource list applying the filter. If there is a selected resource,
71
117
  * stores that resource from the already populated list. If that resource does not exist
@@ -1,28 +1,28 @@
1
- import { ActionTargetItem, AnyObject, ListControlsPagination } from "../types";
1
+ import { ListControlsRoutes } from "../types";
2
2
  import { downloadFile } from "./downloadPdf";
3
3
  import { request } from "./request";
4
4
 
5
- export function useActionRoutes(baseUrl: string) {
5
+ export function useActionRoutes(baseUrl: string): ListControlsRoutes {
6
6
  return {
7
- list(pager: ListControlsPagination) {
7
+ list(pager?) {
8
8
  return request.post(`${baseUrl}/list`, pager);
9
9
  },
10
- summary(filter: AnyObject) {
10
+ summary(filter) {
11
11
  return request.post(`${baseUrl}/summary`, { filter });
12
12
  },
13
- details(target: ActionTargetItem) {
13
+ details(target) {
14
14
  return request.get(`${baseUrl}/${target.id}/details`);
15
15
  },
16
16
  fieldOptions() {
17
17
  return request.get(`${baseUrl}/field-options`);
18
18
  },
19
- applyAction(action: string, target: ActionTargetItem | null, data: object) {
19
+ applyAction(action, target, data) {
20
20
  return request.post(`${baseUrl}/${target ? target.id : "new"}/apply-action`, { action, data });
21
21
  },
22
- batchAction(action: string, targets: ActionTargetItem[], data: object) {
22
+ batchAction(action, targets, data) {
23
23
  return request.post(`${baseUrl}/batch-action`, { action, filter: { id: targets.map(r => r.id) }, data });
24
24
  },
25
- export(filter: AnyObject, name?: string) {
25
+ export(filter, name) {
26
26
  return downloadFile(`${baseUrl}/export`, name || "export.csv", { filter });
27
27
  }
28
28
  };
@@ -45,7 +45,7 @@
45
45
 
46
46
  &.q-field--labeled {
47
47
  .q-field__control-container {
48
- padding-top: 1.1rem;
48
+ padding-top: 0;
49
49
  }
50
50
 
51
51
  &.q-textarea {