quasar-ui-danx 0.4.4 → 0.4.6

Sign up to get free protection for your applications and to get access to all the features.
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 {