added support for linking to the record preview/update form and some other minor improvements

This commit is contained in:
Gani Georgiev
2023-10-01 12:53:26 +03:00
parent ebf73f5602
commit 8908d03b8c
50 changed files with 407 additions and 165 deletions
+7 -1
View File
@@ -223,6 +223,7 @@
{#if active}
<div class="overlay-panel-container" class:padded={popup} class:active>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="overlay"
on:click|preventDefault={() => (overlayClose ? hide() : true)}
@@ -237,7 +238,12 @@
>
<div class="overlay-panel-section panel-header">
{#if btnClose && !popup}
<button type="button" class="overlay-close" on:click|preventDefault={hide}>
<button
type="button"
class="overlay-close"
transition:fade={{ duration: transitionSpeed }}
on:click|preventDefault={hide}
>
<i class="ri-close-line" />
</button>
{/if}
+1 -1
View File
@@ -145,7 +145,7 @@
}
:global(.scroller-wrapper .columns-dropdown) {
top: 40px;
z-index: 100;
z-index: 101;
max-height: 340px;
}
</style>
@@ -50,7 +50,12 @@
<i class="ri-close-line" />
</button>
</div>
<input type="text" placeholder="Search collections..." bind:value={searchTerm} />
<input
type="text"
placeholder="Search collections..."
name="collections-search"
bind:value={searchTerm}
/>
</div>
</header>
+67 -21
View File
@@ -1,5 +1,6 @@
<script>
import { replace, querystring } from "svelte-spa-router";
import { tick } from "svelte";
import { querystring } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import {
collections,
@@ -21,7 +22,7 @@
import RecordsList from "@/components/records/RecordsList.svelte";
import RecordsCount from "@/components/records/RecordsCount.svelte";
const queryParams = new URLSearchParams($querystring);
const initialQueryParams = new URLSearchParams($querystring);
let collectionUpsertPanel;
let collectionDocsPanel;
@@ -29,11 +30,13 @@
let recordPreviewPanel;
let recordsList;
let recordsCount;
let filter = queryParams.get("filter") || "";
let sort = queryParams.get("sort") || "-created";
let selectedCollectionId = queryParams.get("collectionId") || $activeCollection?.id;
let filter = initialQueryParams.get("filter") || "";
let sort = initialQueryParams.get("sort") || "-created";
let selectedCollectionId = initialQueryParams.get("collectionId") || $activeCollection?.id;
let totalCount = 0; // used to manully change the count without the need of reloading the recordsCount component
loadCollections(selectedCollectionId);
$: reactiveParams = new URLSearchParams($querystring);
$: if (
@@ -53,23 +56,32 @@
normalizeSort();
}
$: if (!$isCollectionsLoading && initialQueryParams.get("recordId")) {
showRecordById(initialQueryParams.get("recordId"));
}
// keep the url params in sync
$: if (sort || filter || $activeCollection?.id) {
const query = new URLSearchParams({
collectionId: $activeCollection?.id || "",
filter: filter,
sort: sort,
}).toString();
replace("/collections?" + query);
$: if (!$isCollectionsLoading && (sort || filter || $activeCollection?.id)) {
updateQueryParams();
}
$: $pageTitle = $activeCollection?.name || "Collections";
async function showRecordById(recordId) {
await tick(); // ensure that the reactive component params are resolved
$activeCollection?.type === "view"
? recordPreviewPanel.show(recordId)
: recordUpsertPanel?.show(recordId);
}
function reset() {
selectedCollectionId = $activeCollection?.id;
filter = "";
sort = "-created";
updateQueryParams({ recordId: null });
normalizeSort();
}
@@ -98,7 +110,18 @@
}
}
loadCollections(selectedCollectionId);
function updateQueryParams(extra = {}) {
const query = Object.assign(
{
collectionId: $activeCollection?.id || "",
filter: filter,
sort: sort,
},
extra
);
CommonHelper.replaceQueryParams(query);
}
</script>
{#if $isCollectionsLoading && !$collections.length}
@@ -188,9 +211,15 @@
bind:filter
bind:sort
on:select={(e) => {
updateQueryParams({
recordId: e.detail.id,
});
let showModel = e.detail._partial ? e.detail.id : e.detail;
$activeCollection.type === "view"
? recordPreviewPanel.show(e?.detail)
: recordUpsertPanel?.show(e?.detail);
? recordPreviewPanel?.show(showModel)
: recordUpsertPanel?.show(showModel);
}}
on:delete={() => {
recordsCount?.reload();
@@ -217,16 +246,33 @@
<RecordUpsertPanel
bind:this={recordUpsertPanel}
collection={$activeCollection}
on:hide={() => {
updateQueryParams({ recordId: null });
}}
on:save={(e) => {
recordsList?.reloadLoadedPages();
if (e.detail?.isNew) {
if (filter) {
// if there is applied filter, reload the count since we
// don't know after the save whether the record satisfies it
recordsCount?.reload();
} else if (e.detail.isNew) {
totalCount++;
}
}}
on:delete={() => {
recordsList?.reloadLoadedPages();
}}
on:delete={(e) => {
if (!filter || recordsList?.hasRecord(e.detail.id)) {
totalCount--;
}
recordsList?.reloadLoadedPages();
totalCount--;
}}
/>
<RecordPreviewPanel bind:this={recordPreviewPanel} collection={$activeCollection} />
<RecordPreviewPanel
bind:this={recordPreviewPanel}
collection={$activeCollection}
on:hide={() => {
updateQueryParams({ recordId: null });
}}
/>
@@ -1,25 +1,58 @@
<script>
import { addErrorToast } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import RecordFieldValue from "./RecordFieldValue.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import RecordFieldValue from "@/components/records/RecordFieldValue.svelte";
export let collection;
let recordPanel;
let record = {};
let isLoading = false;
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
export function show(model) {
record = model;
load(model);
return recordPanel?.show();
}
export function hide() {
isLoading = false;
return recordPanel?.hide();
}
async function load(model) {
record = {}; // reset
isLoading = true;
record = (await resolveModel(model)) || {};
isLoading = false;
}
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) {
hide();
console.warn("resolveModel:", err);
addErrorToast(`Unable to load record with id "${model}"`);
}
}
return null;
}
return model;
}
</script>
<OverlayPanel
@@ -32,14 +65,14 @@
<h4><strong>{collection?.name}</strong> record preview</h4>
</svelte:fragment>
<table class="table-border preview-table">
<table class="table-border preview-table" class:table-loading={isLoading}>
<tbody>
<tr>
<td class="min-width txt-hint txt-bold">id</td>
<td class="col-field">
<div class="label">
<CopyIcon value={record.id} />
<span class="txt">{record.id}</span>
<span class="txt">{record.id || "..."}</span>
</div>
</td>
</tr>
@@ -7,7 +7,7 @@
import tooltip from "@/actions/tooltip";
import { setErrors } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import { addSuccessToast, addErrorToast } from "@/stores/toasts";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import ModelDateIcon from "@/components/base/ModelDateIcon.svelte";
@@ -38,14 +38,14 @@
let record = null;
let initialDraft = null;
let isSaving = false;
let confirmClose = false; // prevent close recursion
let confirmHide = false; // prevent close recursion
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
let deletedFileNamesMap = {}; // eg.: {"field1":[0, 1], ...}
let originalSerializedData = JSON.stringify(null);
let originalSerializedData = JSON.stringify(original);
let serializedData = originalSerializedData;
let activeTab = tabFormKey;
let isNew = true;
let isLoaded = false;
let isLoading = true;
$: isAuthCollection = collection?.type === "auth";
@@ -60,16 +60,16 @@
$: isNew = !original || !original.id;
$: canSave = isNew || hasChanges;
$: canSave = !isLoading && (isNew || hasChanges);
$: if (isLoaded) {
$: if (!isLoading) {
updateDraft(serializedData);
}
export function show(model) {
load(model);
confirmClose = true;
confirmHide = true;
activeTab = tabFormKey;
@@ -80,14 +80,49 @@
return recordPanel?.hide();
}
function forceHide() {
confirmHide = false;
hide();
}
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) {
isLoaded = false;
setErrors({}); // reset errors
original = model || {};
record = structuredClone(original);
isLoading = true;
// resets
setErrors({});
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();
@@ -100,7 +135,8 @@
}
originalSerializedData = JSON.stringify(record);
isLoaded = true;
isLoading = false;
}
async function replaceOriginal(newOriginal) {
@@ -204,8 +240,7 @@
deleteDraft();
if (hidePanel) {
confirmClose = false;
hide();
forceHide();
} else {
replaceOriginal(result);
}
@@ -406,11 +441,13 @@
{hasEditorField ? 'overlay-panel-xl' : 'overlay-panel-lg'}
{isAuthCollection && !isNew ? 'colored-header' : ''}
"
btnClose={!isLoading}
escClose={!isLoading}
overlayClose={!isLoading}
beforeHide={() => {
if (hasChanges && confirmClose) {
if (hasChanges && confirmHide) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
confirmClose = false;
hide();
forceHide();
});
return false;
@@ -425,10 +462,15 @@
on:show
>
<svelte:fragment slot="header">
<h4 class="panel-title">
{isNew ? "New" : "Edit"}
<strong>{collection?.name}</strong> record
</h4>
{#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>
{/if}
{#if !isNew}
<div class="flex-fill" />
@@ -502,7 +544,7 @@
on:submit|preventDefault={save}
on:keydown={handleFormKeydown}
>
{#if !hasChanges && initialDraft}
{#if !hasChanges && initialDraft && !isLoading}
<div class="block" out:slide={{ duration: 150 }}>
<div class="alert alert-info m-0">
<div class="icon">
@@ -546,7 +588,7 @@
<input
type="text"
id={uniqueId}
placeholder="Leave empty to auto generate..."
placeholder={!isLoading ? "Leave empty to auto generate..." : ""}
minlength="15"
readonly={!isNew}
bind:value={record.id}
@@ -602,7 +644,12 @@
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<button
type="button"
class="btn btn-transparent"
disabled={isSaving || isLoading}
on:click={() => hide()}
>
<span class="txt">Cancel</span>
</button>
@@ -610,7 +657,7 @@
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSaving}
class:btn-loading={isSaving || isLoading}
disabled={!canSave || isSaving}
>
<span class="txt">{isNew ? "Create" : "Save changes"}</span>
+12 -2
View File
@@ -105,6 +105,10 @@
} catch (_) {}
}
export function hasRecord(id) {
return !!records.find((r) => r.id);
}
export async function reloadLoadedPages() {
const loadedPages = currentPage;
@@ -157,8 +161,7 @@
skipTotal: 1,
filter: CommonHelper.normalizeSearchFilter(filter, fallbackSearchFields),
expand: relFields.map((field) => field.name).join(","),
// @todo temp disable the :excerpt fields until individual RecordUpsert loader is implemented
// fields: listFields.join(","),
fields: listFields.join(","),
requestKey: "records_list",
})
.then(async (result) => {
@@ -171,6 +174,13 @@
lastTotal = result.items.length;
dispatch("load", records.concat(result.items));
// mark the records as "partial" because of the excerpt
if (editorFields.length) {
for (let record of result.items) {
record._partial = true;
}
}
// optimize the records listing by rendering the rows in task batches
if (breakTasks) {
const currentYieldId = ++yieldedRecordsId;
@@ -81,9 +81,11 @@
}
loadPromises.push(
ApiClient.collection(collectionId).getFullList(batchSize, {
ApiClient.collection(collectionId).getFullList({
batch: batchSize,
filter: filters.join("||"),
$autoCancel: false,
fields: "*:excerpt(200)",
requestKey: null,
})
);
}
@@ -140,8 +142,9 @@
const result = await ApiClient.collection(collectionId).getList(page, batchSize, {
filter: CommonHelper.normalizeSearchFilter(filter, fallbackSearchFields),
sort: !isView ? "-created" : "",
fields: "*:excerpt(200)",
skipTotal: 1,
$cancelKey: uniqueId + "loadList",
requestKey: uniqueId + "loadList",
});
list = CommonHelper.filterDuplicatesByKey(list.concat(result.items));
@@ -9,7 +9,7 @@
export let record;
export let collection;
export let isNew = !record.id;
export let isNew = !record?.id;
let originalUsername = record.username || null;
@@ -62,7 +62,7 @@
text: "Make email public or private",
position: "top-right",
}}
on:click={() => (record.emailVisibility = !record.emailVisibility)}
on:click|preventDefault={() => (record.emailVisibility = !record.emailVisibility)}
>
<span class="txt">Public: {record.emailVisibility ? "On" : "Off"}</span>
</button>
@@ -2,25 +2,49 @@
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import TinyMCE from "@tinymce/tinymce-svelte";
import { onMount } from "svelte";
export let field;
export let value = undefined;
let mounted = false;
let mountedTimeoutId = null;
$: conf = Object.assign(CommonHelper.defaultEditorOptions(), {
convert_urls: field.options?.convertUrls,
relative_urls: false,
});
// normalize value
// (depending on the editor plugins, `undefined` may throw an error in case the TinyMCE text functions are used)
$: if (typeof value == "undefined") {
value = "";
}
onMount(() => {
mountedTimeoutId = setTimeout(() => {
mounted = true;
}, 100);
return () => {
clearTimeout(mountedTimeoutId);
};
});
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<Field class="form-field form-field-editor {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<TinyMCE
id={uniqueId}
scriptSrc="{import.meta.env.BASE_URL}libs/tinymce/tinymce.min.js"
{conf}
bind:value
/>
{#if mounted}
<TinyMCE
id={uniqueId}
scriptSrc="{import.meta.env.BASE_URL}libs/tinymce/tinymce.min.js"
{conf}
bind:value
/>
{:else}
<div class="tinymce-wrapper" />
{/if}
</Field>
@@ -101,6 +101,7 @@
});
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="block"
on:dragover|preventDefault={() => {
@@ -74,7 +74,8 @@
loadPromises.push(
ApiClient.collection(field?.options?.collectionId).getFullList(batchSize, {
filter: filters.join("||"),
$autoCancel: false,
fields: "*:excerpt(200)",
requestKey: null,
})
);
}
+2
View File
@@ -28,9 +28,11 @@
@keyframes fadeIn {
0% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 1;
visibility: visible;
}
}
+4 -1
View File
@@ -670,7 +670,7 @@ a.thumb:not(.thumb-active) {
font-family: var(--iconFontFamily);
color: inherit;
text-align: center;
animation: loaderShow var(--baseAnimationSpeed),
animation: loaderShow var(--activeAnimationSpeed),
rotate 0.9s var(--baseAnimationSpeed) infinite linear;
}
@@ -855,6 +855,9 @@ a.thumb:not(.thumb-active) {
.entrance-right {
animation: entranceRight var(--entranceAnimationSpeed);
}
.entrance-fade {
animation: fadeIn var(--entranceAnimationSpeed);
}
.provider-logo {
$boxSize: 32px;
+3 -3
View File
@@ -59,14 +59,14 @@
.markers {
position: absolute;
z-index: 1;
left: 4px;
top: 4px;
left: 3px;
top: 3px;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 5px;
.marker {
$size: 4px;
$size: 5px;
display: block;
width: $size;
height: $size;
+1 -1
View File
@@ -271,7 +271,7 @@ table {
thead {
position: sticky;
top: 0;
z-index: 99;
z-index: 100;
transition: box-shadow var(--baseAnimationSpeed);
}
tbody {
+59
View File
@@ -1923,4 +1923,63 @@ export default class CommonHelper {
options: {},
}, data);
}
/**
* Extracts the query parameters from the current url.
*
* @return {Object}
*/
static getQueryParams() {
let query = "";
let url = window.location.href
const queryStart = url.indexOf("?");
if (queryStart > -1) {
query = url.substring(queryStart + 1);
url = url.substring(0, queryStart);
}
return Object.fromEntries(new URLSearchParams(query))
}
/**
* Replaces the current query parameters without triggering
* the router navigation.
*
* @param {Object} params
*/
static replaceQueryParams(params) {
params = params || {};
let query = "";
let url = window.location.href
const queryStart = url.indexOf("?");
if (queryStart > -1) {
query = url.substring(queryStart + 1);
url = url.substring(0, queryStart);
}
const parsed = new URLSearchParams(query)
for (let key in params) {
const val = params[key];
if (val === null) {
parsed.delete(key);
} else {
parsed.set(key, val);
}
}
query = parsed.toString();
if (query != "") {
url += ("?" + query);
}
window.location.replace(url);
}
}