quasar-ui-danx 0.4.27 → 0.4.29

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