quasar-ui-danx 0.4.27 → 0.4.29

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 (51) hide show
  1. package/README.md +35 -35
  2. package/dist/danx.es.js +24686 -24119
  3. package/dist/danx.es.js.map +1 -1
  4. package/dist/danx.umd.js +109 -109
  5. package/dist/danx.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +1 -1
  8. package/src/components/ActionTable/ActionTable.vue +29 -7
  9. package/src/components/ActionTable/Filters/FilterableField.vue +14 -2
  10. package/src/components/ActionTable/Form/ActionForm.vue +17 -12
  11. package/src/components/ActionTable/Form/Fields/DateField.vue +24 -20
  12. package/src/components/ActionTable/Form/Fields/DateRangeField.vue +57 -53
  13. package/src/components/ActionTable/Form/Fields/EditOnClickTextField.vue +9 -2
  14. package/src/components/ActionTable/Form/Fields/EditableDiv.vue +51 -21
  15. package/src/components/ActionTable/Form/Fields/FieldLabel.vue +1 -1
  16. package/src/components/ActionTable/Form/Fields/SelectField.vue +27 -6
  17. package/src/components/ActionTable/Form/Fields/SelectOrCreateField.vue +56 -0
  18. package/src/components/ActionTable/Form/Fields/TextField.vue +2 -0
  19. package/src/components/ActionTable/Form/Fields/index.ts +1 -0
  20. package/src/components/ActionTable/Form/RenderedForm.vue +7 -20
  21. package/src/components/ActionTable/Form/Utilities/MaxLengthCounter.vue +1 -1
  22. package/src/components/ActionTable/Form/Utilities/SaveStateIndicator.vue +37 -0
  23. package/src/components/ActionTable/Form/Utilities/index.ts +1 -0
  24. package/src/components/ActionTable/Layouts/ActionTableLayout.vue +20 -23
  25. package/src/components/ActionTable/Toolbars/ActionToolbar.vue +44 -36
  26. package/src/components/ActionTable/{listControls.ts → controls.ts} +13 -9
  27. package/src/components/ActionTable/index.ts +1 -1
  28. package/src/components/DragAndDrop/ListItemDraggable.vue +45 -31
  29. package/src/components/DragAndDrop/dragAndDrop.ts +221 -220
  30. package/src/components/DragAndDrop/listDragAndDrop.ts +269 -227
  31. package/src/components/PanelsDrawer/PanelsDrawer.vue +7 -7
  32. package/src/components/PanelsDrawer/PanelsDrawerTabs.vue +3 -3
  33. package/src/components/Utility/Buttons/ShowHideButton.vue +86 -0
  34. package/src/components/Utility/Buttons/index.ts +1 -0
  35. package/src/components/Utility/Dialogs/ActionFormDialog.vue +30 -0
  36. package/src/components/Utility/Dialogs/CreateNewWithNameDialog.vue +26 -0
  37. package/src/components/Utility/Dialogs/RenderedFormDialog.vue +50 -0
  38. package/src/components/Utility/Dialogs/index.ts +3 -0
  39. package/src/helpers/FileUpload.ts +4 -4
  40. package/src/helpers/actions.ts +84 -20
  41. package/src/helpers/files.ts +56 -43
  42. package/src/helpers/formats.ts +23 -20
  43. package/src/helpers/objectStore.ts +24 -12
  44. package/src/types/actions.d.ts +50 -26
  45. package/src/types/controls.d.ts +23 -25
  46. package/src/types/fields.d.ts +1 -0
  47. package/src/types/files.d.ts +2 -2
  48. package/src/types/index.d.ts +5 -0
  49. package/src/types/shared.d.ts +9 -0
  50. package/src/types/tables.d.ts +3 -3
  51. package/types/vue-shims.d.ts +3 -2
