pocketbase/ui/src/components/records/RecordUpsertPanel.svelte

774 lines
26 KiB
Svelte
Raw Normal View History

2022-07-07 05:19:05 +08:00
<script>
import { createEventDispatcher, tick } from "svelte";
import { slide } from "svelte/transition";
import { ClientResponseError } from "pocketbase";
2022-07-07 05:19:05 +08:00
import ApiClient from "@/utils/ApiClient";
2024-09-30 00:23:19 +08:00
import CommonHelper from "@/utils/CommonHelper";
2022-10-30 16:28:14 +08:00
import tooltip from "@/actions/tooltip";
2022-07-07 05:19:05 +08:00
import Field from "@/components/base/Field.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
2024-09-30 00:23:19 +08:00
import Toggler from "@/components/base/Toggler.svelte";
import AutodateIcon from "@/components/records/AutodateIcon.svelte";
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
2022-10-30 16:28:14 +08:00
import AuthFields from "@/components/records/fields/AuthFields.svelte";
2022-07-07 05:19:05 +08:00
import BoolField from "@/components/records/fields/BoolField.svelte";
import DateField from "@/components/records/fields/DateField.svelte";
2024-09-30 00:23:19 +08:00
import EditorField from "@/components/records/fields/EditorField.svelte";
import EmailField from "@/components/records/fields/EmailField.svelte";
2022-07-07 05:19:05 +08:00
import FileField from "@/components/records/fields/FileField.svelte";
2024-09-30 00:23:19 +08:00
import JsonField from "@/components/records/fields/JsonField.svelte";
import NumberField from "@/components/records/fields/NumberField.svelte";
import PasswordField from "@/components/records/fields/PasswordField.svelte";
2022-07-07 05:19:05 +08:00
import RelationField from "@/components/records/fields/RelationField.svelte";
2024-09-30 00:23:19 +08:00
import SelectField from "@/components/records/fields/SelectField.svelte";
import TextField from "@/components/records/fields/TextField.svelte";
import UrlField from "@/components/records/fields/UrlField.svelte";
import ImpersonatePopup from "@/components/records/ImpersonatePopup.svelte";
import { confirm } from "@/stores/confirmation";
import { setErrors } from "@/stores/errors";
import { addErrorToast, addSuccessToast } from "@/stores/toasts";
2022-07-07 05:19:05 +08:00
const dispatch = createEventDispatcher();
const formId = "record_" + CommonHelper.randomString(5);
const tabFormKey = "form";
const tabProviderKey = "providers";
2022-07-07 05:19:05 +08:00
export let collection;
let recordPanel;
2024-09-30 00:23:19 +08:00
let impersonatePopup;
let original = {};
let record = {};
let initialDraft = null;
2022-07-07 05:19:05 +08:00
let isSaving = false;
let confirmHide = false; // prevent close recursion
2022-07-07 05:19:05 +08:00
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
let deletedFileNamesMap = {}; // eg.: {"field1":[0, 1], ...}
let originalSerializedData = JSON.stringify(original);
let serializedData = originalSerializedData;
let activeTab = tabFormKey;
let isNew = true;
let isLoading = true;
let initialCollection = collection;
2024-09-30 00:23:19 +08:00
let regularFields = [];
2022-07-07 05:19:05 +08:00
2023-08-15 02:20:49 +08:00
$: isAuthCollection = collection?.type === "auth";
2024-09-30 00:23:19 +08:00
$: isSuperusersCollection = collection?.name === "_superusers";
$: hasEditorField = !!collection?.fields?.find((f) => f.type === "editor");
$: idField = collection?.fields?.find((f) => f.name === "id");
2022-07-07 05:19:05 +08:00
$: hasFileChanges =
CommonHelper.hasNonEmptyProps(uploadedFilesMap) || CommonHelper.hasNonEmptyProps(deletedFileNamesMap);
2022-07-07 05:19:05 +08:00
$: serializedData = JSON.stringify(record);
$: hasChanges = hasFileChanges || originalSerializedData != serializedData;
2022-07-07 05:19:05 +08:00
2023-08-15 02:20:49 +08:00
$: isNew = !original || !original.id;
$: canSave = !isLoading && (isNew || hasChanges);
2022-07-07 05:19:05 +08:00
$: if (!isLoading) {
updateDraft(serializedData);
}
$: if (collection && initialCollection?.id != collection?.id) {
onCollectionChange();
}
2024-09-30 00:23:19 +08:00
const baseSkipFieldNames = ["id"];
const authSkipFieldNames = baseSkipFieldNames.concat(
"email",
"emailVisibility",
"verified",
"tokenKey",
"password",
);
$: skipFieldNames = isAuthCollection ? authSkipFieldNames : baseSkipFieldNames;
$: regularFields =
collection?.fields?.filter((f) => !skipFieldNames.includes(f.name) && f.type != "autodate") || [];
2022-07-07 05:19:05 +08:00
export function show(model) {
load(model);
confirmHide = true;
2022-07-07 05:19:05 +08:00
activeTab = tabFormKey;
2022-10-30 16:28:14 +08:00
return recordPanel?.show();
2022-07-07 05:19:05 +08:00
}
export function hide() {
return recordPanel?.hide();
2022-07-07 05:19:05 +08:00
}
function forceHide() {
confirmHide = false;
hide();
}
function onCollectionChange() {
initialCollection = collection;
if (!recordPanel?.isActive()) {
return;
}
updateDraft(JSON.stringify(record));
forceHide();
}
async function resolveModel(model) {
if (model && typeof model === "string") {
// load from id
try {
return await ApiClient.collection(collection.id).getOne(model);
} catch (err) {
if (!err.isAbort) {
forceHide();
console.warn("resolveModel:", err);
addErrorToast(`Unable to load record with id "${model}"`);
}
}
return null;
}
return model;
}
async function load(model) {
isLoading = true;
// resets
setErrors({});
2022-07-07 05:19:05 +08:00
uploadedFilesMap = {};
deletedFileNamesMap = {};
// load the minimum model data if possible to minimize layout shifts
original =
typeof model === "string"
? { id: model, collectionId: collection?.id, collectionName: collection?.name }
: model || {};
record = structuredClone(original);
// resolve the complete model
original = (await resolveModel(model)) || {};
record = structuredClone(original);
// wait to populate the fields to get the normalized values
await tick();
initialDraft = getDraft();
if (!initialDraft || areRecordsEqual(record, initialDraft)) {
initialDraft = null;
2023-04-17 18:28:41 +08:00
} else {
delete initialDraft.password;
delete initialDraft.passwordConfirm;
}
originalSerializedData = JSON.stringify(record);
isLoading = false;
}
async function replaceOriginal(newOriginal) {
setErrors({}); // reset errors
2023-08-15 02:20:49 +08:00
original = newOriginal || {};
uploadedFilesMap = {};
deletedFileNamesMap = {};
2024-09-30 00:23:19 +08:00
// to avoid layout shifts we replace only the file and non-collection fields
const skipFields = collection?.fields?.filter((f) => f.type != "file")?.map((f) => f.name) || [];
2023-08-15 02:20:49 +08:00
for (let k in newOriginal) {
if (skipFields.includes(k)) {
continue;
}
record[k] = newOriginal[k];
}
// wait to populate the fields to get the normalized values
await tick();
originalSerializedData = JSON.stringify(record);
deleteDraft();
}
function draftKey() {
return "record_draft_" + (collection?.id || "") + "_" + (original?.id || "");
}
function getDraft(fallbackRecord) {
try {
const raw = window.localStorage.getItem(draftKey());
if (raw) {
return JSON.parse(raw);
}
} catch (_) {}
return fallbackRecord;
}
function updateDraft(newSerializedData) {
try {
window.localStorage.setItem(draftKey(), newSerializedData);
} catch (e) {
// ignore local storage errors in case the serialized data
// exceed the browser localStorage single value quota
console.warn("updateDraft failure:", e);
window.localStorage.removeItem(draftKey());
}
2022-07-07 05:19:05 +08:00
}
function restoreDraft() {
if (initialDraft) {
record = initialDraft;
initialDraft = null;
}
}
function areRecordsEqual(recordA, recordB) {
2023-08-15 02:20:49 +08:00
const cloneA = structuredClone(recordA || {});
const cloneB = structuredClone(recordB || {});
2024-09-30 00:23:19 +08:00
const fileFields = collection?.fields?.filter((f) => f.type === "file");
for (let field of fileFields) {
2023-08-15 02:20:49 +08:00
delete cloneA[field.name];
delete cloneB[field.name];
}
// props to exclude from the checks
const excludeProps = ["expand", "password", "passwordConfirm"];
for (let prop of excludeProps) {
delete cloneA[prop];
delete cloneB[prop];
}
return JSON.stringify(cloneA) == JSON.stringify(cloneB);
}
function deleteDraft() {
initialDraft = null;
window.localStorage.removeItem(draftKey());
2022-07-07 05:19:05 +08:00
}
async function save(hidePanel = true) {
2022-10-30 16:28:14 +08:00
if (isSaving || !canSave || !collection?.id) {
2022-07-07 05:19:05 +08:00
return;
}
isSaving = true;
try {
const data = exportFormData();
2022-07-07 05:19:05 +08:00
let result;
if (isNew) {
result = await ApiClient.collection(collection.id).create(data);
} else {
result = await ApiClient.collection(collection.id).update(record.id, data);
}
2022-07-07 05:19:05 +08:00
addSuccessToast(isNew ? "Successfully created record." : "Successfully updated record.");
deleteDraft();
2024-09-30 00:23:19 +08:00
// logout on password change of the current logged in user
if (
isSuperusersCollection &&
record?.id == ApiClient.authStore.record?.id &&
!!data.get("password")
) {
return ApiClient.logout();
}
if (hidePanel) {
forceHide();
} else {
replaceOriginal(result);
}
dispatch("save", {
isNew: isNew,
record: result,
});
} catch (err) {
ApiClient.error(err);
}
isSaving = false;
2022-07-07 05:19:05 +08:00
}
function deleteConfirm() {
if (!original?.id) {
return; // nothing to delete
}
confirm(`Do you really want to delete the selected record?`, () => {
2022-10-30 16:28:14 +08:00
return ApiClient.collection(original.collectionId)
.delete(original.id)
2022-07-07 05:19:05 +08:00
.then(() => {
2024-09-30 00:23:19 +08:00
forceHide();
2022-07-07 05:19:05 +08:00
addSuccessToast("Successfully deleted record.");
dispatch("delete", original);
})
.catch((err) => {
2023-05-14 03:10:14 +08:00
ApiClient.error(err);
2022-07-07 05:19:05 +08:00
});
});
}
function exportFormData() {
2023-08-15 02:20:49 +08:00
const data = structuredClone(record || {});
2022-07-07 05:19:05 +08:00
const formData = new FormData();
2024-09-30 00:23:19 +08:00
const exportableFields = {};
const jsonFields = {};
2024-09-30 00:23:19 +08:00
for (const field of collection?.fields || []) {
if (field.type == "autodate" || (isAuthCollection && field.type == "password")) {
continue;
}
2022-10-30 16:28:14 +08:00
exportableFields[field.name] = true;
if (field.type == "json") {
jsonFields[field.name] = true;
}
2022-10-30 16:28:14 +08:00
}
2024-09-30 00:23:19 +08:00
// export the auth password fields only if explicitly set
if (isAuthCollection && data["password"]) {
2022-10-30 16:28:14 +08:00
exportableFields["password"] = true;
2024-09-30 00:23:19 +08:00
}
if (isAuthCollection && data["passwordConfirm"]) {
2022-10-30 16:28:14 +08:00
exportableFields["passwordConfirm"] = true;
2022-07-07 05:19:05 +08:00
}
// export base fields
for (const key in data) {
2024-09-30 00:23:19 +08:00
// skip non-exportable fields
2022-10-30 16:28:14 +08:00
if (!exportableFields[key]) {
2022-07-07 05:19:05 +08:00
continue;
}
// normalize nullable values
if (typeof data[key] === "undefined") {
data[key] = null;
}
// "validate" json fields
if (jsonFields[key] && data[key] !== "") {
try {
JSON.parse(data[key]);
} catch (err) {
const fieldErr = {};
fieldErr[key] = {
code: "invalid_json",
message: err.toString(),
};
// emulate server error
throw new ClientResponseError({
status: 400,
response: {
data: fieldErr,
},
});
}
}
2022-07-07 05:19:05 +08:00
CommonHelper.addValueToFormData(formData, key, data[key]);
}
// add uploaded files (if any)
for (const key in uploadedFilesMap) {
const files = CommonHelper.toArray(uploadedFilesMap[key]);
for (const file of files) {
2024-09-30 00:23:19 +08:00
formData.append(key + "+", file);
2022-07-07 05:19:05 +08:00
}
}
// unset deleted files (if any)
for (const key in deletedFileNamesMap) {
const names = CommonHelper.toArray(deletedFileNamesMap[key]);
for (const name of names) {
2024-09-30 00:23:19 +08:00
formData.append(key + "-", name);
2022-07-07 05:19:05 +08:00
}
}
return formData;
}
2022-10-30 16:28:14 +08:00
function sendVerificationEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent verification email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestVerification(original.email)
.then(() => {
addSuccessToast(`Successfully sent verification email to ${original.email}.`);
})
.catch((err) => {
2023-05-14 03:10:14 +08:00
ApiClient.error(err);
2022-10-30 16:28:14 +08:00
});
});
}
function sendPasswordResetEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent password reset email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestPasswordReset(original.email)
.then(() => {
addSuccessToast(`Successfully sent password reset email to ${original.email}.`);
})
.catch((err) => {
2023-05-14 03:10:14 +08:00
ApiClient.error(err);
2022-10-30 16:28:14 +08:00
});
});
}
function duplicateConfirm() {
if (hasChanges) {
confirm("You have unsaved changes. Do you really want to discard them?", () => {
duplicate();
});
} else {
duplicate();
}
}
async function duplicate() {
2023-08-15 02:20:49 +08:00
let clone = original ? structuredClone(original) : null;
if (clone) {
// reset file fields
2024-09-30 00:23:19 +08:00
const resetTypes = ["file", "autodate"];
const fields = collection?.fields || [];
for (const field of fields) {
2024-09-30 00:23:19 +08:00
if (resetTypes.includes(field.type)) {
delete clone[field.name];
}
}
2024-09-30 00:23:19 +08:00
clone.id = "";
}
deleteDraft();
show(clone);
await tick();
originalSerializedData = "";
}
function handleFormKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS") {
e.preventDefault();
e.stopPropagation();
save(false);
}
}
2022-07-07 05:19:05 +08:00
</script>
<OverlayPanel
bind:this={recordPanel}
class="
record-panel
{hasEditorField ? 'overlay-panel-xl' : 'overlay-panel-lg'}
2024-09-30 00:23:19 +08:00
{isAuthCollection && !isSuperusersCollection && !isNew ? 'colored-header' : ''}
"
btnClose={!isLoading}
escClose={!isLoading}
overlayClose={!isLoading}
2022-07-07 05:19:05 +08:00
beforeHide={() => {
if (hasChanges && confirmHide) {
2022-07-07 05:19:05 +08:00
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
forceHide();
2022-07-07 05:19:05 +08:00
});
2022-07-07 05:19:05 +08:00
return false;
}
2022-10-30 16:28:14 +08:00
setErrors({});
deleteDraft();
2022-07-07 05:19:05 +08:00
return true;
}}
on:hide
on:show
>
<svelte:fragment slot="header">
{#if isLoading}
<span class="loader loader-sm" />
<h4 class="panel-title txt-hint">Loading...</h4>
{:else}
<h4 class="panel-title">
{isNew ? "New" : "Edit"}
<strong>{collection?.name}</strong> record
</h4>
2022-07-07 05:19:05 +08:00
{#if !isNew}
<div class="flex-fill" />
<div
tabindex="0"
role="button"
aria-label="More record options"
class="btn btn-sm btn-circle btn-transparent flex-gap-0"
>
<i class="ri-more-line" aria-hidden="true" />
<Toggler class="dropdown dropdown-right dropdown-nowrap">
{#if isAuthCollection && !original.verified && original.email}
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => sendVerificationEmail()}
>
2024-09-30 00:23:19 +08:00
<i class="ri-mail-check-line" aria-hidden="true" />
<span class="txt">Send verification email</span>
</button>
{/if}
{#if isAuthCollection && original.email}
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => sendPasswordResetEmail()}
>
2024-09-30 00:23:19 +08:00
<i class="ri-mail-lock-line" aria-hidden="true" />
<span class="txt">Send password reset email</span>
</button>
{/if}
2024-09-30 00:23:19 +08:00
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => impersonatePopup?.show()}
>
<i class="ri-id-card-line" aria-hidden="true" />
<span class="txt">Impersonate</span>
</button>
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => duplicateConfirm()}
>
2024-09-30 00:23:19 +08:00
<i class="ri-file-copy-line" aria-hidden="true" />
<span class="txt">Duplicate</span>
</button>
2022-10-30 16:28:14 +08:00
<button
type="button"
class="dropdown-item txt-danger closable"
role="menuitem"
on:click|preventDefault|stopPropagation={() => deleteConfirm()}
2022-10-30 16:28:14 +08:00
>
2024-09-30 00:23:19 +08:00
<i class="ri-delete-bin-7-line" aria-hidden="true" />
<span class="txt">Delete</span>
2022-10-30 16:28:14 +08:00
</button>
</Toggler>
</div>
{/if}
2022-07-07 05:19:05 +08:00
{/if}
2024-09-30 00:23:19 +08:00
{#if isAuthCollection && !isSuperusersCollection && !isNew}
2022-10-30 16:28:14 +08:00
<div class="tabs-header stretched">
<button
type="button"
class="tab-item"
class:active={activeTab === tabFormKey}
on:click={() => (activeTab = tabFormKey)}
2022-10-30 16:28:14 +08:00
>
Account
</button>
<button
type="button"
class="tab-item"
class:active={activeTab === tabProviderKey}
on:click={() => (activeTab = tabProviderKey)}
2022-10-30 16:28:14 +08:00
>
Authorized providers
</button>
</div>
2022-07-07 05:19:05 +08:00
{/if}
2022-10-30 16:28:14 +08:00
</svelte:fragment>
<div class="tabs-content no-animations">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
2022-10-30 16:28:14 +08:00
<form
id={formId}
class="tab-item"
class:no-pointer-events={isLoading}
class:active={activeTab === tabFormKey}
2022-10-30 16:28:14 +08:00
on:submit|preventDefault={save}
on:keydown={handleFormKeydown}
2022-10-30 16:28:14 +08:00
>
{#if !hasChanges && initialDraft && !isLoading}
<div class="block" out:slide={{ duration: 150 }}>
<div class="alert alert-info m-0">
<div class="icon">
<i class="ri-information-line" />
</div>
2023-05-19 18:48:58 +08:00
<div class="flex flex-gap-xs">
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}
2023-04-14 20:28:24 +08:00
<Field class="form-field {!isNew ? 'readonly' : ''}" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
<span class="flex-fill" />
</label>
{#if !isNew}
2022-10-30 16:28:14 +08:00
<div class="form-field-addon">
2024-09-30 00:23:19 +08:00
<AutodateIcon {record} />
2022-10-30 16:28:14 +08:00
</div>
{/if}
<input
type="text"
id={uniqueId}
2024-09-30 00:23:19 +08:00
placeholder={!isLoading && !CommonHelper.isEmpty(idField?.autogeneratePattern)
? "Leave empty to auto generate..."
: ""}
minlength={idField?.min}
readonly={!isNew}
2023-04-14 20:28:24 +08:00
bind:value={record.id}
/>
</Field>
2022-07-07 05:19:05 +08:00
2023-08-15 02:20:49 +08:00
{#if isAuthCollection}
2023-04-17 18:28:41 +08:00
<AuthFields bind:record {isNew} {collection} />
2024-09-30 00:23:19 +08:00
{#if regularFields.length}
<hr />
{/if}
2022-07-07 05:19:05 +08:00
{/if}
2022-10-30 16:28:14 +08:00
2024-09-30 00:23:19 +08:00
{#each regularFields as field (field.name)}
2022-10-30 16:28:14 +08:00
{#if field.type === "text"}
2024-09-30 00:23:19 +08:00
<TextField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "number"}
2024-09-30 00:23:19 +08:00
<NumberField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "bool"}
2024-09-30 00:23:19 +08:00
<BoolField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "email"}
2024-09-30 00:23:19 +08:00
<EmailField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "url"}
2024-09-30 00:23:19 +08:00
<UrlField {field} {original} {record} bind:value={record[field.name]} />
2023-01-17 19:31:48 +08:00
{:else if field.type === "editor"}
2024-09-30 00:23:19 +08:00
<EditorField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "date"}
2024-09-30 00:23:19 +08:00
<DateField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "select"}
2024-09-30 00:23:19 +08:00
<SelectField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "json"}
2024-09-30 00:23:19 +08:00
<JsonField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{:else if field.type === "file"}
<FileField
{field}
2024-09-30 00:23:19 +08:00
{original}
2022-10-30 16:28:14 +08:00
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileNames={deletedFileNamesMap[field.name]}
2022-10-30 16:28:14 +08:00
/>
{:else if field.type === "relation"}
2024-09-30 00:23:19 +08:00
<RelationField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "password"}
<PasswordField {field} {original} {record} bind:value={record[field.name]} />
2022-10-30 16:28:14 +08:00
{/if}
{/each}
</form>
2024-09-30 00:23:19 +08:00
{#if isAuthCollection && !isSuperusersCollection && !isNew}
<div class="tab-item" class:active={activeTab === tabProviderKey}>
2022-10-30 16:28:14 +08:00
<ExternalAuthsList {record} />
2022-07-07 05:19:05 +08:00
</div>
2022-10-30 16:28:14 +08:00
{/if}
</div>
2022-07-07 05:19:05 +08:00
<svelte:fragment slot="footer">
<button
type="button"
class="btn btn-transparent"
disabled={isSaving || isLoading}
on:click={() => hide()}
>
2022-07-07 05:19:05 +08:00
<span class="txt">Cancel</span>
</button>
2023-02-19 01:33:42 +08:00
2024-09-30 00:23:19 +08:00
<div class="btns-group no-gap">
<button
type="submit"
form={formId}
title="Save and close"
2024-10-08 21:22:49 +08:00
class="btn"
class:btn-expanded={isNew}
class:btn-expanded-sm={!isNew}
2024-09-30 00:23:19 +08:00
class:btn-loading={isSaving || isLoading}
disabled={!canSave || isSaving}
>
<span class="txt">{isNew ? "Create" : "Save changes"}</span>
</button>
{#if !isNew}
<button type="button" class="btn p-l-5 p-r-5 flex-gap-0" disabled={!canSave || isSaving}>
<i class="ri-arrow-down-s-line" aria-hidden="true"></i>
<Toggler class="dropdown dropdown-upside dropdown-right dropdown-nowrap m-b-5">
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => save(false)}
>
<span class="txt">Save and continue</span>
</button>
</Toggler>
</button>
{/if}
</div>
2022-07-07 05:19:05 +08:00
</svelte:fragment>
</OverlayPanel>
2024-09-30 00:23:19 +08:00
<ImpersonatePopup bind:this={impersonatePopup} {record} {collection} />
<style>
.panel-title {
line-height: var(--smBtnHeight);
}
</style>