[#976] added optional RelationOptions.DisplayFields and refactored the relation picker UI

This commit is contained in:
Gani Georgiev
2023-01-23 21:57:35 +02:00
parent 4c73e16f54
commit 4c010847e3
106 changed files with 1845 additions and 981 deletions
@@ -233,7 +233,7 @@
<svelte:fragment slot="footer">
{#if !admin.isNew}
<button type="button" class="btn btn-sm btn-circle btn-secondary">
<button type="button" class="btn btn-sm btn-circle btn-transparent">
<!-- empty span for alignment -->
<span />
<i class="ri-more-line" />
@@ -247,7 +247,7 @@
<div class="flex-fill" />
{/if}
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
@@ -86,6 +86,7 @@
extraAutocompleteKeys={["email"]}
on:submit={(e) => (filter = e.detail)}
/>
<div class="clearfix m-b-base" />
<HorizontalScroller class="table-wrapper">
<table class="table" class:table-loading={isLoading}>
+1 -1
View File
@@ -36,7 +36,7 @@
<button
autofocus
type="button"
class="btn btn-secondary btn-expanded-sm"
class="btn btn-transparent btn-expanded-sm"
disabled={isConfirmationBusy}
on:click={() => {
confirmed = false;
+78
View File
@@ -0,0 +1,78 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let index;
export let list = [];
export let disabled = false;
let dragging = false;
let dragover = false;
function onDrag(event, i) {
if (!event && !disabled) {
return;
}
dragging = true;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setData("text/plain", i);
}
function onDrop(event, target) {
if (!event && !disabled) {
return;
}
dragover = false;
dragging = false;
event.dataTransfer.dropEffect = "move";
const start = parseInt(event.dataTransfer.getData("text/plain"));
if (start < target) {
list.splice(target + 1, 0, list[start]);
list.splice(start, 1);
} else {
list.splice(target, 0, list[start]);
list.splice(start + 1, 1);
}
list = list;
dispatch("sort", list);
}
</script>
<div
draggable={true}
class="draggable"
class:dragging
class:dragover
on:dragover|preventDefault={() => {
dragover = true;
}}
on:dragleave|preventDefault={() => {
dragover = false;
}}
on:dragend={() => {
dragover = false;
dragging = false;
}}
on:dragstart={(e) => onDrag(e, index)}
on:drop={(e) => onDrop(e, index)}
>
<slot {dragging} {dragover} />
</div>
<style>
.draggable {
user-select: none;
outline: 0;
min-width: 0;
}
</style>
+1
View File
@@ -33,6 +33,7 @@
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div bind:this={container} class={classes} class:error={fieldErrors.length} on:click>
<slot {uniqueId} />
+17 -8
View File
@@ -23,7 +23,7 @@
* <h5 slot="header">My title</h5>
* <p>Lorem ipsum dolor sit amet...</p>
* <svelte:fragment slot="footer">
* <button class="btn btn-secondary">Cancel</button>
* <button class="btn btn-transparent">Cancel</button>
* <button class="btn btn-expanded">Save</button>
* </svelte:fragment>
* </OverlayPanel>
@@ -52,8 +52,11 @@
let transitionSpeed = 150;
let contentScrollThrottle;
let contentScrollClass = "";
let oldActive = active;
$: onActiveChange(active);
$: if (oldActive != active) {
onActiveChange(active);
}
$: handleContentScroll(contentPanel, true);
@@ -81,8 +84,10 @@
return active;
}
async function onActiveChange(state) {
if (state) {
async function onActiveChange(newState) {
oldActive = newState;
if (newState) {
oldFocusedElem = document.activeElement;
wrapper?.focus();
dispatch("show");
@@ -91,7 +96,10 @@
clearTimeout(contentScrollThrottle);
oldFocusedElem?.focus();
dispatch("hide");
document.body.classList.remove("overlay-active");
if (getHolder().querySelectorAll(".overlay-panel-container.active").length <= 1) {
document.body.classList.remove("overlay-active");
}
}
await tick();
@@ -194,6 +202,7 @@
<div bind:this={wrapper} class="overlay-panel-wrapper">
{#if active}
<div class="overlay-panel-container" class:padded={popup} class:active>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="overlay"
on:click|preventDefault={() => (overlayClose ? hide() : true)}
@@ -208,9 +217,9 @@
>
<div class="overlay-panel-section panel-header">
{#if btnClose && !popup}
<div class="overlay-close" on:click|preventDefault={hide}>
<button type="button" class="overlay-close" on:click|preventDefault={hide}>
<i class="ri-close-line" />
</div>
</button>
{/if}
<slot name="header" />
@@ -218,7 +227,7 @@
{#if btnClose && popup}
<button
type="button"
class="btn btn-sm btn-circle btn-secondary btn-close m-l-auto"
class="btn btn-sm btn-circle btn-transparent btn-close m-l-auto"
on:click|preventDefault={hide}
>
<i class="ri-close-line txt-lg" />
+1 -1
View File
@@ -51,6 +51,6 @@
<i class="ri-external-link-line" />
</a>
<div class="flex-fill" />
<button type="button" class="btn btn-secondary" on:click={hide}>Close</button>
<button type="button" class="btn btn-transparent" on:click={hide}>Close</button>
</svelte:fragment>
</OverlayPanel>
@@ -24,7 +24,7 @@
<div class="form-field-addon">
<button
type="button"
class="btn btn-secondary btn-circle"
class="btn btn-transparent btn-circle"
use:tooltip={{ position: "left", text: "Set new value" }}
on:click={() => unlock()}
>
+1 -1
View File
@@ -30,7 +30,7 @@
<button
type="button"
class="btn btn-secondary btn-circle"
class="btn btn-transparent btn-circle"
class:refreshing={refreshTimeoutId}
use:tooltip={tooltipData}
on:click={refresh}
+46 -48
View File
@@ -53,58 +53,56 @@
});
</script>
<div class="searchbar-wrapper">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<form class="searchbar" on:click|stopPropagation on:submit|preventDefault={submit}>
<label for={uniqueId} class="m-l-10 txt-xl">
<i class="ri-search-line" />
</label>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<form class="searchbar" on:click|stopPropagation on:submit|preventDefault={submit}>
<label for={uniqueId} class="m-l-10 txt-xl">
<i class="ri-search-line" />
</label>
{#if filterComponent && !isFilterComponentLoading}
<svelte:component
this={filterComponent}
id={uniqueId}
singleLine
disableRequestKeys
disableIndirectCollectionsKeys
{extraAutocompleteKeys}
baseCollection={autocompleteCollection}
placeholder={value || placeholder}
bind:value={tempValue}
on:submit={submit}
/>
{:else}
<input
bind:this={searchInput}
type="text"
id={uniqueId}
placeholder={value || placeholder}
bind:value={tempValue}
/>
{/if}
{#if value.length || tempValue.length}
{#if tempValue !== value}
<button
type="submit"
class="btn btn-expanded btn-sm btn-warning"
transition:fly|local={{ duration: 150, x: 5 }}
>
<span class="txt">Search</span>
</button>
{/if}
{#if filterComponent && !isFilterComponentLoading}
<svelte:component
this={filterComponent}
id={uniqueId}
singleLine
disableRequestKeys
disableIndirectCollectionsKeys
{extraAutocompleteKeys}
baseCollection={autocompleteCollection}
placeholder={value || placeholder}
bind:value={tempValue}
on:submit={submit}
/>
{:else}
<input
bind:this={searchInput}
type="text"
id={uniqueId}
placeholder={value || placeholder}
bind:value={tempValue}
/>
{/if}
{#if value.length || tempValue.length}
{#if tempValue !== value}
<button
type="button"
class="btn btn-secondary btn-sm btn-hint p-l-xs p-r-xs m-l-10"
type="submit"
class="btn btn-expanded btn-sm btn-warning"
transition:fly|local={{ duration: 150, x: 5 }}
on:click={() => {
clear(false);
submit();
}}
>
<span class="txt">Clear</span>
<span class="txt">Search</span>
</button>
{/if}
</form>
</div>
<button
type="button"
class="btn btn-transparent btn-sm btn-hint p-l-xs p-r-xs m-l-10"
transition:fly|local={{ duration: 150, x: 5 }}
on:click={() => {
clear(false);
submit();
}}
>
<span class="txt">Clear</span>
</button>
{/if}
</form>
+9 -4
View File
@@ -11,8 +11,9 @@
export let items = [];
export let multiple = false;
export let disabled = false;
export let closable = true;
export let selected = multiple ? [] : undefined;
export let toggle = false; // toggle option on click
export let toggle = multiple; // toggle option on click
export let labelComponent = undefined; // custom component to use for each selected option label
export let labelComponentProps = {}; // props to pass to the custom option component
export let optionComponent = undefined; // custom component to use for each dropdown option item
@@ -195,8 +196,9 @@
</script>
<div bind:this={container} class="select {classes}" class:multiple class:disabled>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div bind:this={labelDiv} tabindex={disabled ? "-1" : "0"} class="selected-container" class:disabled>
{#each CommonHelper.toArray(selected) as item}
{#each CommonHelper.toArray(selected) as item, i}
<div class="option">
{#if labelComponent}
<svelte:component this={labelComponent} {item} {...labelComponentProps} />
@@ -205,6 +207,7 @@
{/if}
{#if multiple || toggle}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="clear"
use:tooltip={"Clear"}
@@ -247,7 +250,7 @@
<div class="addon suffix p-r-5">
<button
type="button"
class="btn btn-sm btn-circle btn-secondary clear"
class="btn btn-sm btn-circle btn-transparent clear"
on:click|preventDefault|stopPropagation={resetSearch}
>
<i class="ri-close-line" />
@@ -262,9 +265,11 @@
<div class="options-list">
{#each filteredItems as item}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
tabindex="0"
class="dropdown-item option closable"
class="dropdown-item option"
class:closable
class:selected={isSelected(item)}
on:click={(e) => handleOptionSelect(e, item)}
on:keydown={(e) => handleOptionKeypress(e, item)}
@@ -154,7 +154,7 @@
<!-- visible only on small screens -->
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
<button type="button" class="btn btn-transparent" on:click={() => hide()}>
<span class="txt">Close</span>
</button>
</svelte:fragment>
@@ -1,8 +1,8 @@
<script>
import { SchemaField } from "pocketbase";
import { Collection, SchemaField } from "pocketbase";
import FieldAccordion from "@/components/collections/FieldAccordion.svelte";
export let collection = {};
export let collection = new Collection();
const baseReservedNames = [
"id",
@@ -36,8 +36,8 @@
reservedNames = baseReservedNames.slice(0);
}
$: if (typeof collection?.schema === "undefined") {
collection = collection || {};
$: if (typeof collection.schema === "undefined") {
collection = collection || new Collection();
collection.schema = [];
}
@@ -159,7 +159,7 @@
<button
type="button"
class="btn btn-block {collection?.isAuth || collection.schema?.length ? 'btn-secondary' : 'btn-warning'}"
class="btn btn-block {collection.schema.length ? 'btn-transparent' : 'btn-secondary'}"
on:click={newField}
>
<i class="ri-add-line" />
@@ -92,7 +92,7 @@
<svelte:fragment slot="footer">
<!-- svelte-ignore a11y-autofocus -->
<button autofocus type="button" class="btn btn-secondary" on:click={() => hide()}>
<button autofocus type="button" class="btn btn-transparent" on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button type="button" class="btn btn-expanded" on:click={() => confirm()}>
@@ -6,8 +6,8 @@
import ApiClient from "@/utils/ApiClient";
import { errors, setErrors, removeError } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import { addCollection, removeCollection } from "@/stores/collections";
import { removeAllToasts, addSuccessToast } from "@/stores/toasts";
import { loadCollections, removeCollection } from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
@@ -119,12 +119,16 @@
request
.then((result) => {
removeAllToasts();
loadCollections(result.id);
confirmClose = false;
hide();
addSuccessToast(
collection.isNew ? "Successfully created collection." : "Successfully updated collection."
);
addCollection(result);
dispatch("save", {
isNew: collection.isNew,
@@ -209,7 +213,7 @@
{#if !collection.isNew && !collection.system}
<div class="flex-fill" />
<button type="button" class="btn btn-sm btn-circle btn-secondary flex-gap-0">
<button type="button" class="btn btn-sm btn-circle btn-transparent flex-gap-0">
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right m-t-5">
<button
@@ -256,7 +260,7 @@
<div class="form-field-addon">
<button
type="button"
class="btn btn-sm p-r-10 p-l-10 {collection.isNew ? 'btn-hint' : 'btn-secondary'}"
class="btn btn-sm p-r-10 p-l-10 {collection.isNew ? 'btn-hint' : 'btn-transparent'}"
disabled={!collection.isNew}
>
<!-- empty span for alignment -->
@@ -362,7 +366,7 @@
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
@@ -2,7 +2,7 @@
import { link } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import { hideControls } from "@/stores/app";
import { collections, activeCollection } from "@/stores/collections";
import { collections, activeCollection, isCollectionsLoading } from "@/stores/collections";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
let collectionPanel;
@@ -43,7 +43,7 @@
<div class="form-field-addon">
<button
type="button"
class="btn btn-xs btn-secondary btn-circle btn-clear"
class="btn btn-xs btn-transparent btn-circle btn-clear"
class:hidden={!hasSearch}
on:click={() => (searchTerm = "")}
>
@@ -56,7 +56,11 @@
<hr class="m-t-5 m-b-xs" />
<div class="sidebar-content" class:sidebar-content-compact={filteredCollections.length > 20}>
<div
class="sidebar-content"
class:fade={$isCollectionsLoading}
class:sidebar-content-compact={filteredCollections.length > 20}
>
{#each filteredCollections as collection (collection.id)}
<a
href="/collections?collectionId={collection.id}"
@@ -183,7 +183,7 @@
{#if field.toDelete}
<button
type="button"
class="btn btn-sm btn-danger btn-secondary"
class="btn btn-sm btn-danger btn-transparent"
on:click|stopPropagation={() => {
field.toDelete = false;
}}
@@ -311,7 +311,7 @@
<div class="col-sm-4 txt-right">
<div class="flex-fill" />
<div class="inline-flex flex-gap-sm flex-nowrap">
<button type="button" class="btn btn-circle btn-sm btn-secondary">
<button type="button" class="btn btn-circle btn-sm btn-transparent">
<i class="ri-more-line" />
<Toggler
class="dropdown dropdown-sm dropdown-upside dropdown-right dropdown-nowrap no-min-width"
@@ -65,14 +65,18 @@
{#if isAdminOnly}
<button
type="button"
class="btn btn-sm btn-secondary btn-success lock-toggle"
class="btn btn-sm btn-transparent btn-success lock-toggle"
on:click={unlock}
>
<i class="ri-lock-unlock-line" />
<span class="txt">Set custom rule</span>
</button>
{:else}
<button type="button" class="btn btn-sm btn-secondary btn-hint lock-toggle" on:click={lock}>
<button
type="button"
class="btn btn-sm btn-transparent btn-hint lock-toggle"
on:click={lock}
>
<i class="ri-lock-line" />
<span class="txt">Set Admins only</span>
</button>
@@ -6,7 +6,7 @@
}
</script>
<button class="btn btn-sm btn-secondary m-t-5" on:click={toggle}>
<button class="btn btn-sm btn-transparent m-t-5" on:click={toggle}>
{#if expanded}
<span class="txt">Hide details</span>
<i class="ri-arrow-up-s-line" />
@@ -1,10 +1,11 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import { collections } from "@/stores/collections";
export let key = "";
export let options = {};
@@ -14,9 +15,13 @@
{ label: "True", value: true },
];
let isLoading = false;
let collections = [];
const baseFields = ["id", "created", "updated"];
const authFields = ["username", "email", "emailVisibility", "verified"];
let upsertPanel = null;
let displayFieldsList = [];
let oldCollectionId = null;
// load defaults
$: if (CommonHelper.isEmpty(options)) {
@@ -24,27 +29,39 @@
maxSelect: 1,
collectionId: null,
cascadeDelete: false,
displayFields: [],
};
}
$: selectedColection = collections.find((c) => c.id == options.collectionId) || null;
$: selectedColection = $collections.find((c) => c.id == options.collectionId) || null;
loadCollections();
$: if (oldCollectionId != options.collectionId) {
oldCollectionId = options.collectionId;
refreshDisplayFieldsList();
}
async function loadCollections() {
isLoading = true;
try {
const result = await ApiClient.collections.getFullList(200, {
sort: "created",
});
collections = CommonHelper.sortCollections(result);
} catch (err) {
ApiClient.errorResponseHandler(err);
function refreshDisplayFieldsList() {
displayFieldsList = baseFields.slice(0);
if (!selectedColection) {
return;
}
isLoading = false;
if (selectedColection.isAuth) {
displayFieldsList = displayFieldsList.concat(authFields);
}
for (const field of selectedColection.schema) {
displayFieldsList.push(field.name);
}
// deselect any missing display field
if (options?.displayFields?.length > 0) {
for (let i = options.displayFields.length - 1; i >= 0; i--) {
if (!displayFieldsList.includes(options.displayFields[i])) {
options.displayFields.splice(i, 1);
}
}
}
}
</script>
@@ -53,19 +70,21 @@
<Field class="form-field required" name="schema.{key}.options.collectionId" let:uniqueId>
<label for={uniqueId}>Collection</label>
<ObjectSelect
searchable={collections.length > 5}
selectPlaceholder={isLoading ? "Loading..." : "Select collection"}
searchable={$collections.length > 5}
selectPlaceholder={"Select collection"}
noOptionsText="No collections found"
selectionKey="id"
items={collections}
items={$collections}
bind:keyOfSelected={options.collectionId}
>
<svelte:fragment slot="afterOptions">
<hr />
<button
type="button"
class="btn btn-warning btn-block btn-sm m-t-5"
class="btn btn-transparent btn-block btn-sm"
on:click={() => upsertPanel?.show()}
>
<i class="ri-add-line" />
<span class="txt">New collection</span>
</button>
</svelte:fragment>
@@ -87,11 +106,30 @@
<input type="number" id={uniqueId} step="1" min="1" bind:value={options.maxSelect} />
</Field>
</div>
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
<div class="col-sm-9">
<Field class="form-field" name="schema.{key}.options.displayFields" let:uniqueId>
<label for={uniqueId}>
Delete record on {selectedColection ? selectedColection.name : "relation"} delete
<span class="txt">Display fields</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Optional select the field(s) that will be used in the listings UI. Leave empty for auto.",
position: "top",
}}
/>
</label>
<Select
multiple
searchable
id={uniqueId}
items={displayFieldsList}
bind:selected={options.displayFields}
/>
</Field>
</div>
<div class="col-sm-3">
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
<label for={uniqueId}>Cascade delete</label>
<ObjectSelect id={uniqueId} items={defaultOptions} bind:keyOfSelected={options.cascadeDelete} />
</Field>
</div>
@@ -103,6 +141,5 @@
if (e?.detail?.collection?.id) {
options.collectionId = e.detail.collection.id;
}
loadCollections();
}}
/>
+1 -1
View File
@@ -92,7 +92,7 @@
</table>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
<button type="button" class="btn btn-transparent" on:click={() => hide()}>
<span class="txt">Close</span>
</button>
</svelte:fragment>
+1 -1
View File
@@ -221,7 +221,7 @@
<div class="block txt-center m-t-xs">
<button
type="button"
class="btn btn-lg btn-secondary btn-expanded"
class="btn btn-lg btn-transparent btn-expanded"
class:btn-loading={isLoading}
class:btn-disabled={isLoading}
on:click={() => load(currentPage + 1)}
+1 -1
View File
@@ -63,7 +63,7 @@
on:submit={(e) => (filter = e.detail)}
/>
<div class="clearfix m-b-xs" />
<div class="clearfix m-b-base" />
{#key refreshToken}
<LogsChart bind:filter {presets} />
@@ -74,7 +74,7 @@
<div class="txt-hint">ID: {auth.providerId}</div>
<button
type="button"
class="btn btn-secondary link-hint btn-circle btn-sm m-l-auto"
class="btn btn-transparent link-hint btn-circle btn-sm m-l-auto"
on:click={() => unlinkExternalAuth(auth.provider)}
>
<i class="ri-close-line" />
@@ -45,7 +45,7 @@
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
@@ -48,7 +48,7 @@
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
@@ -42,7 +42,7 @@
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
@@ -53,7 +53,7 @@
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{/if}
+6 -4
View File
@@ -28,13 +28,13 @@
let recordsList;
let filter = queryParams.get("filter") || "";
let sort = queryParams.get("sort") || "-created";
let selectedCollectionId = queryParams.get("collectionId") || "";
let selectedCollectionId = queryParams.get("collectionId") || $activeCollection?.id;
$: reactiveParams = new URLSearchParams($querystring);
$: if (
!$isCollectionsLoading &&
reactiveParams.has("collectionId") &&
reactiveParams.get("collectionId") &&
reactiveParams.get("collectionId") != selectedCollectionId
) {
changeActiveCollectionById(reactiveParams.get("collectionId"));
@@ -64,7 +64,7 @@
loadCollections(selectedCollectionId);
</script>
{#if $isCollectionsLoading}
{#if $isCollectionsLoading && !$collections.length}
<PageWrapper center>
<div class="placeholder-section m-b-base">
<span class="loader loader-lg" />
@@ -106,7 +106,7 @@
{#if !$hideControls}
<button
type="button"
class="btn btn-secondary btn-circle"
class="btn btn-transparent btn-circle"
use:tooltip={{ text: "Edit collection", position: "right" }}
on:click={() => collectionUpsertPanel?.show($activeCollection)}
>
@@ -139,6 +139,7 @@
autocompleteCollection={$activeCollection}
on:submit={(e) => (filter = e.detail)}
/>
<div class="clearfix m-b-base" />
<RecordsList
bind:this={recordsList}
@@ -146,6 +147,7 @@
bind:filter
bind:sort
on:select={(e) => recordPanel?.show(e?.detail)}
on:new={() => recordPanel?.show()}
/>
</PageWrapper>
{/if}
@@ -1,18 +1,13 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import IdLabel from "@/components/base/IdLabel.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import IdLabel from "@/components/base/IdLabel.svelte";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
export let record;
export let field;
// rough text cut to avoid rendering large chunk of texts
function cutText(text) {
text = text || "";
return text.length > 150 ? text.substring(0, 150) : text;
}
</script>
<td class="col-type-{field.type} col-field-{field.name}">
@@ -31,17 +26,17 @@
use:tooltip={"Open in new tab"}
on:click|stopPropagation
>
{cutText(record[field.name])}
{CommonHelper.truncate(record[field.name])}
</a>
{:else if field.type === "editor"}
<span class="txt txt-ellipsis">
{cutText(CommonHelper.plainText(record[field.name]))}
<span class="txt">
{CommonHelper.truncate(CommonHelper.plainText(record[field.name]), 300, true)}
</span>
{:else if field.type === "date"}
<FormattedDate date={record[field.name]} />
{:else if field.type === "json"}
<span class="txt txt-ellipsis">
{cutText(JSON.stringify(record[field.name]))}
{CommonHelper.truncate(JSON.stringify(record[field.name]))}
</span>
{:else if field.type === "select"}
<div class="inline-flex">
@@ -50,11 +45,21 @@
{/each}
</div>
{:else if field.type === "relation" || field.type === "user"}
{@const relations = CommonHelper.toArray(record[field.name])}
{@const expanded = CommonHelper.toArray(record.expand[field.name])}
<div class="inline-flex">
{#each CommonHelper.toArray(record[field.name]).slice(0, 20) as item, i (i + item)}
<IdLabel id={item} />
{/each}
{#if CommonHelper.toArray(record[field.name]).length > 20}
{#if expanded.length}
{#each expanded.slice(0, 20) as item, i (i + item)}
<span class="label">
<RecordInfo record={item} displayFields={field.options?.displayFields} />
</span>
{/each}
{:else}
{#each relations.slice(0, 20) as item, i (i + item)}
<IdLabel id={item} />
{/each}
{/if}
{#if relations.length > 20}
...
{/if}
</div>
@@ -65,8 +70,8 @@
{/each}
</div>
{:else}
<span class="txt txt-ellipsis" title={cutText(record[field.name])}>
{cutText(record[field.name])}
<span class="txt txt-ellipsis" title={CommonHelper.truncate(record[field.name])}>
{CommonHelper.truncate(record[field.name])}
</span>
{/if}
</td>
@@ -0,0 +1,36 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
export let record;
export let displayFields = [];
$: displayValue = CommonHelper.displayValue(record, displayFields);
</script>
<div class="record-info">
<i
class="link-hint txt-sm ri-information-line"
use:tooltip={{
text: CommonHelper.truncate(
JSON.stringify(CommonHelper.truncateObject(record), null, 2),
800,
true
),
class: "code",
position: "left",
}}
/>
<span class="txt txt-ellipsis">{CommonHelper.truncate(displayValue, 150)}</span>
</div>
<style lang="scss">
.record-info {
display: inline-flex;
vertical-align: top;
align-items: center;
max-width: 100%;
min-width: 0;
gap: 5px;
}
</style>
@@ -1,192 +0,0 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import RecordSelectOption from "./RecordSelectOption.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
const uniqueId = "select_" + CommonHelper.randomString(5);
// original select props
export let multiple = false;
export let selected = [];
export let keyOfSelected = multiple ? [] : undefined;
export let selectPlaceholder = "- Select -";
export let optionComponent = RecordSelectOption; // custom component to use for each dropdown option item
// custom props
export let collectionId;
let list = [];
let currentPage = 1;
let totalItems = 0;
let isLoadingList = false;
let isLoadingSelected = false;
let isLoadingCollection = false;
let collection = null;
let upsertPanel;
$: if (collectionId) {
loadCollection();
loadSelected().then(() => {
loadList(true);
});
}
$: isLoading = isLoadingList || isLoadingSelected;
$: canLoadMore = totalItems > list.length;
async function loadCollection() {
if (!collectionId) {
collection = null;
isLoadingCollection = false;
return;
}
isLoadingCollection = true;
try {
collection = await ApiClient.collections.getOne(collectionId, {
$cancelKey: "collection_" + uniqueId,
});
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingCollection = false;
}
async function loadSelected() {
const selectedIds = CommonHelper.toArray(keyOfSelected);
if (!collectionId || !selectedIds.length) {
return;
}
isLoadingSelected = true;
let loadedItems = [];
// batch load all selected records to avoid parser stack overflow errors
const filterIds = selectedIds.slice();
const loadPromises = [];
while (filterIds.length > 0) {
const filters = [];
for (const id of filterIds.splice(0, 50)) {
filters.push(`id="${id}"`);
}
loadPromises.push(
ApiClient.collection(collectionId).getFullList(200, {
filter: filters.join("||"),
$autoCancel: false,
})
);
}
try {
await Promise.all(loadPromises).then((values) => {
loadedItems = loadedItems.concat(...values);
});
// preserve selected order
selected = [];
for (const id of selectedIds) {
const item = CommonHelper.findByKey(loadedItems, "id", id);
if (item) {
selected.push(item);
}
}
// add the selected models to the list (if not already)
list = CommonHelper.filterDuplicatesByKey(selected.concat(list));
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingSelected = false;
}
async function loadList(reset = false) {
if (!collectionId) {
return;
}
isLoadingList = true;
try {
const page = reset ? 1 : currentPage + 1;
const result = await ApiClient.collection(collectionId).getList(page, 200, {
sort: "-created",
$cancelKey: uniqueId + "loadList",
});
if (reset) {
list = CommonHelper.toArray(selected).slice();
}
list = CommonHelper.filterDuplicatesByKey(
list.concat(result.items, CommonHelper.toArray(selected))
);
currentPage = result.page;
totalItems = result.totalItems;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingList = false;
}
</script>
<ObjectSelect
selectPlaceholder={isLoading ? "Loading..." : selectPlaceholder}
items={list}
searchable={list.length > 5}
selectionKey="id"
labelComponent={optionComponent}
disabled={isLoading}
{optionComponent}
{multiple}
bind:keyOfSelected
bind:selected
on:show
on:hide
class="records-select block-options"
{...$$restProps}
>
<svelte:fragment slot="afterOptions">
{#if !isLoadingCollection && collection}
<button
type="button"
class="btn btn-warning btn-block btn-sm m-t-5"
on:click={() => upsertPanel?.show()}
>
<span class="txt">New record</span>
</button>
{/if}
{#if canLoadMore}
<button
type="button"
class="btn btn-block btn-sm m-t-5"
class:btn-loading={isLoadingList}
class:btn-disabled={isLoadingList}
on:click|stopPropagation={() => loadList()}
>
<span class="txt">Load more</span>
</button>
{/if}
</svelte:fragment>
</ObjectSelect>
<RecordUpsertPanel
bind:this={upsertPanel}
{collection}
on:save={(e) => {
if (e?.detail?.id) {
keyOfSelected = CommonHelper.toArray(keyOfSelected).concat(e.detail.id);
}
loadList(true);
}}
/>
@@ -1,62 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
const excludedMetaProps = ["id", "created", "updated", "collectionId", "collectionName"];
export let item = {}; // model
$: meta = extractMeta(item);
function extractMeta(model) {
model = model || {};
const props = [
// prioritized common displayable props
"title",
"name",
"email",
"username",
"label",
"key",
"heading",
"content",
"description",
// fallback to the available props
...Object.keys(model),
];
for (const prop of props) {
if (
typeof model[prop] === "string" &&
!CommonHelper.isEmpty(model[prop]) &&
!excludedMetaProps.includes(prop)
) {
return prop + ": " + model[prop];
}
}
return "";
}
</script>
<i
class="ri-information-line link-hint"
use:tooltip={{ text: JSON.stringify(item, null, 2), position: "left", class: "code" }}
/>
<div class="content">
<div class="block txt-ellipsis">{item.id}</div>
{#if meta !== "" && meta !== item.id}
<small class="block txt-hint txt-ellipsis">{meta}</small>
{/if}
</div>
<style>
.content {
flex-shrink: 1;
flex-grow: 0;
width: auto;
min-width: 0;
}
</style>
@@ -246,7 +246,7 @@
{#if !record.isNew}
<div class="flex-fill" />
<button type="button" class="btn btn-sm btn-circle btn-secondary">
<button type="button" class="btn btn-sm btn-circle btn-transparent">
<div class="content">
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right dropdown-nowrap">
@@ -381,7 +381,7 @@
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
+35 -9
View File
@@ -15,11 +15,13 @@
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
const dispatch = createEventDispatcher();
const sortRegex = /^([\+\-])?(\w+)$/;
export let collection;
export let sort = "";
export let filter = "";
let recordPanel;
let records = [];
let currentPage = 1;
let totalRecords = 0;
@@ -31,6 +33,12 @@
let hiddenColumns = [];
let collumnsToHide = [];
$: fields = collection?.schema || [];
$: relFields = fields.filter((field) => field.type === "relation");
$: visibleFields = fields.filter((field) => !hiddenColumns.includes(field.id));
$: if (collection?.id) {
loadStoredHiddenColumns();
clearList();
@@ -42,10 +50,6 @@
$: canLoadMore = totalRecords > records.length;
$: fields = collection?.schema || [];
$: visibleFields = fields.filter((field) => !hiddenColumns.includes(field.id));
$: totalBulkSelected = Object.keys(bulkSelected).length;
$: areAllRecordsSelected = records.length && totalBulkSelected === records.length;
@@ -108,10 +112,23 @@
isLoading = true;
// allow sorting by the relation display fields
let listSort = sort;
const sortMatch = listSort.match(sortRegex);
const relField = sortMatch ? relFields.find((f) => f.name === sortMatch[2]) : null;
if (sortMatch && relField?.options?.displayFields?.length > 0) {
const parts = [];
for (const displayField of relField.options.displayFields) {
parts.push((sortMatch[1] || "") + sortMatch[2] + "." + displayField);
}
listSort = parts.join(",");
}
return ApiClient.collection(collection.id)
.getList(page, 30, {
sort: sort,
sort: listSort,
filter: filter,
expand: relFields.map((field) => field.name).join(","),
})
.then(async (result) => {
if (page <= 1) {
@@ -329,7 +346,7 @@
{/if}
<th class="col-type-action min-width">
<button bind:this={columnsTrigger} type="button" class="btn btn-sm btn-secondary p-0">
<button bind:this={columnsTrigger} type="button" class="btn btn-sm btn-transparent p-0">
<i class="ri-more-line" />
</button>
</th>
@@ -447,6 +464,15 @@
>
<span class="txt">Clear filters</span>
</button>
{:else}
<button
type="button"
class="btn btn-secondary btn-expanded m-t-sm"
on:click={() => dispatch("new")}
>
<i class="ri-add-line" />
<span class="txt">New record</span>
</button>
{/if}
</td>
</tr>
@@ -464,7 +490,7 @@
<div class="block txt-center m-t-xs">
<button
type="button"
class="btn btn-lg btn-secondary btn-expanded"
class="btn btn-lg btn-transparent btn-expanded"
class:btn-loading={isLoading}
class:btn-disabled={isLoading}
on:click={() => load(currentPage + 1)}
@@ -482,7 +508,7 @@
</div>
<button
type="button"
class="btn btn-xs btn-secondary btn-outline p-l-5 p-r-5"
class="btn btn-xs btn-transparent btn-outline p-l-5 p-r-5"
class:btn-disabled={isDeleting}
on:click={() => deselectAllRecords()}
>
@@ -491,7 +517,7 @@
<div class="flex-fill" />
<button
type="button"
class="btn btn-sm btn-secondary btn-danger"
class="btn btn-sm btn-transparent btn-danger"
class:btn-loading={isDeleting}
class:btn-disabled={isDeleting}
on:click={() => deleteSelectedConfirm()}
@@ -0,0 +1,344 @@
<script>
import { createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import scrollend from "@/actions/scrollend";
import tooltip from "@/actions/tooltip";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import Draggable from "@/components/base/Draggable.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import { collections } from "@/stores/collections";
const dispatch = createEventDispatcher();
const uniqueId = "picker_" + CommonHelper.randomString(5);
const batchSize = 100;
export let value;
export let field;
let pickerPanel;
let upsertPanel;
let filter = "";
let list = [];
let selected = [];
let currentPage = 1;
let totalItems = 0;
let isLoadingList = false;
let isLoadingSelected = false;
$: maxSelect = field?.options?.maxSelect || null;
$: collectionId = field?.options?.collectionId;
$: displayFields = field?.options?.displayFields;
$: collection = $collections.find((c) => c.id == collectionId) || null;
$: if (typeof filter !== "undefined" && !isLoadingSelected && pickerPanel?.isActive()) {
loadList(true); // reset list on filter or list change
}
$: isLoading = isLoadingList || isLoadingSelected;
$: canLoadMore = totalItems > list.length;
$: canSelectMore = maxSelect === null || maxSelect > selected.length;
export function show() {
filter = "";
list = [];
selected = [];
loadSelected();
loadList(true);
return pickerPanel?.show();
}
export function hide() {
return pickerPanel?.hide();
}
async function loadSelected() {
const selectedIds = CommonHelper.toArray(value);
if (!collectionId || !selectedIds.length) {
return;
}
isLoadingSelected = true;
let loadedItems = [];
// batch load all selected records to avoid parser stack overflow errors
const filterIds = selectedIds.slice();
const loadPromises = [];
while (filterIds.length > 0) {
const filters = [];
for (const id of filterIds.splice(0, batchSize)) {
filters.push(`id="${id}"`);
}
loadPromises.push(
ApiClient.collection(collectionId).getFullList(batchSize, {
filter: filters.join("||"),
$autoCancel: false,
})
);
}
try {
await Promise.all(loadPromises).then((values) => {
loadedItems = loadedItems.concat(...values);
});
// preserve selected order
selected = [];
for (const id of selectedIds) {
const item = CommonHelper.findByKey(loadedItems, "id", id);
if (item) {
selected.push(item);
}
}
if (!filter.trim()) {
// add the selected models to the list (if not already)
list = CommonHelper.filterDuplicatesByKey(selected.concat(list));
}
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingSelected = false;
}
async function loadList(reset = false) {
if (!collectionId) {
return;
}
isLoadingList = true;
if (reset) {
if (!filter.trim()) {
// prepend the loaded selected items
list = CommonHelper.toArray(selected).slice();
} else {
list = [];
}
}
try {
const page = reset ? 1 : currentPage + 1;
const result = await ApiClient.collection(collectionId).getList(page, batchSize, {
filter: filter,
sort: "-created",
$cancelKey: uniqueId + "loadList",
});
list = CommonHelper.filterDuplicatesByKey(list.concat(result.items));
currentPage = result.page;
totalItems = result.totalItems;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoadingList = false;
}
$: isSelected = function (record) {
return CommonHelper.findByKey(selected, "id", record.id);
};
function select(record) {
if (maxSelect == 1) {
selected = [record];
} else if (canSelectMore) {
CommonHelper.pushUnique(selected, record);
selected = selected;
}
}
function deselect(record) {
CommonHelper.removeByKey(selected, "id", record.id);
selected = selected;
}
function toggle(record) {
if (isSelected(record)) {
deselect(record);
} else {
select(record);
}
}
function save() {
if (maxSelect != 1) {
value = selected.map((r) => r.id);
} else {
value = selected?.[0]?.id || "";
}
dispatch("save", selected);
hide();
}
</script>
<OverlayPanel bind:this={pickerPanel} popup class="overlay-panel-xl" on:hide on:show {...$$restProps}>
<svelte:fragment slot="header">
<h4>
Select <strong>{collection?.name || ""}</strong> records
</h4>
</svelte:fragment>
<div class="flex m-b-base flex-gap-10">
<Searchbar
value={filter}
autocompleteCollection={collection}
on:submit={(e) => (filter = e.detail)}
/>
<button
type="button"
class="btn btn-transparent btn-hint p-l-sm p-r-sm"
on:click={() => upsertPanel?.show()}
>
<div class="txt">New record</div>
</button>
</div>
<div
class="list picker-list m-b-base"
use:scrollend={() => {
if (canLoadMore && !isLoadingList) {
loadList();
}
}}
>
{#each list as record (record.id)}
{@const selected = isSelected(record)}
<div
tabindex="0"
class="list-item handle"
class:selected
class:disabled={!selected && maxSelect > 1 && !canSelectMore}
on:click={() => toggle(record)}
on:keydown={(e) => {
if (e.code === "Enter" || e.code === "Space") {
e.preventDefault();
e.stopPropagation();
toggle(record);
}
}}
>
{#if selected}
<i class="ri-checkbox-circle-fill txt-success" />
{:else}
<i class="ri-checkbox-blank-circle-line txt-disabled" />
{/if}
<div class="content">
<RecordInfo {record} {displayFields} />
</div>
<div class="actions nonintrusive">
<button
type="button"
class="btn btn-sm btn-circle btn-transparent btn-hint m-l-auto"
use:tooltip={"Edit"}
on:keydown|stopPropagation
on:click|stopPropagation={() => upsertPanel?.show(record)}
>
<i class="ri-pencil-line" />
</button>
</div>
</div>
{:else}
<div class="list-item">
{#if isLoading}
<div class="block txt-center">
<span class="loader loader-sm active" />
</div>
{:else}
<span class="txt txt-hint">No records found.</span>
{#if filter?.length}
<button type="button" class="btn btn-hint btn-sm" on:click={() => (filter = "")}>
<span class="txt">Clear filters</span>
</button>
{/if}
{/if}
</div>
{/each}
</div>
<h5 class="section-title">
Selected
{#if maxSelect > 1}
({selected.length} of MAX {maxSelect})
{/if}
</h5>
{#if selected.length}
<div class="selected-list">
{#each selected as record, i}
<Draggable bind:list={selected} index={i} let:dragging let:dragover>
<span class="label" class:label-danger={dragging} class:label-warning={dragover}>
<RecordInfo {record} {displayFields} />
<button
type="button"
title="Remove"
class="btn btn-circle btn-transparent btn-hint btn-xs"
on:click={() => deselect(record)}
>
<i class="ri-close-line" />
</button>
</span>
</Draggable>
{/each}
</div>
{:else}
<p class="txt-hint">No selected records.</p>
{/if}
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button type="button" class="btn" on:click={() => save()}>
<span class="txt">Save selection</span>
</button>
</svelte:fragment>
</OverlayPanel>
<RecordUpsertPanel
bind:this={upsertPanel}
{collection}
on:save={(e) => {
CommonHelper.removeByKey(list, "id", e.detail.id);
list.unshift(e.detail);
list = list;
CommonHelper.pushOrReplaceByKey(selected, e.detail);
selected = selected;
}}
on:delete={(e) => {
CommonHelper.removeByKey(list, "id", e.detail.id);
list = list;
CommonHelper.removeByKey(selected, "id", e.detail.id);
selected = selected;
}}
/>
<style lang="scss">
.picker-list {
max-height: 380px;
}
.selected-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
max-height: 220px;
overflow: auto;
}
</style>
@@ -56,7 +56,7 @@
<div class="form-field-addon email-visibility-addon">
<button
type="button"
class="btn btn-sm btn-secondary {record.emailVisibility ? 'btn-success' : 'btn-hint'}"
class="btn btn-sm btn-transparent {record.emailVisibility ? 'btn-success' : 'btn-hint'}"
use:tooltip={{
text: "Make email public or private",
position: "top-right",
@@ -70,47 +70,56 @@
}
</script>
<Field class="form-field form-field-file {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<Field
class="form-field form-field-list form-field-file {field.required ? 'required' : ''}"
name={field.name}
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<div bind:this={filesListElem} class="files-list">
<div bind:this={filesListElem} class="list">
{#each valueAsArray as filename, i (filename)}
{@const isDeleted = deletedFileIndexes.includes(i)}
<div class="list-item">
<div class:fade={deletedFileIndexes.includes(i)}>
<RecordFileThumb {record} {filename} />
</div>
<a
href={ApiClient.getFileUrl(record, filename)}
class="filename link-hint"
class:txt-strikethrough={deletedFileIndexes.includes(i)}
title="Download"
target="_blank"
rel="noopener noreferrer"
>
{filename}
</a>
{#if deletedFileIndexes.includes(i)}
<button
type="button"
class="btn btn-sm btn-danger btn-secondary"
on:click={() => restoreExistingFile(i)}
<div class="content">
<a
href={ApiClient.getFileUrl(record, filename)}
class="txt-ellipsis {isDeleted ? 'txt-strikethrough txt-hint' : 'link-primary'}"
title="Download"
target="_blank"
rel="noopener noreferrer"
>
<span class="txt">Restore</span>
</button>
{:else}
<button
type="button"
class="btn btn-secondary btn-sm btn-circle btn-remove txt-hint"
use:tooltip={"Remove file"}
on:click={() => removeExistingFile(i)}
>
<i class="ri-close-line" />
</button>
{/if}
{filename}
</a>
</div>
<div class="actions">
{#if deletedFileIndexes.includes(i)}
<button
type="button"
class="btn btn-sm btn-danger btn-transparent"
on:click={() => restoreExistingFile(i)}
>
<span class="txt">Restore</span>
</button>
{:else}
<button
type="button"
class="btn btn-transparent btn-hint btn-sm btn-circle btn-remove"
use:tooltip={"Remove file"}
on:click={() => removeExistingFile(i)}
>
<i class="ri-close-line" />
</button>
{/if}
</div>
</div>
{/each}
@@ -125,7 +134,7 @@
</div>
<button
type="button"
class="btn btn-secondary btn-sm btn-circle btn-remove"
class="btn btn-transparent btn-sm btn-circle btn-remove"
use:tooltip={"Remove file"}
on:click={() => removeNewFile(i)}
>
@@ -135,7 +144,7 @@
{/each}
{#if !maxReached}
<div class="list-item btn-list-item">
<div class="list-item list-item-btn">
<input
bind:this={fileInput}
type="file"
@@ -151,7 +160,7 @@
/>
<button
type="button"
class="btn btn-secondary btn-sm btn-block"
class="btn btn-transparent btn-sm btn-block"
on:click={() => fileInput?.click()}
>
<i class="ri-upload-cloud-line" />
@@ -1,37 +1,145 @@
<script>
import { SchemaField } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import RecordSelect from "@/components/records/RecordSelect.svelte";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import RecordsPicker from "@/components/records/RecordsPicker.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
const batchSize = 100;
export let value;
export let picker;
export let field = new SchemaField();
export let value = undefined;
let list = [];
let isLoading = false;
$: isMultiple = field.options?.maxSelect != 1;
$: if (
isMultiple &&
Array.isArray(value) &&
field.options?.maxSelect &&
value.length > field.options.maxSelect
) {
value = value.slice(field.options.maxSelect - 1);
load();
async function load() {
const ids = CommonHelper.toArray(value);
if (!field?.options?.collectionId || !ids.length) {
list = [];
isLoading = false;
return;
}
isLoading = true;
// batch load all selected records to avoid parser stack overflow errors
const filterIds = ids.slice();
const loadPromises = [];
while (filterIds.length > 0) {
const filters = [];
for (const id of filterIds.splice(0, batchSize)) {
filters.push(`id="${id}"`);
}
loadPromises.push(
ApiClient.collection(field?.options?.collectionId).getFullList(batchSize, {
filter: filters.join("||"),
$autoCancel: false,
})
);
}
try {
let loadedItems = [];
await Promise.all(loadPromises).then((values) => {
loadedItems = loadedItems.concat(...values);
});
// preserve selected order
for (const id of ids) {
const rel = CommonHelper.findByKey(loadedItems, "id", id);
if (rel) {
list.push(rel);
}
}
list = list;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
function remove(rel) {
CommonHelper.removeByKey(list, "id", rel.id);
list = list;
if (isMultiple) {
value = list.map((r) => r.id);
} else {
value = list[0]?.id || "";
}
}
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<Field class="form-field form-field-list {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<RecordSelect
toggle
id={uniqueId}
multiple={isMultiple}
collectionId={field.options?.collectionId}
bind:keyOfSelected={value}
/>
{#if field.options?.maxSelect > 1}
<div class="help-block">Select up to {field.options.maxSelect} items.</div>
{/if}
<div class="list">
<div class="relations-list">
{#each list as record}
<div class="list-item">
<div class="content">
<RecordInfo {record} displayFields={field.options?.displayFields} />
</div>
<div class="actions">
<button
type="button"
class="btn btn-transparent btn-hint btn-sm btn-circle btn-remove"
use:tooltip={"Remove"}
on:click={() => remove(record)}
>
<i class="ri-close-line" />
</button>
</div>
</div>
{:else}
{#if isLoading}
{#each CommonHelper.toArray(value).slice(0, 10) as _}
<div class="list-item">
<div class="skeleton-loader" />
</div>
{/each}
{/if}
{/each}
</div>
<div class="list-item list-item-btn">
<button type="button" class="btn btn-transparent btn-sm btn-block" on:click={() => picker?.show()}>
<i class="ri-layout-line" />
<span class="txt">Open picker</span>
</button>
</div>
</div>
</Field>
<RecordsPicker
bind:this={picker}
{value}
{field}
on:save={(e) => {
list = e.detail || [];
value = isMultiple ? list.map((r) => r.id) : list[0] || "";
}}
/>
<style lang="scss">
.relations-list {
max-height: 300px;
overflow: auto; /* fallback */
overflow: overlay;
}
</style>
@@ -117,7 +117,7 @@
</form>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={hide} disabled={isSubmitting}>Close</button>
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>Close</button>
<button
type="submit"
form={formId}
@@ -137,7 +137,7 @@
{/each}
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" on:click={hide} disabled={isImporting}>Close</button>
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isImporting}>Close</button>
<button
type="button"
class="btn btn-expanded"
@@ -129,7 +129,7 @@
{#if hasChanges}
<button
type="button"
class="btn btn-secondary btn-hint"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
@@ -110,7 +110,7 @@
{#if hasChanges}
<button
type="button"
class="btn btn-secondary btn-hint"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
@@ -88,7 +88,7 @@
>
<button
type="button"
class="btn btn-sm btn-secondary fade copy-schema"
class="btn btn-sm btn-transparent fade copy-schema"
on:click={() => copy()}
>
<span class="txt">Copy</span>
@@ -382,7 +382,7 @@
<div class="flex m-t-base">
{#if !!schemas}
<button type="button" class="btn btn-secondary link-hint" on:click={() => clear()}>
<button type="button" class="btn btn-transparent link-hint" on:click={() => clear()}>
<span class="txt">Clear</span>
</button>
{/if}
+1 -1
View File
@@ -242,7 +242,7 @@
{#if hasChanges}
<button
type="button"
class="btn btn-secondary btn-hint"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
@@ -291,7 +291,7 @@
{#if hasChanges}
<button
type="button"
class="btn btn-secondary btn-hint"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
@@ -131,7 +131,7 @@
{#if hasChanges}
<button
type="button"
class="btn btn-secondary btn-hint"
class="btn btn-transparent btn-hint"
disabled={isSaving}
on:click={() => reset()}
>
@@ -31,7 +31,6 @@
<div class="sidebar-title">
<span class="txt">Sync</span>
<small class="label label-danger label-sm">Experimental</small>
</div>
<a
href="/settings/export-collections"