@@ -0,0 +1,30 @@
1
+ <template>
2
+ <ConfirmDialog
3
+ :title="title"
4
+ :confirm-text="confirmText || title"
5
+ @confirm="$emit('confirm', {name})"
6
+ @close="$emit('close')"
7
+ >
8
+ <ActionForm
9
+ :form="form"
10
+ :action="action"
11
+ :target="target"
12
+ />
13
+ </ConfirmDialog>
14
+ </template>
15
+ <script setup lang="ts">
16
+ import { ref } from "vue";
17
+ import { ActionTargetItem, Form, ResourceAction } from "../../../types";
18
+ import { ActionForm } from "../../ActionTable";
19
+ import ConfirmDialog from "./ConfirmDialog";
20
+
21
+ defineEmits(["confirm", "close"]);
22
+ defineProps<{
23
+ title: string;
24
+ confirmText?: string;
25
+ form: Form;
26
+ action: ResourceAction;
27
+ target: ActionTargetItem;
28
+ }>();
29
+ const name = ref("");
30
+ </script>
@@ -0,0 +1,26 @@
1
+ <template>
2
+ <ConfirmDialog
3
+ :title="title"
4
+ :confirm-text="confirmText || title"
5
+ @confirm="$emit('confirm', {name})"
6
+ @close="$emit('close')"
7
+ >
8
+ <TextField
9
+ v-model="name"
10
+ label="Name"
11
+ />
12
+ <slot />
13
+ </ConfirmDialog>
14
+ </template>
15
+ <script setup lang="ts">
16
+ import { ref } from "vue";
17
+ import { TextField } from "../../ActionTable";
18
+ import ConfirmDialog from "./ConfirmDialog";
19
+
20
+ defineEmits(["confirm", "close"]);
21
+ defineProps<{
22
+ title: string;
23
+ confirmText?: string;
24
+ }>();
25
+ const name = ref("");
26
+ </script>
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <ConfirmDialog
3
+ :title="title"
4
+ :confirm-text="confirmText || title"
5
+ :content-class="contentClass"
6
+ @confirm="$emit('confirm', input)"
7
+ @close="$emit('close')"
8
+ >
9
+ <RenderedForm
10
+ v-bind="renderedFormProps"
11
+ v-model:values="input"
12
+ empty-value=""
13
+ >
14
+ <slot />
15
+ </RenderedForm>
16
+ </ConfirmDialog>
17
+ </template>
18
+ <script setup lang="ts">
19
+ import { AnyObject, Form } from "../../../types";
20
+ import { RenderedForm } from "../../ActionTable/Form";
21
+ import ConfirmDialog from "./ConfirmDialog";
22
+
23
+ defineEmits(["confirm", "close"]);
24
+ const props = defineProps<{
25
+ title: string;
26
+ confirmText?: string;
27
+ form: Form;
28
+ noLabel?: boolean;
29
+ showName?: boolean;
30
+ disable?: boolean;
31
+ readonly?: boolean;
32
+ clearable?: boolean;
33
+ fieldClass?: string;
34
+ savingClass?: string;
35
+ hideSavedAt?: boolean;
36
+ contentClass?: string;
37
+ }>();
38
+ const input = defineModel<AnyObject>();
39
+
40
+ const renderedFormProps = {
41
+ form: props.form,
42
+ noLabel: props.noLabel,
43
+ showName: props.showName,
44
+ disable: props.disable,
45
+ readonly: props.readonly,
46
+ clearable: props.clearable,
47
+ fieldClass: props.fieldClass,
48
+ savingClass: props.savingClass
49
+ };
50
+ </script>
@@ -1,6 +1,9 @@
1
+ export { default as ActionFormDialog } from "./ActionFormDialog.vue";
1
2
  export { default as ConfirmActionDialog } from "./ConfirmActionDialog.vue";
2
3
  export { default as ConfirmDialog } from "./ConfirmDialog.vue";
4
+ export { default as CreateNewWithNameDialog } from "./CreateNewWithNameDialog.vue";
3
5
  export { default as FullScreenCarouselDialog } from "./FullscreenCarouselDialog.vue";
4
6
  export { default as FullScreenDialog } from "./FullScreenDialog.vue";
5
7
  export { default as InfoDialog } from "./InfoDialog.vue";
6
8
  export { default as InputDialog } from "./InputDialog.vue";
