store unsaved record changes in local storage
This commit is contained in:
		
							parent
							
								
									6127350e91
								
							
						
					
					
						commit
						5eb54c7a3d
					
				|  | @ -84,7 +84,7 @@ | |||
| {:else if field.type === "relation"} | ||||
|     {@const relations = CommonHelper.toArray(rawValue)} | ||||
|     {@const expanded = CommonHelper.toArray(record.expand[field.name])} | ||||
|     {@const relLimit = short ? 20 : 200} | ||||
|     {@const relLimit = short ? 20 : 500} | ||||
|     <div class="inline-flex"> | ||||
|         {#if expanded.length} | ||||
|             {#each expanded.slice(0, relLimit) as item, i (i + item)} | ||||
|  | @ -103,7 +103,7 @@ | |||
|     </div> | ||||
| {:else if field.type === "file"} | ||||
|     {@const files = CommonHelper.toArray(rawValue)} | ||||
|     {@const filesLimit = short ? 10 : 200} | ||||
|     {@const filesLimit = short ? 10 : 500} | ||||
|     <div class="inline-flex"> | ||||
|         {#each files.slice(0, filesLimit) as filename, i (i + filename)} | ||||
|             <RecordFileThumb {record} {filename} size="sm" /> | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| <script> | ||||
|     import { createEventDispatcher, tick } from "svelte"; | ||||
|     import { slide } from "svelte/transition"; | ||||
|     import { Record } from "pocketbase"; | ||||
|     import CommonHelper from "@/utils/CommonHelper"; | ||||
|     import ApiClient from "@/utils/ApiClient"; | ||||
|  | @ -25,24 +26,25 @@ | |||
|     import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte"; | ||||
| 
 | ||||
|     const dispatch = createEventDispatcher(); | ||||
| 
 | ||||
|     const formId = "record_" + CommonHelper.randomString(5); | ||||
|     const TAB_FORM = "form"; | ||||
|     const TAB_PROVIDERS = "providers"; | ||||
|     const tabFormKey = "form"; | ||||
|     const tabProviderKey = "providers"; | ||||
| 
 | ||||
|     export let collection; | ||||
| 
 | ||||
|     let recordPanel; | ||||
|     let recordForm; | ||||
|     let original = null; | ||||
|     let record = new Record(); | ||||
|     let record = null; | ||||
|     let initialDraft = null; | ||||
|     let isSaving = false; | ||||
|     let confirmClose = false; // prevent close recursion | ||||
|     let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...} | ||||
|     let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...} | ||||
|     let initialFormHash = ""; | ||||
|     let activeTab = TAB_FORM; | ||||
|     let originalSerializedData = JSON.stringify(null); | ||||
|     let serializedData = originalSerializedData; | ||||
|     let activeTab = tabFormKey; | ||||
|     let isNew = true; | ||||
|     let isLoaded = false; | ||||
| 
 | ||||
|     $: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor"); | ||||
| 
 | ||||
|  | @ -50,18 +52,24 @@ | |||
|         CommonHelper.hasNonEmptyProps(uploadedFilesMap) || | ||||
|         CommonHelper.hasNonEmptyProps(deletedFileIndexesMap); | ||||
| 
 | ||||
|     $: hasChanges = hasFileChanges || initialFormHash != calculateFormHash(record); | ||||
|     $: serializedData = JSON.stringify(record); | ||||
| 
 | ||||
|     $: hasChanges = hasFileChanges || originalSerializedData != serializedData; | ||||
| 
 | ||||
|     $: isNew = !original || original.isNew; | ||||
| 
 | ||||
|     $: canSave = isNew || hasChanges; | ||||
| 
 | ||||
|     $: if (isLoaded) { | ||||
|         updateDraft(serializedData); | ||||
|     } | ||||
| 
 | ||||
|     export function show(model) { | ||||
|         load(model); | ||||
| 
 | ||||
|         confirmClose = true; | ||||
| 
 | ||||
|         activeTab = TAB_FORM; | ||||
|         activeTab = tabFormKey; | ||||
| 
 | ||||
|         return recordPanel?.show(); | ||||
|     } | ||||
|  | @ -71,21 +79,73 @@ | |||
|     } | ||||
| 
 | ||||
