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"}
|
{:else if field.type === "relation"}
|
||||||
{@const relations = CommonHelper.toArray(rawValue)}
|
{@const relations = CommonHelper.toArray(rawValue)}
|
||||||
{@const expanded = CommonHelper.toArray(record.expand[field.name])}
|
{@const expanded = CommonHelper.toArray(record.expand[field.name])}
|
||||||
{@const relLimit = short ? 20 : 200}
|
{@const relLimit = short ? 20 : 500}
|
||||||
<div class="inline-flex">
|
<div class="inline-flex">
|
||||||
{#if expanded.length}
|
{#if expanded.length}
|
||||||
{#each expanded.slice(0, relLimit) as item, i (i + item)}
|
{#each expanded.slice(0, relLimit) as item, i (i + item)}
|
||||||
|
@ -103,7 +103,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if field.type === "file"}
|
{:else if field.type === "file"}
|
||||||
{@const files = CommonHelper.toArray(rawValue)}
|
{@const files = CommonHelper.toArray(rawValue)}
|
||||||
{@const filesLimit = short ? 10 : 200}
|
{@const filesLimit = short ? 10 : 500}
|
||||||
<div class="inline-flex">
|
<div class="inline-flex">
|
||||||
{#each files.slice(0, filesLimit) as filename, i (i + filename)}
|
{#each files.slice(0, filesLimit) as filename, i (i + filename)}
|
||||||
<RecordFileThumb {record} {filename} size="sm" />
|
<RecordFileThumb {record} {filename} size="sm" />
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher, tick } from "svelte";
|
import { createEventDispatcher, tick } from "svelte";
|
||||||
|
import { slide } from "svelte/transition";
|
||||||
import { Record } from "pocketbase";
|
import { Record } from "pocketbase";
|
||||||
import CommonHelper from "@/utils/CommonHelper";
|
import CommonHelper from "@/utils/CommonHelper";
|
||||||
import ApiClient from "@/utils/ApiClient";
|
import ApiClient from "@/utils/ApiClient";
|
||||||
|
@ -25,24 +26,25 @@
|
||||||
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
|
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const formId = "record_" + CommonHelper.randomString(5);
|
const formId = "record_" + CommonHelper.randomString(5);
|
||||||
const TAB_FORM = "form";
|
const tabFormKey = "form";
|
||||||
const TAB_PROVIDERS = "providers";
|
const tabProviderKey = "providers";
|
||||||
|
|
||||||
export let collection;
|
export let collection;
|
||||||
|
|
||||||
let recordPanel;
|
let recordPanel;
|
||||||
let recordForm;
|
|
||||||
let original = null;
|
let original = null;
|
||||||
let record = new Record();
|
let record = null;
|
||||||
|
let initialDraft = null;
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
let confirmClose = false; // prevent close recursion
|
let confirmClose = false; // prevent close recursion
|
||||||
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
|
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
|
||||||
let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...}
|
let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...}
|
||||||
let initialFormHash = "";
|
let originalSerializedData = JSON.stringify(null);
|
||||||
let activeTab = TAB_FORM;
|
let serializedData = originalSerializedData;
|
||||||
|
let activeTab = tabFormKey;
|
||||||
let isNew = true;
|
let isNew = true;
|
||||||
|
let isLoaded = false;
|
||||||
|
|
||||||
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
|
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
|
||||||
|
|
||||||
|
@ -50,18 +52,24 @@
|
||||||
CommonHelper.hasNonEmptyProps(uploadedFilesMap) ||
|
CommonHelper.hasNonEmptyProps(uploadedFilesMap) ||
|
||||||
CommonHelper.hasNonEmptyProps(deletedFileIndexesMap);
|
CommonHelper.hasNonEmptyProps(deletedFileIndexesMap);
|
||||||
|
|
||||||
$: hasChanges = hasFileChanges || initialFormHash != calculateFormHash(record);
|
$: serializedData = JSON.stringify(record);
|
||||||
|
|
||||||
|
$: hasChanges = hasFileChanges || originalSerializedData != serializedData;
|
||||||
|
|
||||||
$: isNew = !original || original.isNew;
|
$: isNew = !original || original.isNew;
|
||||||
|
|
||||||
$: canSave = isNew || hasChanges;
|
$: canSave = isNew || hasChanges;
|
||||||
|
|
||||||
|
$: if (isLoaded) {
|
||||||
|
updateDraft(serializedData);
|
||||||
|
}
|
||||||
|
|
||||||
export function show(model) {
|
export function show(model) {
|
||||||
load(model);
|
load(model);
|
||||||
|
|
||||||
confirmClose = true;
|
confirmClose = true;
|
||||||
|
|
||||||
activeTab = TAB_FORM;
|
activeTab = tabFormKey;
|
||||||
|
|
||||||
return recordPanel?.show();
|
return recordPanel?.show();
|
||||||
}
|
}
|
||||||
|
@ -71,21 +79,73 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(model) {
|
async function load(model) {
|
||||||
|
isLoaded = false;
|
||||||
setErrors({}); // reset errors
|
setErrors({}); // reset errors
|
||||||
original = model || new Record();
|
original = model || new Record();
|
||||||
if (model?.$clone) {
|
record = original.$clone();
|
||||||
record = model.$clone();
|
|
||||||
} else {
|
|
||||||
record = new Record();
|
|
||||||
}
|
|
||||||
uploadedFilesMap = {};
|
uploadedFilesMap = {};
|
||||||
deletedFileIndexesMap = {};
|
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) {
|
function draftKey() {
|
||||||
return JSON.stringify(m);
|
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() {
|
function save() {
|
||||||
|
@ -108,6 +168,7 @@
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
addSuccessToast(isNew ? "Successfully created record." : "Successfully updated record.");
|
addSuccessToast(isNew ? "Successfully created record." : "Successfully updated record.");
|
||||||
confirmClose = false;
|
confirmClose = false;
|
||||||
|
deleteDraft();
|
||||||
hide();
|
hide();
|
||||||
dispatch("save", result);
|
dispatch("save", result);
|
||||||
})
|
})
|
||||||
|
@ -258,7 +319,7 @@
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
initialFormHash = "";
|
originalSerializedData = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -275,9 +336,13 @@
|
||||||
confirmClose = false;
|
confirmClose = false;
|
||||||
hide();
|
hide();
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
deleteDraft();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
on:hide
|
on:hide
|
||||||
|
@ -335,16 +400,16 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
class:active={activeTab === TAB_FORM}
|
class:active={activeTab === tabFormKey}
|
||||||
on:click={() => (activeTab = TAB_FORM)}
|
on:click={() => (activeTab = tabFormKey)}
|
||||||
>
|
>
|
||||||
Account
|
Account
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
class:active={activeTab === TAB_PROVIDERS}
|
class:active={activeTab === tabProviderKey}
|
||||||
on:click={() => (activeTab = TAB_PROVIDERS)}
|
on:click={() => (activeTab = tabProviderKey)}
|
||||||
>
|
>
|
||||||
Authorized providers
|
Authorized providers
|
||||||
</button>
|
</button>
|
||||||
|
@ -354,12 +419,41 @@
|
||||||
|
|
||||||
<div class="tabs-content">
|
<div class="tabs-content">
|
||||||
<form
|
<form
|
||||||
bind:this={recordForm}
|
|
||||||
id={formId}
|
id={formId}
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
class:active={activeTab === TAB_FORM}
|
class:active={activeTab === tabFormKey}
|
||||||
on:submit|preventDefault={save}
|
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>
|
<Field class="form-field {!isNew ? 'readonly' : ''}" name="id" let:uniqueId>
|
||||||
<label for={uniqueId}>
|
<label for={uniqueId}>
|
||||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||||
|
@ -429,7 +523,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if collection.$isAuth && !isNew}
|
{#if collection.$isAuth && !isNew}
|
||||||
<div class="tab-item" class:active={activeTab === TAB_PROVIDERS}>
|
<div class="tab-item" class:active={activeTab === tabProviderKey}>
|
||||||
<ExternalAuthsList {record} />
|
<ExternalAuthsList {record} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -293,7 +293,6 @@ button {
|
||||||
height: var(--loaderSize);
|
height: var(--loaderSize);
|
||||||
line-height: var(--loaderSize);
|
line-height: var(--loaderSize);
|
||||||
font-size: var(--loaderSize);
|
font-size: var(--loaderSize);
|
||||||
font-weight: normal;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
Loading…
Reference in New Issue