9
+ export { default as RenderedFormDialog } from "./RenderedFormDialog.vue";
@@ -243,11 +243,11 @@ export class FileUpload {
243
243
  /**
244
244
  * Mark the presigned upload as completed and return the file resource from the platform server
245
245
  */
246
- async completePresignedUpload(fileUpload: XHRFileUpload) {
246
+ async completePresignedUpload(xhrFile: XHRFileUpload) {
247
247
  // Show 95% as the last 5% will be to complete the presigned upload
248
- this.fireProgressCallback(fileUpload, .95);
248
+ this.fireProgressCallback(xhrFile, .95);
249
249
 
250
- if (!fileUpload.file.resource_id) {
250
+ if (!xhrFile.file.resource_id) {
251
251
  throw new Error("File resource ID is required to complete presigned upload");
252
252
  }
253
253
 
@@ -256,7 +256,7 @@ export class FileUpload {
256
256
  }
257
257
 
258
258
  // Let the platform know the presigned upload is complete
259
- return await this.options.completePresignedUpload(fileUpload.file.resource_id);
259
+ return await this.options.completePresignedUpload(xhrFile.file.resource_id, xhrFile);
260
260
  }
261
261
 
262
262
  /**
@@ -1,7 +1,9 @@
1
1
  import { useDebounceFn } from "@vueuse/core";
2
+ import { FaSolidCopy as CopyIcon, FaSolidPencil as EditIcon, FaSolidTrash as DeleteIcon } from "danx-icon";
2
3
  import { uid } from "quasar";
3
- import { isReactive, Ref, shallowRef } from "vue";
4
- import type { ActionOptions, ActionTarget, AnyObject, ResourceAction } from "../types";
4
+ import { h, isReactive, Ref, shallowRef } from "vue";
5
+ import { ConfirmActionDialog, CreateNewWithNameDialog } from "../components";
6
+ import type { ActionGlobalOptions, ActionOptions, ActionTarget, ListController, ResourceAction } from "../types";
5
7
  import { FlashMessages } from "./FlashMessages";
6
8
  import { storeObject } from "./objectStore";
7
9
 
@@ -11,7 +13,7 @@ export const activeActionVnode: Ref = shallowRef(null);
11
13
  * Hook to perform an action on a set of targets
12
14
  * This helper allows you to perform actions by name on a set of targets using a provided list of actions
13
15
  */
14
- export function useActions(actions: ActionOptions[], globalOptions: Partial<ActionOptions> | null = null) {
16
+ export function useActions(actions: ActionOptions[], globalOptions: ActionGlobalOptions | null = null) {
15
17
  const namespace = uid();
16
18
 
17
19
  /**
@@ -33,6 +35,14 @@ export function useActions(actions: ActionOptions[], globalOptions: Partial<Acti
33
35
  return storeObject(extendedAction);
34
36
  }
35
37
 
38
+ /**
39
+ * Updates an action replacing the old options with the new options
40
+ */
41
+ function modifyAction(actionName: string, actionOptions: Partial<ActionOptions>): ResourceAction {
42
+ const action = getAction(actionName);
43
+ return storeObject({ ...action, ...actionOptions });
44
+ }
45
+
36
46
  /**
37
47
  * Resolve the action object based on the provided name (or return the object if the name is already an object)
38
48
  */
@@ -48,6 +58,9 @@ export function useActions(actions: ActionOptions[], globalOptions: Partial<Acti
48
58
  if (isReactive(baseOptions) && "__type" in baseOptions) return baseOptions as ResourceAction;
49
59
 
50
60
  const resourceAction: ResourceAction = storeObject({
61
+ onAction: globalOptions?.routes?.applyAction,
62
+ onBatchAction: globalOptions?.routes?.batchAction,
63
+ onBatchSuccess: globalOptions?.controls?.clearSelectedRows,
51
64
  ...globalOptions,
52
65
  ...baseOptions,
53
66
  trigger: (target, input) => performAction(resourceAction, target, input),
@@ -60,27 +73,22 @@ export function useActions(actions: ActionOptions[], globalOptions: Partial<Acti
60
73
  resourceAction.trigger = useDebounceFn((target, input) => performAction(resourceAction, target, input), baseOptions.debounce);
61
74
  }
62
75
 
76
+ // Splice the resourceAction in place of the action in the actions list
77
+ actions.splice(actions.findIndex(a => a.name === actionName), 1, resourceAction);
78
+
63
79
  return resourceAction;
64
80
  }
65
81
 
66
82
  /**
67
- * Filter the list of actions based on the provided filters in key-value pairs
68
- * You can filter on any ActionOptions property by matching the value exactly or by providing an array of values
69
- *
70
- * @param filters
71
- * @returns {ActionOptions[]}
83
+ * Returns a filtered list of actions. Useful for building ordered menus.
84
+ * NOTE: If an action doesn't already exist, it will be created.
72
85
  */
73
- function getActions(filters?: AnyObject): ResourceAction[] {
74
- let filteredActions = [...actions];
75
-
76
- if (filters) {
77
- for (const filterKey of Object.keys(filters)) {
78
- const filterValue = filters[filterKey];
79
- filteredActions = filteredActions.filter((a: AnyObject) => a[filterKey] === filterValue || (Array.isArray(filterValue) && filterValue.includes(a[filterKey])));
80
- }
86
+ function getActions(names: string[]): ResourceAction[] {
87
+ const filteredActions = [];
88
+ for (const name of names) {
89
+ filteredActions.push(getAction(name));
81
90
  }
82
-
83
- return filteredActions.map((a: ActionOptions) => getAction(a.name));
91
+ return filteredActions;
84
92
  }
85
93
 
86
94
  /**
@@ -90,11 +98,15 @@ export function useActions(actions: ActionOptions[], globalOptions: Partial<Acti
90
98
  * @param {object[]|object} target - an array of targets or a single target object
91
99
  * @param {any} input - The input data to pass to the action handler
92
100
  */
93
- async function performAction(action: ResourceAction, target: ActionTarget = null, input: any = null) {
101
+ async function performAction(action: ResourceAction | string, target: ActionTarget = null, input: any = null): Promise<any | void> {
102
+ if (typeof action === "string") {
103
+ action = getAction(action);
104
+ }
105
+
94
106
  // Resolve the original action, if the current action is an alias
95
107
  const aliasedAction = action.alias ? getAction(action.alias) : null;
96
108
 
97
- const vnode = action.vnode && action.vnode(target);
109
+ const vnode = action.vnode && action.vnode(target, input);
98
110
  let result: any;
99
111
 
100
112
  // Run the onStart handler if it exists and quit the operation if it returns false
@@ -160,6 +172,8 @@ export function useActions(actions: ActionOptions[], globalOptions: Partial<Acti
160
172
  return {
161
173
  getAction,
162
174
  getActions,
175
+ action: performAction,
176
+ modifyAction,
163
177
  extendAction
164
178
  };
165
179
  }
@@ -271,3 +285,53 @@ async function onConfirmAction(action: ActionOptions, target: ActionTarget, inpu
271
285
 
272
286
  return result;
273
287
  }
288
+
289
+ export function withDefaultActions(label: string, listController?: ListController): ActionOptions[] {
290
+ return [
291
+ {
292
+ name: "create",
293
+ label: "Create " + label,
294
+ vnode: () => h(CreateNewWithNameDialog, { title: "Create " + label }),
295
+ onFinish: listController && ((result) => {
296
+ listController.activatePanel(result.item, "edit");
297
+ listController.loadListAndSummary();
298
+ })
299
+ },
300
+ {
301
+ name: "update",
302
+ optimistic: true
303
+ },
304
+ {
305
+ name: "update-debounced",
306
+ alias: "update",
307
+ debounce: 1000,
308
+ optimistic: true
309
+ },
310
+ {
311
+ name: "copy",
312
+ label: "Copy",
313
+ icon: CopyIcon,
314
+ onSuccess: listController?.loadListAndSummary
315
+ },
316
+ {
317
+ name: "edit",
318
+ label: "Edit",
319
+ icon: EditIcon,
320
+ onAction: (action, target) => listController?.activatePanel(target, "edit")
321
+ },
322
+ {
323
+ name: "delete",
324
+ label: "Delete",
325
+ class: "text-red-500",
326
+ iconClass: "text-red-500",
327
+ icon: DeleteIcon,
328
+ onFinish: listController?.loadListAndSummary,
329
+ vnode: (target: ActionTarget) => h(ConfirmActionDialog, {
330
+ action: "Delete",
331
+ label,
332
+ target,
333
+ confirmClass: "bg-red-900"
334
+ })
335
+ }
336
+ ];
337
+ }
@@ -1,53 +1,66 @@
1
1
  import ExifReader from "exifreader";
2
+ import { UploadedFile } from "src/types";
2
3
  import { useCompatibility } from "./compatibility";
3
4
  import { FlashMessages } from "./FlashMessages";
4
5
 
5
- export async function resolveFileLocation(file, waitMessage = null) {
6
- if (file.location) {
7
- return file.location;
8
- }
6
+ export async function resolveFileLocation(file: UploadedFile, waitMessage: string | null = null) {
7
+ if (file.location) {
8
+ return file.location;
9
+ }
9
10
 
10
- try {
11
- const tags = await ExifReader.load(file.blobUrl || file.url, {
12
- expanded: true
13
- });
14
- if (tags.gps) {
15
- return {
16
- latitude: tags.gps.Latitude,
17
- longitude: tags.gps.Longitude
18
- };
19
- }
11
+ try {
12
+ const tags = await ExifReader.load(file.blobUrl || file.url || "", {
13
+ expanded: true
14
+ });
15
+ if (tags.gps) {
16
+ return {
17
+ latitude: tags.gps.Latitude,
18
+ longitude: tags.gps.Longitude
19
+ };
20
+ }
21
+ } catch (error) {
22
+ console.error("Failed to load EXIF data from file:", error);
23
+ }
20
24
 
21
- const { waitForLocation, location } = useCompatibility();
25
+ try {
26
+ const { waitForLocation, location } = useCompatibility();
22
27
 
23
- // Show a waiting for location message if we have not returned within 1 second
24
- if (waitMessage) {
25
- setTimeout(() => {
26
- if (!location.value && waitMessage) {
27
- FlashMessages.warning(waitMessage);
28
- }
29
- }, 1000);
30
- }
28
+ // Show a waiting for location message if we have not returned within 1 second
29
+ if (waitMessage) {
30
+ setTimeout(() => {
31
+ if (!location.value && waitMessage) {
32
+ FlashMessages.warning(waitMessage);
33
+ }
34
+ }, 1000);
35
+ }
31
36
 
32
- // Wait for the browser to return the location (https only as http will not return a location)
33
- if (window.location.protocol === "https:") {
34
- await waitForLocation();
35
- }
36
- // Ignore the wait message if we already returned
37
- waitMessage = false;
38
- if (!location.value) {
39
- return null;
40
- }
37
+ // Wait for the browser to return the location (https only as http will not return a location)
38
+ if (window.location.protocol === "https:") {
39
+ await waitForLocation();
40
+ } else if (window.location.href.match("localhost")) {
41
+ // XXX: Special case for local development so we can test without https
42
+ return {
43
+ latitude: 37.7749,
44
+ longitude: -122.4194,
45
+ accuracy: 1,
46
+ altitude: 0,
47
+ altitudeAccuracy: 0
48
+ };
49
+ }
41
50
 
42
- return {
43
- latitude: location.value.latitude,
44
- longitude: location.value.longitude,
45
- accuracy: location.value.accuracy,
46
- altitude: location.value.altitude,
47
- altitudeAccuracy: location.value.altitudeAccuracy
48
- };
49
- } catch (error) {
50
- console.error(error);
51
- return null;
52
- }
51
+ if (!location.value) {
52
+ return null;
53
+ }
54
+
55
+ return {
56
+ latitude: location.value.latitude,
57
+ longitude: location.value.longitude,
58
+ accuracy: location.value.accuracy,
59
+ altitude: location.value.altitude,
60
+ altitudeAccuracy: location.value.altitudeAccuracy
61
+ };
62
+ } catch (error) {
63
+ console.error(error);
64
+ return null;
65
+ }
53
66
  }
@@ -32,32 +32,35 @@ export function remoteDateTime(dateTimeString: string) {
32
32
  /**
33
33
  * Parses a date string into a Luxon DateTime object
34
34
  */
35
- export function parseDateTime(dateTime: string | DateTime | null): DateTime {
35
+ export function parseDateTime(dateTime: string | DateTime | null): DateTime<boolean> | null {
36
36
  if (typeof dateTime === "string") {
37
- dateTime = dateTime.replace("T", " ").replace(/\//g, "-");
38
- return DateTime.fromSQL(dateTime);
37
+ return parseSqlDateTime(dateTime) || parseQDate(dateTime) || parseQDateTime(dateTime);
39
38
  }
40
39
  return dateTime || DateTime.fromSQL("0000-00-00 00:00:00");
41
40
  }
42
41
 
42
+ /**
43
+ * Parses a SQL formatted date string into a Luxon DateTime object
44
+ */
45
+ export function parseSqlDateTime(dateTime: string) {
46
+ const parsed = DateTime.fromSQL(dateTime.replace("T", " ").replace(/\//g, "-"));
47
+ return parsed.isValid ? parsed : null;
48
+ }
49
+
43
50
  /**
44
51
  * Parses a Quasar formatted date string into a Luxon DateTime object
45
- * @param date
46
- * @param format
47
- * @returns {DateTime}
48
52
  */
49
- export function parseQDate(date: string, format = "yyyy/MM/dd") {
50
- return DateTime.fromFormat(date, format);
53
+ export function parseQDate(date: string, format = "yyyy/MM/dd"): DateTime<boolean> | null {
54
+ const parsed = DateTime.fromFormat(date, format);
55
+ return parsed.isValid ? parsed : null;
51
56
  }
52
57
 
53
58
  /**
54
59
  * Parses a Quasar formatted date/time string into a Luxon DateTime object
55
- * @param date
56
- * @param format
57
- * @returns {DateTime}
58
60
  */
59
- export function parseQDateTime(date: string, format = "yyyy/MM/dd HH:mm:ss") {
60
- return DateTime.fromFormat(date, format);
61
+ export function parseQDateTime(date: string, format = "yyyy/MM/dd HH:mm:ss"): DateTime<boolean> | null {
62
+ const parsed = DateTime.fromFormat(date, format);
63
+ return parsed.isValid ? parsed : null;
61
64
  }
62
65
 
63
66
  /**
@@ -91,8 +94,8 @@ export function fDateTime(
91
94
  dateTime: string | DateTime | null = null,
92
95
  { format = "M/d/yy h:mma", empty = "- -" }: fDateOptions = {}
93
96
  ) {
94
- const formatted = parseDateTime(dateTime).toFormat(format).toLowerCase();
95
- return ["Invalid DateTime", "invalid datetime"].includes(formatted) ? empty : formatted;
97
+ const formatted = parseDateTime(dateTime)?.toFormat(format).toLowerCase();
98
+ return formatted || empty;
96
99
  }
97
100
 
98
101
  /**
@@ -111,9 +114,9 @@ export function dbDateTime(dateTime: string | DateTime | null = null) {
111
114
  * @param format
112
115
  * @returns {string}
113
116
  */
114
- export function fDate(dateTime: string, { empty = "--", format = "M/d/yy" }: fDateOptions = {}) {
115
- const formatted = parseDateTime(dateTime).toFormat(format || "M/d/yy");
116
- return ["Invalid DateTime", "invalid datetime"].includes(formatted) ? empty : formatted;
117
+ export function fDate(dateTime: string | DateTime | null, { empty = "--", format = "M/d/yy" }: fDateOptions = {}) {
118
+ const formatted = parseDateTime(dateTime)?.toFormat(format || "M/d/yy");
119
+ return formatted || empty;
117
120
  }
118
121
 
119
122
  /**
@@ -130,8 +133,8 @@ export function fSecondsToTime(second: number) {
130
133
 
131
134
  export function fElapsedTime(start: string, end?: string) {
132
135
  const endDateTime = end ? parseDateTime(end) : DateTime.now();
133
- const diff = endDateTime.diff(parseDateTime(start), ["hours", "minutes", "seconds"]);
134
- if (!diff.isValid) {
136
+ const diff = endDateTime?.diff(parseDateTime(start) || DateTime.now(), ["hours", "minutes", "seconds"]);
137
+ if (!diff?.isValid) {
135
138
  return "-";
136
139
  }
137
140
  const hours = Math.floor(diff.hours);
@@ -1,6 +1,6 @@
1
1
  import { uid } from "quasar";
2
2
  import { ShallowReactive, shallowReactive } from "vue";
3
- import { TypedObject } from "../types";
3
+ import { AnyObject, TypedObject } from "../types";
4
4
  import { FlashMessages } from "./FlashMessages";
5
5
 
6
6
  const store = new Map<string, any>();
@@ -18,7 +18,7 @@ export function storeObjects<T extends TypedObject>(newObjects: T[]) {
18
18
  * Store an object in the object store via type + id
19
19
  * Returns the stored object that should be used instead of the passed object as the returned object is shared across the system
20
20
  */
21
- export function storeObject<T extends TypedObject>(newObject: T): ShallowReactive<T> {
21
+ export function storeObject<T extends TypedObject>(newObject: T, recentlyStoredObjects: AnyObject = {}): ShallowReactive<T> {
22
22
  const id = newObject?.id || newObject?.name;
23
23
  const type = newObject?.__type;
24
24
  if (!id || !type) return shallowReactive(newObject);
@@ -32,38 +32,50 @@ export function storeObject<T extends TypedObject>(newObject: T): ShallowReactiv
32
32
 
33
33
  const objectKey = `${type}:${id}`;
34
34
 
35
+ // If the object was recently stored, return the recently stored object to avoid infinite recursion
36
+ if (recentlyStoredObjects[objectKey]) {
37
+ return recentlyStoredObjects[objectKey];
38
+ }
39
+
35
40
  // Retrieve the existing object if it already exists in the store
36
41
  const oldObject = store.get(objectKey);
37
42
 
38
43
  // If an old object exists, and it is newer than the new object, do not store the new object, just return the old
44
+ // NOTE: If the timestamp is the same, its possible the intention is to update the existing object, so DO NOT return old object in this case
39
45
  // @ts-expect-error __timestamp is guaranteed to be set in this case on both old and new
40
- if (oldObject && newObject.__timestamp <= oldObject.__timestamp) {
46
+ if (oldObject && newObject.__timestamp < oldObject.__timestamp) {
47
+ recentlyStoredObjects[objectKey] = oldObject;
41
48
  return oldObject;
42
49
  }
43
50
 
51
+ // Reference to the reactive version of the object so we can update all the child relationships and return the reactive object
52
+ const reactiveObject = oldObject || shallowReactive(newObject);
53
+
54
+ // Make sure to store the object in the recently stored objects to avoid infinite recursion
55
+ recentlyStoredObjects[objectKey] = reactiveObject;
56
+
44
57
  // Recursively store all the children of the object as well
45
58
  for (const key of Object.keys(newObject)) {
46
59
  const value = newObject[key];
47
60
  if (Array.isArray(value) && value.length > 0) {
48
61
  for (const index in value) {
49
62
  if (value[index] && typeof value[index] === "object") {
50
- newObject[key][index] = storeObject(value[index]);
63
+ newObject[key][index] = storeObject(value[index], recentlyStoredObjects);
51
64
  }
52
65
  }
53
66
  } else if (value?.__type) {
54
- // @ts-expect-error newObject[key] is guaranteed to be a TypedObject
55
- newObject[key] = storeObject(value);
67
+ // @ts-expect-error __type is guaranteed to be set in this case
68
+ newObject[key] = storeObject(value as TypedObject, recentlyStoredObjects);
56
69
  }
57
70
  }
58
71
 
59
- // Update the old object with the new object properties
60
- if (oldObject) {
61
- Object.assign(oldObject, newObject);
62
- return oldObject;
72
+ Object.assign(reactiveObject, newObject);
73
+
74
+ if (!oldObject) {
75
+ // Store the reactive object in the store if there was not already one existing
76
+ store.set(objectKey, reactiveObject);
63
77
  }
64
78
 
65
- const reactiveObject = shallowReactive(newObject);
66
- store.set(objectKey, reactiveObject);
67
79
  return reactiveObject;
68
80
  }
69
81