|     async function load(model) { | ||||
|         isLoaded = false; | ||||
|         setErrors({}); // reset errors | ||||
|         original = model || new Record(); | ||||
|         if (model?.$clone) { | ||||
|             record = model.$clone(); | ||||
|         } else { | ||||
|             record = new Record(); | ||||
|         } | ||||
|         record = original.$clone(); | ||||
|         uploadedFilesMap = {}; | ||||
|         deletedFileIndexesMap = {}; | ||||
|         await tick(); // wait to populate the fields to get the normalized values | ||||
|         initialFormHash = calculateFormHash(record); | ||||
| 
 | ||||
|         // wait to populate the fields to get the normalized values | ||||
|         await tick(); | ||||
| 
 | ||||
|         initialDraft = getDraft(); | ||||
|         if (!initialDraft || areRecordsEqual(record, initialDraft)) { | ||||
|             initialDraft = null; | ||||
|         } | ||||
| 
 | ||||
|         originalSerializedData = JSON.stringify(record); | ||||
|         isLoaded = true; | ||||
|     } | ||||
| 
 | ||||
|     function calculateFormHash(m) { | ||||
|         return JSON.stringify(m); | ||||
|     function draftKey() { | ||||
|         return "record_draft_" + (collection?.id || "") + "_" + (original?.id || ""); | ||||
|     } | ||||
| 
 | ||||
|     function getDraft(fallbackRecord) { | ||||
|         try { | ||||
|             const raw = window.localStorage.getItem(draftKey()); | ||||
|             if (raw) { | ||||
|                 return new Record(JSON.parse(raw)); | ||||
|             } | ||||
|         } catch (_) {} | ||||
| 
 | ||||
|         return fallbackRecord; | ||||
|     } | ||||
| 
 | ||||
|     function updateDraft(newSerializedData) { | ||||
|         window.localStorage.setItem(draftKey(), newSerializedData); | ||||
|     } | ||||
| 
 | ||||
|     function restoreDraft() { | ||||
|         if (initialDraft) { | ||||
|             record = initialDraft; | ||||
|             initialDraft = null; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function areRecordsEqual(recordA, recordB) { | ||||
|         const cloneA = recordA?.$clone(); | ||||
|         const cloneB = recordB?.$clone(); | ||||
| 
 | ||||
|         const fileFields = collection?.schema?.filter((f) => f.type === "file"); | ||||
|         for (let field of fileFields) { | ||||
|             delete cloneA?.[field.name]; | ||||
|             delete cloneB?.[field.name]; | ||||
|         } | ||||
| 
 | ||||
|         // delete password props | ||||
|         delete cloneA?.password; | ||||
|         delete cloneA?.passwordConfirm; | ||||
|         delete cloneB?.password; | ||||
|         delete cloneB?.passwordConfirm; | ||||
| 
 | ||||
|         return JSON.stringify(cloneA) == JSON.stringify(cloneB); | ||||
|     } | ||||
| 
 | ||||
|     function deleteDraft() { | ||||
|         initialDraft = null; | ||||
|         window.localStorage.removeItem(draftKey()); | ||||
|     } | ||||
| 
 | ||||
|     function save() { | ||||
|  | @ -108,6 +168,7 @@ | |||
|             .then((result) => { | ||||
|                 addSuccessToast(isNew ? "Successfully created record." : "Successfully updated record."); | ||||
|                 confirmClose = false; | ||||
|                 deleteDraft(); | ||||
|                 hide(); | ||||
|                 dispatch("save", result); | ||||
|             }) | ||||
|  | @ -258,7 +319,7 @@ | |||
| 
 | ||||
|         await tick(); | ||||
| 
 | ||||
|         initialFormHash = ""; | ||||
|         originalSerializedData = ""; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
|  | @ -275,9 +336,13 @@ | |||
|                 confirmClose = false; | ||||
|                 hide(); | ||||
|             }); | ||||
| 
 | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         setErrors({}); | ||||
|         deleteDraft(); | ||||
| 
 | ||||
|         return true; | ||||
|     }} | ||||
|     on:hide | ||||
|  | @ -335,16 +400,16 @@ | |||
|                 <button | ||||
|                     type="button" | ||||
|                     class="tab-item" | ||||
|                     class:active={activeTab === TAB_FORM} | ||||
|                     on:click={() => (activeTab = TAB_FORM)} | ||||
|                     class:active={activeTab === tabFormKey} | ||||
|                     on:click={() => (activeTab = tabFormKey)} | ||||
|                 > | ||||
|                     Account | ||||
|                 </button> | ||||
|                 <button | ||||
|                     type="button" | ||||
|                     class="tab-item" | ||||
|                     class:active={activeTab === TAB_PROVIDERS} | ||||
|                     on:click={() => (activeTab = TAB_PROVIDERS)} | ||||
|                     class:active={activeTab === tabProviderKey} | ||||
|                     on:click={() => (activeTab = tabProviderKey)} | ||||
|                 > | ||||
|                     Authorized providers | ||||
|                 </button> | ||||
|  | @ -354,12 +419,41 @@ | |||
| 
 | ||||
