[#976] added optional RelationOptions.DisplayFields and refactored the relation picker UI
This commit is contained in:
+3
-1
@@ -54,7 +54,9 @@
|
||||
$appName = settings?.meta?.appName || "";
|
||||
$hideControls = !!settings?.meta?.hideControls;
|
||||
} catch (err) {
|
||||
console.warn("Failed to load app settings.", err);
|
||||
if (!err?.isAbort) {
|
||||
console.warn("Failed to load app settings.", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Simple Svelte scrollend detection action.
|
||||
// ===================================================================
|
||||
//
|
||||
// ### Example usage
|
||||
//
|
||||
// Simple form (with default 100px threshold):
|
||||
// ```html
|
||||
// <div class="list" use:scrollend={() => { console.log("end reached") }}>
|
||||
// ...
|
||||
// </div>
|
||||
// ```
|
||||
//
|
||||
// With custom threshold:
|
||||
// ```html
|
||||
// <div class="list" use:scrollend={{
|
||||
// threshold: 10,
|
||||
// callback: () => { console.log("end reached") }
|
||||
// }}>
|
||||
// ...
|
||||
// </div>
|
||||
// ```
|
||||
// ===================================================================
|
||||
|
||||
function normalize(rawData) {
|
||||
if (typeof rawData === "function") {
|
||||
return {
|
||||
threshold: 100,
|
||||
callback: rawData,
|
||||
}
|
||||
}
|
||||
|
||||
return rawData || {};
|
||||
}
|
||||
|
||||
export default function scrollend(node, options) {
|
||||
options = normalize(options);
|
||||
|
||||
options?.callback && options.callback();
|
||||
|
||||
function onScroll(e) {
|
||||
if (!options?.callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop;
|
||||
|
||||
if (offset <= options.threshold) {
|
||||
options.callback();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener("scroll", onScroll);
|
||||
node.addEventListener("resize", onScroll);
|
||||
|
||||
return {
|
||||
update(newOptions) {
|
||||
options = normalize(newOptions);
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("scroll", onScroll);
|
||||
node.removeEventListener("resize", onScroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
display: block;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
transition: left var(--activeAnimationSpeed);
|
||||
.alert {
|
||||
text-align: left;
|
||||
pointer-events: auto;
|
||||
@@ -112,10 +113,12 @@
|
||||
margin: var(--baseSpacing) auto;
|
||||
@include shadowize();
|
||||
}
|
||||
.app-sidebar ~ .app-body & {
|
||||
}
|
||||
body:not(.overlay-active) {
|
||||
.app-sidebar ~ .app-body .toasts-wrapper {
|
||||
left: var(--appSidebarWidth);
|
||||
}
|
||||
.app-sidebar ~ .app-body .page-sidebar ~ & {
|
||||
.app-sidebar ~ .app-body .page-sidebar ~ .toasts-wrapper {
|
||||
left: calc(var(--appSidebarWidth) + var(--pageSidebarWidth));
|
||||
}
|
||||
}
|
||||
|
||||
+91
-9
@@ -192,7 +192,7 @@ a {
|
||||
.txt-ellipsis {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -329,6 +329,7 @@ a,
|
||||
|
||||
.content {
|
||||
@extend %block;
|
||||
min-width: 0;
|
||||
& > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -369,6 +370,7 @@ a,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--smSpacing);
|
||||
}
|
||||
.flex-fill {
|
||||
@@ -447,22 +449,33 @@ a,
|
||||
}
|
||||
|
||||
.label {
|
||||
--labelVPadding: 3px;
|
||||
--labelHPadding: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
padding: var(--labelVPadding) var(--labelHPadding);
|
||||
min-height: 23px;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
font-size: var(--smFontSize);
|
||||
line-height: var(--smLineHeight);
|
||||
border-radius: 30px;
|
||||
background: var(--baseAlt2Color);
|
||||
color: var(--txtPrimaryColor);
|
||||
white-space: nowrap;
|
||||
|
||||
.btn:last-child {
|
||||
margin-right: calc(-0.5 * var(--labelHPadding));
|
||||
}
|
||||
.btn:first-child {
|
||||
margin-left: calc(-0.5 * var(--labelHPadding));
|
||||
}
|
||||
|
||||
// styles
|
||||
&.label-sm {
|
||||
--labelHPadding: 5px;
|
||||
font-size: var(--xsFontSize);
|
||||
padding: 3px 5px;
|
||||
min-height: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -609,6 +622,7 @@ a.thumb:not(.thumb-active) {
|
||||
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -695,6 +709,8 @@ a.thumb:not(.thumb-active) {
|
||||
.list {
|
||||
@extend %block;
|
||||
position: relative;
|
||||
overflow: auto; /* fallback */
|
||||
overflow: overlay;
|
||||
border: 1px solid var(--baseAlt2Color);
|
||||
border-radius: var(--baseRadius);
|
||||
.list-item {
|
||||
@@ -703,11 +719,77 @@ a.thumb:not(.thumb-active) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--baseAlt2Color);
|
||||
gap: var(--xsSpacing);
|
||||
outline: 0;
|
||||
padding: 10px var(--xsSpacing);
|
||||
min-height: 50px;
|
||||
border-top: 1px solid var(--baseAlt2Color);
|
||||
transition: background var(--baseAnimationSpeed);
|
||||
&:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
user-select: text;
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: -1px -5px -1px 0;
|
||||
&.nonintrusive {
|
||||
@include hide();
|
||||
transform: translateX(5px);
|
||||
transition: transform var(--baseAnimationSpeed),
|
||||
opacity var(--baseAnimationSpeed),
|
||||
visibility var(--baseAnimationSpeed);
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:active {
|
||||
.actions.nonintrusive {
|
||||
@include show();
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
// styles
|
||||
&.selected {
|
||||
background: var(--bodyColor);
|
||||
}
|
||||
&.handle:not(.disabled) {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--baseAlt1Color);
|
||||
}
|
||||
&:active {
|
||||
background: var(--baseAlt2Color);
|
||||
}
|
||||
}
|
||||
&.disabled:not(.selected) {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-btn {
|
||||
padding: 5px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
&.list-compact {
|
||||
.list-item {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
// states
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
background: var(--baseAlt1Color);
|
||||
}
|
||||
|
||||
+55
-49
@@ -112,31 +112,38 @@ button {
|
||||
border: 2px solid currentColor;
|
||||
background: #fff;
|
||||
}
|
||||
&.btn-secondary,
|
||||
&.btn-outline {
|
||||
box-shadow: none;
|
||||
color: var(--txtPrimaryColor);
|
||||
|
||||
@mixin btnOpacity($base: 1, $hover: 1, $active: 1) {
|
||||
&:before {
|
||||
opacity: 0;
|
||||
background: var(--baseAlt4Color);
|
||||
opacity: $base;
|
||||
}
|
||||
&:focus-visible,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
&:hover {
|
||||
&:before {
|
||||
opacity: 0.11;
|
||||
opacity: $hover;
|
||||
}
|
||||
}
|
||||
&.active,
|
||||
&:active {
|
||||
&:before {
|
||||
opacity: 0.22;
|
||||
opacity: $active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary,
|
||||
&.btn-transparent,
|
||||
&.btn-outline {
|
||||
box-shadow: none;
|
||||
color: var(--txtPrimaryColor);
|
||||
@include btnOpacity(0, 0.25, 0.35);
|
||||
&:before {
|
||||
background: var(--baseAlt3Color);
|
||||
}
|
||||
@each $name, $color in $variationsMap {
|
||||
&.btn-#{$name} {
|
||||
color: $color;
|
||||
@include btnOpacity(0, 0.15, 0.25);
|
||||
&:before {
|
||||
background: $color;
|
||||
}
|
||||
@@ -144,6 +151,20 @@ button {
|
||||
}
|
||||
&.btn-hint {
|
||||
color: var(--txtHintColor);
|
||||
&:focus-visible,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--txtPrimaryColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.btn-secondary {
|
||||
@include btnOpacity(0.3, 0.5, 0.7);
|
||||
@each $name, $color in $variationsMap {
|
||||
&.btn-#{$name} {
|
||||
@include btnOpacity(0.15, 0.25, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +178,7 @@ button {
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
&.btn-secondary {
|
||||
&.btn-transparent {
|
||||
background: none;
|
||||
}
|
||||
&.btn-outline {
|
||||
@@ -167,7 +188,7 @@ button {
|
||||
|
||||
// sizes
|
||||
&.btn-expanded {
|
||||
min-width: 140px;
|
||||
min-width: 150px;
|
||||
}
|
||||
&.btn-expanded-sm {
|
||||
min-width: 90px;
|
||||
@@ -231,7 +252,7 @@ button {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
i {
|
||||
$iconSize: 24px;
|
||||
$iconSize: 19px;
|
||||
font-size: 1.2857rem;
|
||||
text-align: center;
|
||||
width: $iconSize;
|
||||
@@ -242,10 +263,12 @@ button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&.btn-sm i,
|
||||
&.btn-xs i {
|
||||
&.btn-sm i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
&.btn-xs i {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
// loading
|
||||
@@ -1025,51 +1048,34 @@ select {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-field-file {
|
||||
.form-field-list {
|
||||
label {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.filename {
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
i {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
.files-list {
|
||||
@extend %block;
|
||||
padding-top: 5px;
|
||||
.list {
|
||||
background: var(--baseAlt1Color);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-bottom-left-radius: var(--baseRadius);
|
||||
border-bottom-right-radius: var(--baseRadius);
|
||||
transition: background var(--baseAnimationSpeed);
|
||||
.list-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
row-gap: 10px;
|
||||
column-gap: var(--xsSpacing);
|
||||
padding: 10px 15px;
|
||||
min-height: 44px;
|
||||
border-top: 1px solid var(--baseAlt2Color);
|
||||
&:last-child {
|
||||
border-radius: inherit;
|
||||
border-bottom: 0;
|
||||
&.selected {
|
||||
background: var(--baseAlt2Color);
|
||||
}
|
||||
&.handle:not(.disabled) {
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--baseAlt2Color);
|
||||
}
|
||||
&:active {
|
||||
background: var(--baseAlt3Color);
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn-list-item {
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
&:focus-within {
|
||||
.files-list, label {
|
||||
.list, label {
|
||||
background: var(--baseAlt1Color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
padding: 5px 7px;
|
||||
margin: 0 0 var(--smSpacing);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--txtHintColor);
|
||||
background: var(--baseAlt1Color);
|
||||
@@ -58,17 +58,3 @@
|
||||
background: var(--baseAlt2Color);
|
||||
}
|
||||
}
|
||||
|
||||
.searchbar-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;;
|
||||
min-width: var(--btnHeight);
|
||||
min-height: var(--btnHeight);
|
||||
.search-toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ table {
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.txt-ellipsis {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
td, th {
|
||||
outline: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
transform var(--baseAnimationSpeed);
|
||||
transform: scale(0.98);
|
||||
white-space: pre-line;
|
||||
word-break: break-all;
|
||||
@include hide();
|
||||
|
||||
// styles
|
||||
|
||||
@@ -14,19 +14,19 @@
|
||||
--baseColor: #ffffff;
|
||||
--baseAlt1Color: #ebeff2;
|
||||
--baseAlt2Color: #dee3e8;
|
||||
--baseAlt3Color: #a9b4bc;
|
||||
--baseAlt4Color: #7c868d;
|
||||
--baseAlt3Color: #d1d7db;
|
||||
--baseAlt4Color: #848d94;
|
||||
|
||||
--infoColor: #3da9fc;
|
||||
--infoAltColor: #d8eefe;
|
||||
--successColor: #2cb67d;
|
||||
--successAltColor: #d6f5e8;
|
||||
--dangerColor: #ef4565;
|
||||
--dangerColor: #e13756;
|
||||
--dangerAltColor: #fcdee4;
|
||||
--warningColor: #ff8e3c;
|
||||
--warningAltColor: #ffe7d6;
|
||||
|
||||
--overlayColor: rgba(65, 80, 105, 0.25);
|
||||
--overlayColor: rgba(60, 70, 105, 0.25);
|
||||
--tooltipColor: rgba(0, 0, 0, 0.85);
|
||||
--shadowColor: rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
--inputHeight: 34px;
|
||||
|
||||
--btnHeight: 40px;
|
||||
--xsBtnHeight: 24px;
|
||||
--xsBtnHeight: 22px;
|
||||
--smBtnHeight: 30px;
|
||||
--lgBtnHeight: 54px;
|
||||
|
||||
|
||||
@@ -51,26 +51,24 @@ export function removeCollection(collection) {
|
||||
export async function loadCollections(activeId = null) {
|
||||
isCollectionsLoading.set(true);
|
||||
|
||||
activeCollection.set({});
|
||||
collections.set([]);
|
||||
|
||||
return ApiClient.collections.getFullList(200, {
|
||||
"sort": "+created",
|
||||
})
|
||||
.then((items) => {
|
||||
collections.set(CommonHelper.sortCollections(items));
|
||||
|
||||
const item = activeId && CommonHelper.findByKey(items, "id", activeId);
|
||||
if (item) {
|
||||
activeCollection.set(item);
|
||||
} else if (items.length) {
|
||||
activeCollection.set(items[0]);
|
||||
}
|
||||
try {
|
||||
let items = await ApiClient.collections.getFullList(200, {
|
||||
"sort": "+created",
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isCollectionsLoading.set(false);
|
||||
});
|
||||
|
||||
items = CommonHelper.sortCollections(items);
|
||||
|
||||
collections.set(items);
|
||||
|
||||
const item = activeId && CommonHelper.findByKey(items, "id", activeId);
|
||||
if (item) {
|
||||
activeCollection.set(item);
|
||||
} else if (items.length) {
|
||||
activeCollection.set(items[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isCollectionsLoading.set(false);
|
||||
}
|
||||
|
||||
+105
-12
@@ -108,7 +108,7 @@ export default class CommonHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and returns arr as a valid array instance (if not already).
|
||||
* Normalizes and returns arr as a new array instance.
|
||||
*
|
||||
* @param {Array} arr
|
||||
* @param {Boolean} [allowEmpty]
|
||||
@@ -116,7 +116,7 @@ export default class CommonHelper {
|
||||
*/
|
||||
static toArray(arr, allowEmpty = false) {
|
||||
if (Array.isArray(arr)) {
|
||||
return arr;
|
||||
return arr.slice();
|
||||
}
|
||||
|
||||
return (allowEmpty || !CommonHelper.isEmpty(arr)) && typeof arr !== "undefined" ? [arr] : [];
|
||||
@@ -230,10 +230,9 @@ export default class CommonHelper {
|
||||
/**
|
||||
* Adds or replace an object array element by comparing its key value.
|
||||
*
|
||||
* @param {Array} objectsArr
|
||||
* @param {Object} item
|
||||
* @param {Mixed} [key]
|
||||
* @return {Array}
|
||||
* @param {Array} objectsArr
|
||||
* @param {Object} item
|
||||
* @param {Mixed} [key]
|
||||
*/
|
||||
static pushOrReplaceByKey(objectsArr, item, key = "id") {
|
||||
for (let i = objectsArr.length - 1; i >= 0; i--) {
|
||||
@@ -473,6 +472,45 @@ export default class CommonHelper {
|
||||
return (doc.body.innerText || "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates the provided text to the specified max characters length.
|
||||
*
|
||||
* @param {String} str
|
||||
* @param {Number} length
|
||||
* @return {String}
|
||||
*/
|
||||
static truncate(str, length = 150, dots = false) {
|
||||
str = str || "";
|
||||
|
||||
if (str.length <= length) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.substring(0, length) + (dots ? "..." : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new object copy with truncated the large text fields.
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @return {Object}
|
||||
*/
|
||||
static truncateObject(obj) {
|
||||
const truncated = {};
|
||||
|
||||
for (let key in obj) {
|
||||
let value = obj[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = CommonHelper.truncate(value, 150, true);
|
||||
}
|
||||
|
||||
truncated[key] = value;
|
||||
}
|
||||
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and converts the provided string to a slug.
|
||||
*
|
||||
@@ -1112,13 +1150,13 @@ export default class CommonHelper {
|
||||
const singleCollections = [];
|
||||
const baseCollections = [];
|
||||
|
||||
for (const colelction of collections) {
|
||||
if (colelction.type == 'auth') {
|
||||
authCollections.push(colelction);
|
||||
} else if (colelction.type == 'single') {
|
||||
singleCollections.push(colelction);
|
||||
for (const collection of collections) {
|
||||
if (collection.type === 'auth') {
|
||||
authCollections.push(collection);
|
||||
} else if (collection.type === 'single') {
|
||||
singleCollections.push(collection);
|
||||
} else {
|
||||
baseCollections.push(colelction);
|
||||
baseCollections.push(collection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,4 +1260,59 @@ export default class CommonHelper {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to output the first displayable field of the provided model.
|
||||
*
|
||||
* @param {Object} model
|
||||
* @return {String}
|
||||
*/
|
||||
static displayValue(model, displayFields) {
|
||||
model = model || {};
|
||||
displayFields = displayFields || [];
|
||||
|
||||
let result = [];
|
||||
|
||||
for (const field of displayFields) {
|
||||
let val = model[field];
|
||||
|
||||
if (typeof val === "undefined") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (CommonHelper.isEmpty(val)) {
|
||||
result.push("N/A");
|
||||
} else if (typeof val === "boolean") {
|
||||
result.push(val ? "True" : "False");
|
||||
} else if (typeof val === "string") {
|
||||
val = val.indexOf("<") >= 0 ? CommonHelper.plainText(val) : val;
|
||||
result.push(CommonHelper.truncate(val));
|
||||
} else {
|
||||
result.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
return result.join(", ");
|
||||
}
|
||||
|
||||
const fallbackProps = [
|
||||
"title",
|
||||
"name",
|
||||
"email",
|
||||
"username",
|
||||
"heading",
|
||||
"label",
|
||||
"key",
|
||||
"id",
|
||||
];
|
||||
|
||||
for (const prop of fallbackProps) {
|
||||
if (!CommonHelper.isEmpty(model[prop])) {
|
||||
return model[prop];
|
||||
}
|
||||
}
|
||||
|
||||
return "N/A";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user