|     <div class="tabs-content"> | ||||
|         <form | ||||
|             bind:this={recordForm} | ||||
|             id={formId} | ||||
|             class="tab-item" | ||||
|             class:active={activeTab === TAB_FORM} | ||||
|             class:active={activeTab === tabFormKey} | ||||
|             on:submit|preventDefault={save} | ||||
|         > | ||||
|             {#if !hasChanges && initialDraft} | ||||
|                 <div class="block" out:slide={{ duration: 150 }}> | ||||
|                     <div class="alert alert-info m-0"> | ||||
|                         <div class="icon"> | ||||
|                             <i class="ri-information-line" /> | ||||
|                         </div> | ||||
|                         <div class="content"> | ||||
|                             The record has previous unsaved changes. | ||||
|                             <button | ||||
|                                 type="button" | ||||
|                                 class="btn btn-sm btn-secondary" | ||||
|                                 on:click={() => restoreDraft()} | ||||
|                             > | ||||
|                                 Restore draft | ||||
|                             </button> | ||||
|                         </div> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="close" | ||||
|                             aria-label="Discard draft" | ||||
|                             use:tooltip={"Discard draft"} | ||||
|                             on:click|preventDefault={() => deleteDraft()} | ||||
|                         > | ||||
|                             <i class="ri-close-line" /> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                     <div class="clearfix p-b-base" /> | ||||
|                 </div> | ||||
|             {/if} | ||||
| 
 | ||||
|             <Field class="form-field {!isNew ? 'readonly' : ''}" name="id" let:uniqueId> | ||||
|                 <label for={uniqueId}> | ||||
|                     <i class={CommonHelper.getFieldTypeIcon("primary")} /> | ||||
|  | @ -429,7 +523,7 @@ | |||
|         </form> | ||||
| 
 | ||||
|         {#if collection.$isAuth && !isNew} | ||||
|             <div class="tab-item" class:active={activeTab === TAB_PROVIDERS}> | ||||
|             <div class="tab-item" class:active={activeTab === tabProviderKey}> | ||||
|                 <ExternalAuthsList {record} /> | ||||
|             </div> | ||||
|         {/if} | ||||
|  |  | |||
|  | @ -293,7 +293,6 @@ button { | |||
|             height: var(--loaderSize); | ||||
|             line-height: var(--loaderSize); | ||||
|             font-size: var(--loaderSize); | ||||
|             font-weight: normal; | ||||
|             color: inherit; | ||||
|             text-align: center; | ||||
|             font-weight: normal; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue