merge v0.23.0-rc changes

This commit is contained in:
Gani Georgiev
2024-09-29 19:23:19 +03:00
parent ad92992324
commit 844f18cac3
753 changed files with 85141 additions and 63396 deletions
@@ -0,0 +1,44 @@
<script>
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
const detailedDateFormat = "yyyy-MM-dd HH:mm:ss.SSS";
export let record;
let tooltipDates = [];
$: collection = record && $collections.find((c) => c.id == record.collectionId);
$: if (record) {
refreshTooltipDates();
}
function refreshTooltipDates() {
tooltipDates = [];
const fields = collection.fields || [];
for (let field of fields) {
if (field.type != "autodate") {
continue;
}
tooltipDates.push(
field.name +
": " +
CommonHelper.formatToLocalDate(record[field.name], detailedDateFormat) +
" Local",
);
}
}
</script>
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: tooltipDates.join("\n"),
position: "left",
}}
/>
@@ -1,10 +1,10 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import providersList from "@/providers.js";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import providersList from "@/providers.js";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
@@ -31,7 +31,12 @@
isLoading = true;
try {
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
externalAuths = await ApiClient.collection("_externalAuths").getFullList({
filter: ApiClient.filter("collectionRef = {:collectionId} && recordRef = {:recordId}", {
collectionId: record.collectionId,
recordId: record.id,
}),
});
} catch (err) {
ApiClient.error(err);
}
@@ -39,23 +44,28 @@
isLoading = false;
}
function unlinkExternalAuth(provider) {
if (!record?.id || !provider) {
function unlinkExternalAuth(externalAuth) {
if (!record?.id || !externalAuth) {
return; // nothing to unlink
}
confirm(`Do you really want to unlink the ${getProviderTitle(provider)} provider?`, () => {
return ApiClient.collection(record.collectionId)
.unlinkExternalAuth(record.id, provider)
.then(() => {
addSuccessToast(`Successfully unlinked the ${getProviderTitle(provider)} provider.`);
dispatch("unlink", provider);
loadExternalAuths(); // reload list
})
.catch((err) => {
ApiClient.error(err);
});
});
confirm(
`Do you really want to unlink the ${getProviderTitle(externalAuth.provider)} provider?`,
() => {
return ApiClient.collection("_externalAuths")
.delete(externalAuth.id)
.then(() => {
addSuccessToast(
`Successfully unlinked the ${getProviderTitle(externalAuth.provider)} provider.`,
);
dispatch("unlink", externalAuth.provider);
loadExternalAuths(); // reload list
})
.catch((err) => {
ApiClient.error(err);
});
},
);
}
loadExternalAuths();
@@ -80,7 +90,7 @@
<button
type="button"
class="btn btn-transparent link-hint btn-circle btn-sm m-l-auto"
on:click={() => unlinkExternalAuth(auth.provider)}
on:click={() => unlinkExternalAuth(auth)}
>
<i class="ri-close-line" />
</button>
@@ -0,0 +1,157 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { addSuccessToast } from "@/stores/toasts";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import SdkTabs from "@/components/base/SdkTabs.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
const formId = "impersonate_" + CommonHelper.randomString(5);
export let collection;
export let record;
let panel;
let duration = 0;
let isSubmitting = false;
let impersonateClient;
$: backendAbsUrl = CommonHelper.getApiExampleUrl(impersonateClient?.baseURL);
export function show() {
if (!record) {
return;
}
panel?.show();
}
export function hide() {
panel?.hide();
reset();
}
async function submit() {
if (isSubmitting || !collection || !record) {
return;
}
isSubmitting = true;
try {
impersonateClient = await ApiClient.collection(collection.name).impersonate(record.id, duration);
dispatch("submit", impersonateClient);
} catch (err) {
ApiClient.error(err);
}
isSubmitting = false;
}
function reset() {
duration = 0;
impersonateClient = undefined;
}
</script>
<OverlayPanel
bind:this={panel}
overlayClose={false}
escClose={!isSubmitting}
beforeHide={() => !isSubmitting}
popup
on:show
on:hide
>
<svelte:fragment slot="header">
<h4>Impersonate auth token</h4>
</svelte:fragment>
<div class="clearfix"></div>
{#if impersonateClient?.authStore?.token}
<div class="alert alert-success">
<div class="content txt-bold">
<span class="txt token-holder">{impersonateClient.authStore.token}</span>
<CopyIcon value={impersonateClient.authStore.token} />
</div>
</div>
<SdkTabs
js={`
import PocketBase from 'pocketbase';
const token = "...";
const pb = new PocketBase('${backendAbsUrl}');
pb.authStore.save(token, null);
`}
dart={`
import 'package:pocketbase/pocketbase.dart';
final token = "...";
final pb = PocketBase('${backendAbsUrl}');
pb.authStore.save(token, null);
`}
/>
{:else}
<form id={formId} on:submit|preventDefault={submit}>
<p>
Generate a non-refreshable auth token for
<strong>{CommonHelper.displayValue(record)}:</strong>
</p>
<Field class="form-field m-b-0 m-t-sm" name="duration" let:uniqueId>
<label for={uniqueId}>Token duration (in seconds)</label>
<input
type="number"
id={uniqueId}
placeholder="Default to the collection setting ({collection?.authToken?.duration || 0}s)"
min="0"
step="1"
value={duration || ""}
on:input={(e) => (duration = e.target.value << 0)}
/>
</Field>
</form>
{/if}
<svelte:fragment slot="footer">
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
<span class="txt">Close</span>
</button>
{#if impersonateClient?.authStore?.token}
<button
type="button"
class="btn btn-secondary btn-expanded"
disabled={isSubmitting}
on:click={() => reset()}
>
<span class="txt">Generate a new one</span>
</button>
{:else}
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSubmitting}
disabled={isSubmitting}
on:click={() => submit()}
>
<span class="txt">Generate token</span>
</button>
{/if}
</svelte:fragment>
</OverlayPanel>
<style>
.token-holder {
user-select: all;
}
</style>
@@ -1,5 +1,8 @@
<script>
import { onMount } from "svelte";
import { pageTitle } from "@/stores/app";
$pageTitle = "OAuth2 auth failed";
onMount(() => {
window.close();
@@ -1,5 +1,8 @@
<script>
import { onMount } from "svelte";
import { pageTitle } from "@/stores/app";
$pageTitle = "OAuth2 auth completed";
onMount(() => {
window.close();
@@ -20,7 +20,7 @@
isLoading = true;
// init a custom client to avoid interfering with the admin state
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
@@ -21,7 +21,7 @@
isLoading = true;
// init a custom client to avoid interfering with the admin state
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
@@ -1,40 +1,74 @@
<script>
import PocketBase, { getTokenPayload } from "pocketbase";
import FullPage from "@/components/base/FullPage.svelte";
import ApiClient from "@/utils/ApiClient";
import PocketBase, { getTokenPayload, isTokenExpired } from "pocketbase";
export let params;
let success = false;
let isLoading = false;
let successConfirm = false;
let isConfirming = false;
let successResend = false;
let isResending = false;
send();
async function send() {
isLoading = true;
if (isConfirming) {
return;
}
// init a custom client to avoid interfering with the admin state
isConfirming = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).confirmVerification(params?.token);
success = true;
successConfirm = true;
} catch (err) {
success = false;
successConfirm = false;
}
isLoading = false;
isConfirming = false;
}
$: canResend = params?.token && isTokenExpired(params.token);
async function resend() {
const payload = getTokenPayload(params?.token);
if (isResending || !payload.collectionId || !payload.email) {
return;
}
isResending = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).requestVerification(payload.email);
successResend = true;
} catch (err) {
ApiClient.error(err);
successResend = false;
}
isResending = false;
}
</script>
<FullPage nobranding>
{#if isLoading}
{#if isConfirming}
<div class="txt-center">
<div class="loader loader-lg">
<em>Please wait...</em>
</div>
</div>
{:else if success}
{:else if successConfirm}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
@@ -42,6 +76,17 @@
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{:else if successResend}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Please check your email for the new verification link.</p>
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
@@ -53,8 +98,20 @@
</div>
</div>
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{#if canResend}
<button
type="button"
class="btn btn-transparent btn-block"
class:btn-loading={isResending}
disabled={isResending}
on:click={resend}
>
<span class="txt">Resend</span>
</button>
{:else}
<button type="button" class="btn btn-transparent btn-block" on:click={() => window.close()}>
Close
</button>
{/if}
{/if}
</FullPage>
+30 -21
View File
@@ -1,26 +1,26 @@
<script>
import { tick } from "svelte";
import { querystring } from "svelte-spa-router";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
import RecordPreviewPanel from "@/components/records/RecordPreviewPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordsCount from "@/components/records/RecordsCount.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
import { hideControls, pageTitle } from "@/stores/app";
import {
collections,
activeCollection,
changeActiveCollectionById,
collections,
isCollectionsLoading,
loadCollections,
changeActiveCollectionById,
} from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import { pageTitle, hideControls } from "@/stores/app";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordPreviewPanel from "@/components/records/RecordPreviewPanel.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
import RecordsCount from "@/components/records/RecordsCount.svelte";
import CommonHelper from "@/utils/CommonHelper";
import { tick } from "svelte";
import { querystring } from "svelte-spa-router";
const initialQueryParams = new URLSearchParams($querystring);
@@ -31,7 +31,7 @@
let recordsList;
let recordsCount;
let filter = initialQueryParams.get("filter") || "";
let sort = initialQueryParams.get("sort") || "-created";
let sort = initialQueryParams.get("sort") || "-@rowid";
let selectedCollectionId = initialQueryParams.get("collectionId") || $activeCollection?.id;
let totalCount = 0; // used to manully change the count without the need of reloading the recordsCount component
@@ -78,7 +78,7 @@
function reset() {
selectedCollectionId = $activeCollection?.id;
filter = "";
sort = "-created";
sort = "-@rowid";
updateQueryParams({ recordId: null });
@@ -106,7 +106,10 @@
// invalid sort expression or missing sort field
if (sortFields.filter((f) => collectionFields.includes(f)).length != sortFields.length) {
if (collectionFields.includes("created")) {
if ($activeCollection?.type != "view") {
sort = "-@rowid"; // all collections with exception to the view has this field
} else if (collectionFields.includes("created")) {
// common autodate field
sort = "-created";
} else {
sort = "";
@@ -248,7 +251,13 @@
</PageWrapper>
{/if}
<CollectionUpsertPanel bind:this={collectionUpsertPanel} />
<CollectionUpsertPanel
bind:this={collectionUpsertPanel}
on:truncate={() => {
recordsList?.load();
recordsCount?.reload();
}}
/>
<CollectionDocsPanel bind:this={collectionDocsPanel} />
@@ -1,11 +1,12 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import { superuser } from "@/stores/superuser";
import CommonHelper from "@/utils/CommonHelper";
export let record;
export let field;
@@ -14,7 +15,15 @@
$: rawValue = record?.[field.name];
</script>
{#if field.type === "json"}
{#if field.primaryKey}
<div class="label">
<CopyIcon value={rawValue} />
<div class="txt txt-ellipsis">{rawValue}</div>
</div>
{#if record.collectionName == "_superusers" && record.id == $superuser.id}
<span class="label label-warning">You</span>
{/if}
{:else if field.type === "json"}
{@const stringifiedJson = CommonHelper.trimQuotedValue(JSON.stringify(rawValue)) || '""'}
{#if short}
<span class="txt txt-ellipsis">
@@ -31,7 +40,7 @@
{:else if CommonHelper.isEmpty(rawValue)}
<span class="txt-hint">N/A</span>
{:else if field.type === "bool"}
<span class="txt">{rawValue ? "True" : "False"}</span>
<span class="label" class:label-success={!!rawValue}>{rawValue ? "True" : "False"}</span>
{:else if field.type === "number"}
<span class="txt">{rawValue}</span>
{:else if field.type === "url"}
@@ -72,7 +81,7 @@
disabled
/>
{/if}
{:else if field.type === "date"}
{:else if field.type === "date" || field.type === "autodate"}
<FormattedDate date={rawValue} />
{:else if field.type === "select"}
<div class="inline-flex">
@@ -103,7 +112,7 @@
{:else if field.type === "file"}
{@const files = CommonHelper.toArray(rawValue)}
{@const filesLimit = short ? 10 : 500}
<div class="inline-flex" class:multiple={field.options?.maxSelect != 1}>
<div class="inline-flex" class:multiple={field.maxSelect != 1}>
{#each files.slice(0, filesLimit) as filename, i (i + filename)}
<RecordFileThumb {record} {filename} size="sm" />
{/each}
@@ -1,15 +1,15 @@
<script>
import { createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import { collections } from "@/stores/collections";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
const uniqueId = "file_picker_" + CommonHelper.randomString(5);
@@ -37,15 +37,14 @@
$: fileCollections = $collections.filter((c) => {
return (
c.type !== "view" &&
!!CommonHelper.toArray(c.schema).find((f) => {
!!CommonHelper.toArray(c.fields).find((f) => {
return (
// is file field
f.type === "file" &&
// is public (aka. doesn't require file token)
!f.options?.protected &&
!f.protected &&
// allow any MIME type OR image/*
(!f.options?.mimeTypes?.length ||
!!f.options?.mimeTypes?.find((t) => t.startsWith("image/")))
(!f.mimeTypes?.length || !!f.mimeTypes?.find((t) => t.startsWith("image/")))
);
})
);
@@ -56,7 +55,7 @@
selectedCollection = fileCollections[0];
}
$: fileFields = selectedCollection?.schema?.filter((f) => f.type === "file" && !f.options?.protected);
$: fileFields = selectedCollection?.fields?.filter((f) => f.type === "file" && !f.protected);
// reset filter on collection change
$: if (selectedCollection?.id) {
@@ -149,13 +148,13 @@
}
function refreshSizeOptions() {
let sizes = ["100x100"]; // default Admin UI thumb
let sizes = ["100x100"]; // default Superuser UI thumb
// extract the thumb sizes of the selected file field
if (selectedFile?.record?.id) {
for (const field of fileFields) {
if (CommonHelper.toArray(selectedFile.record[field.name]).includes(selectedFile.name)) {
sizes = sizes.concat(CommonHelper.toArray(field.options?.thumbs));
sizes = sizes.concat(CommonHelper.toArray(field.thumbs));
break;
}
}
@@ -206,8 +205,8 @@
{
size: selectedSize,
},
selectedFile
)
selectedFile,
),
);
hide();
@@ -279,7 +278,7 @@
{#if CommonHelper.hasImageExtension(name)}
<img
loading="lazy"
src={ApiClient.files.getUrl(record, name, { thumb: "100x100" })}
src={ApiClient.files.getURL(record, name, { thumb: "100x100" })}
alt={name}
/>
{:else}
@@ -1,7 +1,7 @@
<script>
import PreviewPopup from "@/components/base/PreviewPopup.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import PreviewPopup from "@/components/base/PreviewPopup.svelte";
export let record = null;
export let filename = "";
@@ -19,17 +19,17 @@
$: hasPreview = ["image", "audio", "video"].includes(type) || filename.endsWith(".pdf");
$: originalUrl = !isLoadingToken ? ApiClient.files.getUrl(record, filename, { token }) : "";
$: originalUrl = !isLoadingToken ? ApiClient.files.getURL(record, filename, { token }) : "";
$: thumbUrl = !isLoadingToken
? ApiClient.files.getUrl(record, filename, { thumb: "100x100", token: token })
? ApiClient.files.getURL(record, filename, { thumb: "100x100", token: token })
: "";
async function loadFileToken() {
isLoadingToken = true;
try {
token = await ApiClient.getAdminFileToken(record.collectionId);
token = await ApiClient.getSuperuserFileToken(record.collectionId);
} catch (err) {
console.warn("File token failure:", err);
}
+28 -56
View File
@@ -1,70 +1,43 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { collections } from "@/stores/collections";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfoContent from "@/components/records/RecordInfoContent.svelte";
import CommonHelper from "@/utils/CommonHelper";
export let record;
let fileDisplayFields = [];
let nonFileDisplayFields = [];
$: collection = $collections?.find((item) => item.id == record?.collectionId);
$: if (collection) {
loadDisplayFields();
}
function loadDisplayFields() {
const fields = collection?.schema || [];
// reset
fileDisplayFields = fields.filter((f) => f.presentable && f.type == "file").map((f) => f.name);
nonFileDisplayFields = fields.filter((f) => f.presentable && f.type != "file").map((f) => f.name);
// fallback to the first single file field that accept images
// if no presentable field is available
if (!fileDisplayFields.length && !nonFileDisplayFields.length) {
const fallbackFileField = fields.find((f) => {
return (
f.type == "file" &&
f.options?.maxSelect == 1 &&
f.options?.mimeTypes?.find((t) => t.startsWith("image/"))
);
});
if (fallbackFileField) {
fileDisplayFields.push(fallbackFileField.name);
}
function excludeProps(item, ...props) {
const result = Object.assign({}, item);
for (let prop of props) {
delete result[prop];
}
return result;
}
</script>
<div class="record-info">
<i
class="link-hint txt-sm ri-information-line"
<RecordInfoContent {record} />
<a
href="#/collections?collectionId={record.collectionId}&recordId={record.id}"
target="_blank"
class="inline-flex link-hint"
rel="noopener noreferrer"
use:tooltip={{
text: CommonHelper.truncate(
JSON.stringify(CommonHelper.truncateObject(record), null, 2),
800,
true,
),
text:
"Open relation record in new tab:\n" +
CommonHelper.truncate(
JSON.stringify(CommonHelper.truncateObject(excludeProps(record, "expand")), null, 2),
800,
true,
),
class: "code",
position: "left",
}}
/>
{#each fileDisplayFields as name}
{@const filenames = CommonHelper.toArray(record[name]).slice(0, 5)}
{#each filenames as filename}
{#if !CommonHelper.isEmpty(filename)}
<RecordFileThumb {record} {filename} size="xs" />
{/if}
{/each}
{/each}
<span class="txt txt-ellipsis">
{CommonHelper.truncate(CommonHelper.displayValue(record, nonFileDisplayFields), 70)}
</span>
on:click|stopPropagation
on:keydown|stopPropagation
>
<i class="ri-external-link-line txt-sm"></i>
</a>
</div>
<style lang="scss">
@@ -72,11 +45,10 @@
display: inline-flex;
vertical-align: top;
align-items: center;
justify-content: center;
max-width: 100%;
min-width: 0;
gap: 5px;
:global(.thumb) {
box-shadow: none;
}
padding-left: 1px; // for visual alignment with the new tab icon
}
</style>
@@ -0,0 +1,61 @@
<script>
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import RecordInfoContent from "@/components/records/RecordInfoContent.svelte";
import { collections } from "@/stores/collections";
import CommonHelper from "@/utils/CommonHelper";
export let record;
let fileDisplayFields = [];
let nonFileDisplayFields = [];
$: collection = $collections?.find((item) => item.id == record?.collectionId);
$: if (collection) {
loadDisplayFields();
}
function loadDisplayFields() {
const fields = collection?.fields || [];
fileDisplayFields = fields.filter((f) => !f.hidden && f.presentable && f.type == "file");
nonFileDisplayFields = fields.filter((f) => !f.hidden && f.presentable && f.type != "file");
// fallback to the first single file field that accept images
// if no presentable field is available
if (!fileDisplayFields.length && !nonFileDisplayFields.length) {
const fallbackFileField = fields.find((f) => {
return (
!f.hidden &&
f.type == "file" &&
f.maxSelect == 1 &&
f.mimeTypes?.find((t) => t.startsWith("image/"))
);
});
if (fallbackFileField) {
fileDisplayFields.push(fallbackFileField);
}
}
}
</script>
{#each fileDisplayFields as field}
{@const filenames = CommonHelper.toArray(record[field.name]).slice(0, 5)}
{#each filenames as filename}
{#if !CommonHelper.isEmpty(filename)}
<RecordFileThumb {record} {filename} size="xs" />
{/if}
{/each}
{/each}
{#each nonFileDisplayFields as field, i}
{#if i > 0},{/if}
{#if field.type == "relation" && record.expand?.[field.name]}
<RecordInfoContent bind:record={record.expand[field.name]} />
{:else}
{CommonHelper.truncate(CommonHelper.displayValue(record, [field.name]), 70)}
{/if}
{:else}
{CommonHelper.truncate(CommonHelper.displayValue(record, []), 70)}
{/each}
@@ -12,7 +12,7 @@
let record = {};
let isLoading = false;
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
$: hasEditorField = !!collection?.fields?.find((f) => f.type === "editor");
export function show(model) {
load(model);
@@ -77,7 +77,7 @@
</td>
</tr>
{#each collection?.schema as field}
{#each collection?.fields as field}
<tr>
<td class="min-width txt-hint txt-bold">{field.name}</td>
<td class="col-field">
@@ -1,30 +1,32 @@
<script>
import { createEventDispatcher, tick } from "svelte";
import { slide } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import { ClientResponseError } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { setErrors } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast, addErrorToast } from "@/stores/toasts";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import ModelDateIcon from "@/components/base/ModelDateIcon.svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import AuthFields from "@/components/records/fields/AuthFields.svelte";
import TextField from "@/components/records/fields/TextField.svelte";
import NumberField from "@/components/records/fields/NumberField.svelte";
import BoolField from "@/components/records/fields/BoolField.svelte";
import EmailField from "@/components/records/fields/EmailField.svelte";
import UrlField from "@/components/records/fields/UrlField.svelte";
import DateField from "@/components/records/fields/DateField.svelte";
import SelectField from "@/components/records/fields/SelectField.svelte";
import JsonField from "@/components/records/fields/JsonField.svelte";
import FileField from "@/components/records/fields/FileField.svelte";
import RelationField from "@/components/records/fields/RelationField.svelte";
import EditorField from "@/components/records/fields/EditorField.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import AutodateIcon from "@/components/records/AutodateIcon.svelte";
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
import AuthFields from "@/components/records/fields/AuthFields.svelte";
import BoolField from "@/components/records/fields/BoolField.svelte";
import DateField from "@/components/records/fields/DateField.svelte";
import EditorField from "@/components/records/fields/EditorField.svelte";
import EmailField from "@/components/records/fields/EmailField.svelte";
import FileField from "@/components/records/fields/FileField.svelte";
import JsonField from "@/components/records/fields/JsonField.svelte";
import NumberField from "@/components/records/fields/NumberField.svelte";
import PasswordField from "@/components/records/fields/PasswordField.svelte";
import RelationField from "@/components/records/fields/RelationField.svelte";
import SelectField from "@/components/records/fields/SelectField.svelte";
import TextField from "@/components/records/fields/TextField.svelte";
import UrlField from "@/components/records/fields/UrlField.svelte";
import ImpersonatePopup from "@/components/records/ImpersonatePopup.svelte";
import { confirm } from "@/stores/confirmation";
import { setErrors } from "@/stores/errors";
import { addErrorToast, addSuccessToast } from "@/stores/toasts";
const dispatch = createEventDispatcher();
const formId = "record_" + CommonHelper.randomString(5);
@@ -34,6 +36,7 @@
export let collection;
let recordPanel;
let impersonatePopup;
let original = {};
let record = {};
let initialDraft = null;
@@ -47,10 +50,15 @@
let isNew = true;
let isLoading = true;
let initialCollection = collection;
let regularFields = [];
$: isAuthCollection = collection?.type === "auth";
$: hasEditorField = !!collection?.schema?.find((f) => f.type === "editor");
$: isSuperusersCollection = collection?.name === "_superusers";
$: hasEditorField = !!collection?.fields?.find((f) => f.type === "editor");
$: idField = collection?.fields?.find((f) => f.name === "id");
$: hasFileChanges =
CommonHelper.hasNonEmptyProps(uploadedFilesMap) || CommonHelper.hasNonEmptyProps(deletedFileNamesMap);
@@ -71,6 +79,21 @@
onCollectionChange();
}
const baseSkipFieldNames = ["id"];
const authSkipFieldNames = baseSkipFieldNames.concat(
"email",
"emailVisibility",
"verified",
"tokenKey",
"password",
);
$: skipFieldNames = isAuthCollection ? authSkipFieldNames : baseSkipFieldNames;
$: regularFields =
collection?.fields?.filter((f) => !skipFieldNames.includes(f.name) && f.type != "autodate") || [];
export function show(model) {
load(model);
@@ -162,8 +185,8 @@
uploadedFilesMap = {};
deletedFileNamesMap = {};
// to avoid layout shifts we replace only the file and non-schema fields
const skipFields = collection?.schema?.filter((f) => f.type != "file")?.map((f) => f.name) || [];
// to avoid layout shifts we replace only the file and non-collection fields
const skipFields = collection?.fields?.filter((f) => f.type != "file")?.map((f) => f.name) || [];
for (let k in newOriginal) {
if (skipFields.includes(k)) {
continue;
@@ -216,7 +239,7 @@
const cloneA = structuredClone(recordA || {});
const cloneB = structuredClone(recordB || {});
const fileFields = collection?.schema?.filter((f) => f.type === "file");
const fileFields = collection?.fields?.filter((f) => f.type === "file");
for (let field of fileFields) {
delete cloneA[field.name];
delete cloneB[field.name];
@@ -258,6 +281,15 @@
deleteDraft();
// logout on password change of the current logged in user
if (
isSuperusersCollection &&
record?.id == ApiClient.authStore.record?.id &&
!!data.get("password")
) {
return ApiClient.logout();
}
if (hidePanel) {
forceHide();
} else {
@@ -284,7 +316,7 @@
return ApiClient.collection(original.collectionId)
.delete(original.id)
.then(() => {
hide();
forceHide();
addSuccessToast("Successfully deleted record.");
dispatch("delete", original);
})
@@ -297,14 +329,14 @@
function exportFormData() {
const data = structuredClone(record || {});
const formData = new FormData();
const exportableFields = {
id: data.id,
};
const exportableFields = {};
const jsonFields = {};
for (const field of collection?.schema || []) {
for (const field of collection?.fields || []) {
if (field.type == "autodate" || (isAuthCollection && field.type == "password")) {
continue;
}
exportableFields[field.name] = true;
if (field.type == "json") {
@@ -312,18 +344,17 @@
}
}
if (isAuthCollection) {
exportableFields["username"] = true;
exportableFields["email"] = true;
exportableFields["emailVisibility"] = true;
// export the auth password fields only if explicitly set
if (isAuthCollection && data["password"]) {
exportableFields["password"] = true;
}
if (isAuthCollection && data["passwordConfirm"]) {
exportableFields["passwordConfirm"] = true;
exportableFields["verified"] = true;
}
// export base fields
for (const key in data) {
// skip non-schema fields
// skip non-exportable fields
if (!exportableFields[key]) {
continue;
}
@@ -360,7 +391,7 @@
for (const key in uploadedFilesMap) {
const files = CommonHelper.toArray(uploadedFilesMap[key]);
for (const file of files) {
formData.append(key, file);
formData.append(key + "+", file);
}
}
@@ -368,7 +399,7 @@
for (const key in deletedFileNamesMap) {
const names = CommonHelper.toArray(deletedFileNamesMap[key]);
for (const name of names) {
formData.append(key + "." + name, "");
formData.append(key + "-", name);
}
}
@@ -423,17 +454,16 @@
let clone = original ? structuredClone(original) : null;
if (clone) {
clone.id = "";
clone.created = "";
clone.updated = "";
// reset file fields
const fields = collection?.schema || [];
const resetTypes = ["file", "autodate"];
const fields = collection?.fields || [];
for (const field of fields) {
if (field.type === "file") {
if (resetTypes.includes(field.type)) {
delete clone[field.name];
}
}
clone.id = "";
}
deleteDraft();
@@ -458,7 +488,7 @@
class="
record-panel
{hasEditorField ? 'overlay-panel-xl' : 'overlay-panel-lg'}
{isAuthCollection && !isNew ? 'colored-header' : ''}
{isAuthCollection && !isSuperusersCollection && !isNew ? 'colored-header' : ''}
"
btnClose={!isLoading}
escClose={!isLoading}
@@ -507,7 +537,7 @@
role="menuitem"
on:click={() => sendVerificationEmail()}
>
<i class="ri-mail-check-line" />
<i class="ri-mail-check-line" aria-hidden="true" />
<span class="txt">Send verification email</span>
</button>
{/if}
@@ -518,17 +548,26 @@
role="menuitem"
on:click={() => sendPasswordResetEmail()}
>
<i class="ri-mail-lock-line" />
<i class="ri-mail-lock-line" aria-hidden="true" />
<span class="txt">Send password reset email</span>
</button>
{/if}
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => impersonatePopup?.show()}
>
<i class="ri-id-card-line" aria-hidden="true" />
<span class="txt">Impersonate</span>
</button>
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => duplicateConfirm()}
>
<i class="ri-file-copy-line" />
<i class="ri-file-copy-line" aria-hidden="true" />
<span class="txt">Duplicate</span>
</button>
<button
@@ -537,7 +576,7 @@
role="menuitem"
on:click|preventDefault|stopPropagation={() => deleteConfirm()}
>
<i class="ri-delete-bin-7-line" />
<i class="ri-delete-bin-7-line" aria-hidden="true" />
<span class="txt">Delete</span>
</button>
</Toggler>
@@ -545,7 +584,7 @@
{/if}
{/if}
{#if isAuthCollection && !isNew}
{#if isAuthCollection && !isSuperusersCollection && !isNew}
<div class="tabs-header stretched">
<button
type="button"
@@ -615,14 +654,16 @@
</label>
{#if !isNew}
<div class="form-field-addon">
<ModelDateIcon model={record} />
<AutodateIcon {record} />
</div>
{/if}
<input
type="text"
id={uniqueId}
placeholder={!isLoading ? "Leave empty to auto generate..." : ""}
minlength="15"
placeholder={!isLoading && !CommonHelper.isEmpty(idField?.autogeneratePattern)
? "Leave empty to auto generate..."
: ""}
minlength={idField?.min}
readonly={!isNew}
bind:value={record.id}
/>
@@ -631,45 +672,48 @@
{#if isAuthCollection}
<AuthFields bind:record {isNew} {collection} />
{#if collection?.schema?.length}
{#if regularFields.length}
<hr />
{/if}
{/if}
{#each collection?.schema || [] as field (field.name)}
{#each regularFields as field (field.name)}
{#if field.type === "text"}
<TextField {field} bind:value={record[field.name]} />
<TextField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "number"}
<NumberField {field} bind:value={record[field.name]} />
<NumberField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "bool"}
<BoolField {field} bind:value={record[field.name]} />
<BoolField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "email"}
<EmailField {field} bind:value={record[field.name]} />
<EmailField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "url"}
<UrlField {field} bind:value={record[field.name]} />
<UrlField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "editor"}
<EditorField {field} bind:value={record[field.name]} />
<EditorField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "date"}
<DateField {field} bind:value={record[field.name]} />
<DateField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "select"}
<SelectField {field} bind:value={record[field.name]} />
<SelectField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "json"}
<JsonField {field} bind:value={record[field.name]} />
<JsonField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "file"}
<FileField
{field}
{original}
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileNames={deletedFileNamesMap[field.name]}
/>
{:else if field.type === "relation"}
<RelationField {field} bind:value={record[field.name]} />
<RelationField {field} {original} {record} bind:value={record[field.name]} />
{:else if field.type === "password"}
<PasswordField {field} {original} {record} bind:value={record[field.name]} />
{/if}
{/each}
</form>
{#if isAuthCollection && !isNew}
{#if isAuthCollection && !isSuperusersCollection && !isNew}
<div class="tab-item" class:active={activeTab === tabProviderKey}>
<ExternalAuthsList {record} />
</div>
@@ -686,18 +730,40 @@
<span class="txt">Cancel</span>
</button>
<button
type="submit"
form={formId}
class="btn btn-expanded"
class:btn-loading={isSaving || isLoading}
disabled={!canSave || isSaving}
>
<span class="txt">{isNew ? "Create" : "Save changes"}</span>
</button>
<div class="btns-group no-gap">
<button
type="submit"
form={formId}
title="Save and close"
class="btn btn-expanded"
class:btn-loading={isSaving || isLoading}
disabled={!canSave || isSaving}
>
<span class="txt">{isNew ? "Create" : "Save changes"}</span>
</button>
{#if !isNew}
<button type="button" class="btn p-l-5 p-r-5 flex-gap-0" disabled={!canSave || isSaving}>
<i class="ri-arrow-down-s-line" aria-hidden="true"></i>
<Toggler class="dropdown dropdown-upside dropdown-right dropdown-nowrap m-b-5">
<button
type="button"
class="dropdown-item closable"
role="menuitem"
on:click={() => save(false)}
>
<span class="txt">Save and continue</span>
</button>
</Toggler>
</button>
{/if}
</div>
</svelte:fragment>
</OverlayPanel>
<ImpersonatePopup bind:this={impersonatePopup} {record} {collection} />
<style>
.panel-title {
line-height: var(--smBtnHeight);
+42 -148
View File
@@ -1,19 +1,16 @@
<script>
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import { collections } from "@/stores/collections";
import Field from "@/components/base/Field.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import SortHeader from "@/components/base/SortHeader.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import Field from "@/components/base/Field.svelte";
import CopyIcon from "@/components/base/CopyIcon.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import Scroller from "@/components/base/Scroller.svelte";
import RecordFieldValue from "@/components/records/RecordFieldValue.svelte";
import { collections } from "@/stores/collections";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
const dispatch = createEventDispatcher();
const sortRegex = /^([\+\-])?(\w+)$/;
@@ -36,6 +33,8 @@
let collumnsToHide = [];
let hiddenColumnsKey = "";
const unusedSuperusersFields = ["verified", "emailVisibility"];
$: if (collection?.id) {
hiddenColumnsKey = collection.id + "@hiddenColumns";
loadStoredHiddenColumns();
@@ -44,15 +43,18 @@
$: isView = collection?.type === "view";
$: isAuth = collection?.type === "auth";
$: isSuperusers = collection?.type === "auth" && collection.name === "_superusers";
$: fields = collection?.schema || [];
// skip unused superusers fields
$: fields = (collection?.fields || []).filter(
(f) => !f.hidden && (!isSuperusers || !unusedSuperusersFields.includes(f.name)),
);
$: editorFields = fields.filter((field) => field.type === "editor");
$: editorFields = fields.filter((f) => f.type === "editor");
$: relFields = fields.filter((field) => field.type === "relation");
$: relFields = fields.filter((f) => f.type === "relation");
$: visibleFields = fields.filter((field) => !hiddenColumns.includes(field.id));
$: visibleFields = fields.filter((f) => !hiddenColumns.includes(f.id));
$: if (collection?.id && sort !== -1 && filter !== -1) {
load(1);
@@ -68,23 +70,11 @@
updateStoredHiddenColumns();
}
$: hasCreated = !isView || (records.length > 0 && typeof records[0].created != "undefined");
$: hasUpdated = !isView || (records.length > 0 && typeof records[0].updated != "undefined");
$: collumnsToHide = [].concat(
isAuth
? [
{ id: "@username", name: "username" },
{ id: "@email", name: "email" },
]
: [],
fields.map((f) => {
$: collumnsToHide = fields
.filter((f) => !f.primaryKey)
.map((f) => {
return { id: f.id, name: f.name };
}),
hasCreated ? { id: "@created", name: "created" } : [],
hasUpdated ? { id: "@updated", name: "updated" } : [],
);
});
function updateStoredHiddenColumns() {
if (!collection?.id) {
@@ -114,7 +104,7 @@
}
export function hasRecord(id) {
return !!records.find((r) => r.id);
return !!records.find((r) => r.id == id);
}
export async function reloadLoadedPages() {
@@ -141,8 +131,8 @@
if (sortMatch && sortRelField) {
const relPresentableFields =
$collections
?.find((c) => c.id == sortRelField.options?.collectionId)
?.schema?.filter((f) => f.presentable)
?.find((c) => c.id == sortRelField.collectionId)
?.fields?.filter((f) => f.presentable)
?.map((f) => f.name) || [];
const parts = [];
@@ -163,12 +153,20 @@
listFields.unshift("*");
}
const expandFields = [];
for (const field of relFields) {
const expandItem = CommonHelper.getExpandPresentableRelField(field, $collections, 2);
if (expandItem) {
expandFields.push(expandItem);
}
}
return ApiClient.collection(collection.id)
.getList(page, perPage, {
sort: listSort,
skipTotal: 1,
filter: CommonHelper.normalizeSearchFilter(filter, fallbackSearchFields),
expand: relFields.map((field) => field.name).join(","),
expand: expandFields.join(","),
fields: listFields.join(","),
requestKey: "records_list",
})
@@ -353,65 +351,23 @@
</th>
{/if}
{#if !hiddenColumns.includes("@id")}
<SortHeader class="col-type-text col-field-id" name="id" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
</div>
</SortHeader>
{/if}
{#if isAuth}
{#if !hiddenColumns.includes("@username")}
<SortHeader class="col-type-text col-field-id" name="username" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("user")} />
<span class="txt">username</span>
</div>
</SortHeader>
{/if}
{#if !hiddenColumns.includes("@email")}
<SortHeader class="col-type-email col-field-email" name="email" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">email</span>
</div>
</SortHeader>
{/if}
{/if}
{#each visibleFields as field (field.name)}
{#each visibleFields as field (field.id)}
<SortHeader
class="col-type-{field.type} col-field-{field.name}"
name={field.name}
bind:sort
>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
{#if field.primaryKey}
<i class={CommonHelper.getFieldTypeIcon("primary")} />
{:else}
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
{/if}
<span class="txt">{field.name}</span>
</div>
</SortHeader>
{/each}
{#if hasCreated && !hiddenColumns.includes("@created")}
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("date")} />
<span class="txt">created</span>
</div>
</SortHeader>
{/if}
{#if hasUpdated && !hiddenColumns.includes("@updated")}
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
<div class="col-header-content">
<i class={CommonHelper.getFieldTypeIcon("date")} />
<span class="txt">updated</span>
</div>
</SortHeader>
{/if}
<th class="col-type-action min-width">
{#if collumnsToHide.length}
<button
@@ -455,74 +411,12 @@
</td>
{/if}
{#if !hiddenColumns.includes("@id")}
<td class="col-type-text col-field-id">
<div class="flex flex-gap-5">
<div class="label">
<CopyIcon value={record.id} />
<div class="txt txt-ellipsis">{record.id}</div>
</div>
{#if isAuth}
{#if record.verified}
<i
class="ri-checkbox-circle-fill txt-sm txt-success"
use:tooltip={"Verified"}
/>
{:else}
<i
class="ri-error-warning-fill txt-sm txt-hint"
use:tooltip={"Unverified"}
/>
{/if}
{/if}
</div>
</td>
{/if}
{#if isAuth}
{#if !hiddenColumns.includes("@username")}
<td class="col-type-text col-field-username">
{#if CommonHelper.isEmpty(record.username)}
<span class="txt-hint">N/A</span>
{:else}
<span class="txt txt-ellipsis" title={record.username}>
{record.username}
</span>
{/if}
</td>
{/if}
{#if !hiddenColumns.includes("@email")}
<td class="col-type-text col-field-email">
{#if CommonHelper.isEmpty(record.email)}
<span class="txt-hint">N/A</span>
{:else}
<span class="txt txt-ellipsis" title={record.email}>
{record.email}
</span>
{/if}
</td>
{/if}
{/if}
{#each visibleFields as field (field.name)}
{#each visibleFields as field (field.id)}
<td class="col-type-{field.type} col-field-{field.name}">
<RecordFieldValue short {record} {field} />
</td>
{/each}
{#if hasCreated && !hiddenColumns.includes("@created")}
<td class="col-type-date col-field-created">
<FormattedDate date={record.created} />
</td>
{/if}
{#if hasUpdated && !hiddenColumns.includes("@updated")}
<td class="col-type-date col-field-updated">
<FormattedDate date={record.updated} />
</td>
{/if}
<td class="col-type-action min-width">
<i class="ri-arrow-right-line" />
</td>
+24 -3
View File
@@ -28,9 +28,9 @@
let isLoadingList = false;
let isLoadingSelected = false;
$: maxSelect = field?.options?.maxSelect || null;
$: maxSelect = field?.maxSelect || null;
$: collectionId = field?.options?.collectionId;
$: collectionId = field?.collectionId;
$: collection = $collections.find((c) => c.id == collectionId) || null;
@@ -44,7 +44,7 @@
$: canLoadMore = lastItemsCount == batchSize;
$: canSelectMore = maxSelect === null || maxSelect > selected.length;
$: canSelectMore = maxSelect <= 0 || maxSelect > selected.length;
export function show() {
filter = "";
@@ -60,6 +60,25 @@
return pickerPanel?.hide();
}
function getExpand() {
let expand = "";
const presentableRelFields = collection?.fields?.filter(
(f) => !f.hidden && f.presentable && f.type == "relation",
);
for (const field of presentableRelFields) {
const expandItem = CommonHelper.getExpandPresentableRelField(field, $collections, 2);
if (expandItem) {
if (expand != "") {
expand += ",";
}
expand += expandItem;
}
}
return expand;
}
async function loadSelected() {
const selectedIds = CommonHelper.toArray(value);
@@ -85,6 +104,7 @@
batch: batchSize,
filter: filters.join("||"),
fields: "*:excerpt(200)",
expand: getExpand(),
requestKey: null,
}),
);
@@ -144,6 +164,7 @@
sort: !isView ? "-created" : "",
fields: "*:excerpt(200)",
skipTotal: 1,
expand: getExpand(),
requestKey: uniqueId + "loadList",
});
@@ -1,79 +1,65 @@
<script>
import { slide } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { confirm } from "@/stores/confirmation";
import { removeError } from "@/stores/errors";
import Field from "@/components/base/Field.svelte";
import SecretGeneratorButton from "@/components/base/SecretGeneratorButton.svelte";
import { confirm } from "@/stores/confirmation";
import { removeError } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import { slide } from "svelte/transition";
export let record;
export let collection;
export let isNew = !record?.id;
let originalUsername = record.username || null;
$: isSuperusers = collection?.name == "_superusers";
$: emailField = collection?.fields?.find((f) => f.name == "email") || {};
$: passwordField = collection?.fields?.find((f) => f.name == "password") || {};
let changePasswordToggle = false;
$: if (!record.username && record.username !== null) {
record.username = null;
}
$: if (!changePasswordToggle) {
record.password = null;
record.passwordConfirm = null;
record.password = undefined;
record.passwordConfirm = undefined;
removeError("password");
removeError("passwordConfirm");
}
</script>
<div class="grid m-b-base">
<div class="col-lg-6">
<Field class="form-field {!isNew ? 'required' : ''}" name="username" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("user")} />
<span class="txt">Username</span>
</label>
<input
type="text"
requried={!isNew}
placeholder={isNew ? "Leave empty to auto generate..." : originalUsername}
id={uniqueId}
bind:value={record.username}
/>
</Field>
</div>
<div class="col-lg-6">
<Field
class="form-field {collection.options?.requireEmail ? 'required' : ''}"
name="email"
let:uniqueId
>
<div class="col-lg-12">
<Field class="form-field {emailField?.required ? 'required' : ''}" name="email" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">Email</span>
<span class="txt">email</span>
</label>
<div class="form-field-addon email-visibility-addon">
<button
type="button"
class="btn btn-sm btn-transparent {record.emailVisibility ? 'btn-success' : 'btn-hint'}"
use:tooltip={{
text: "Make email public or private",
position: "top-right",
}}
on:click|preventDefault={() => (record.emailVisibility = !record.emailVisibility)}
>
<span class="txt">Public: {record.emailVisibility ? "On" : "Off"}</span>
</button>
</div>
{#if !isSuperusers}
<div class="form-field-addon email-visibility-addon">
<button
type="button"
class="btn btn-sm btn-transparent {record.emailVisibility
? 'btn-success'
: 'btn-hint'}"
use:tooltip={{
text: "Make email public or private",
position: "top-right",
}}
on:click|preventDefault={() => (record.emailVisibility = !record.emailVisibility)}
>
<span class="txt">Public: {record.emailVisibility ? "On" : "Off"}</span>
</button>
</div>
{/if}
<!-- svelte-ignore a11y-autofocus -->
<input
type="email"
autofocus={isNew}
autocomplete="off"
id={uniqueId}
required={collection.options?.requireEmail}
required={emailField.required}
bind:value={record.email}
/>
</Field>
@@ -104,9 +90,7 @@
bind:value={record.password}
/>
<div class="form-field-addon">
<SecretGeneratorButton
length={Math.max(15, collection?.options?.minPasswordLength || 0)}
/>
<SecretGeneratorButton length={Math.max(15, passwordField.min || 0)} />
</div>
</Field>
</div>
@@ -130,28 +114,30 @@
{/if}
</div>
<div class="col-lg-12">
<Field class="form-field form-field-toggle" name="verified" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={record.verified}
on:change|preventDefault={(e) => {
if (isNew) {
return; // no confirmation required
}
confirm(
`Do you really want to manually change the verified account state?`,
() => {},
() => {
record.verified = !e.target.checked;
},
);
}}
/>
<label for={uniqueId}>Verified</label>
</Field>
</div>
{#if !isSuperusers}
<div class="col-lg-12">
<Field class="form-field form-field-toggle" name="verified" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
bind:checked={record.verified}
on:change|preventDefault={(e) => {
if (isNew) {
return; // no confirmation required
}
confirm(
`Do you really want to manually change the verified account state?`,
() => {},
() => {
record.verified = !e.target.checked;
},
);
}}
/>
<label for={uniqueId}>Verified</label>
</Field>
</div>
{/if}
</div>
<style>
@@ -1,5 +1,6 @@
<script>
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = false;
@@ -7,5 +8,5 @@
<Field class="form-field form-field-toggle {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={value} />
<label for={uniqueId}>{field.name}</label>
<FieldLabel {uniqueId} {field} icon={false} />
</Field>
@@ -1,8 +1,9 @@
<script>
import Flatpickr from "svelte-flatpickr";
import tooltip from "@/actions/tooltip";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import tooltip from "@/actions/tooltip";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
@@ -33,10 +34,7 @@
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name} (UTC)</span>
</label>
<FieldLabel {uniqueId} {field} />
{#if value && !field.required}
<div class="form-field-addon">
@@ -1,10 +1,11 @@
<script>
import { onMount } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import RecordFilePicker from "@/components/records/RecordFilePicker.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = "";
@@ -15,7 +16,7 @@
let mountedTimeoutId = null;
$: conf = Object.assign(CommonHelper.defaultEditorOptions(), {
convert_urls: field.options?.convertUrls,
convert_urls: field.convertURLs,
relative_urls: false,
});
@@ -42,10 +43,8 @@
</script>
<Field class="form-field form-field-editor {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
{#if mounted}
<TinyMCE
id={uniqueId}
@@ -71,9 +70,9 @@
editor?.execCommand(
"InsertImage",
false,
ApiClient.files.getUrl(e.detail.record, e.detail.name, {
ApiClient.files.getURL(e.detail.record, e.detail.name, {
thumb: e.detail.size,
})
}),
);
}}
/>
@@ -1,15 +1,13 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
<input type="email" id={uniqueId} required={field.required} bind:value />
</Field>
@@ -0,0 +1,25 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let uniqueId;
export let field;
export let icon = true;
</script>
<label for={uniqueId}>
{#if icon}
{#if field.primaryKey}
<i class={CommonHelper.getFieldTypeIcon("primary")} />
{:else}
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
{/if}
{/if}
<span class="txt">{field.name}</span>
{#if field.hidden}
<small class="label label-sm label-danger">Hidden</small>
{/if}
<slot />
</label>
@@ -1,12 +1,13 @@
<script>
import { onMount } from "svelte";
import tooltip from "@/actions/tooltip";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Draggable from "@/components/base/Draggable.svelte";
import Field from "@/components/base/Field.svelte";
import UploadedFilePreview from "@/components/base/UploadedFilePreview.svelte";
import RecordFileThumb from "@/components/records/RecordFileThumb.svelte";
import { onMount } from "svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let record;
export let field;
@@ -29,7 +30,7 @@
deletedFileNames = CommonHelper.toArray(deletedFileNames);
}
$: isMultiple = field.options?.maxSelect > 1;
$: isMultiple = field.maxSelect != 1;
$: if (CommonHelper.isEmpty(value)) {
value = isMultiple ? [] : "";
@@ -39,7 +40,7 @@
$: maxReached =
(valueAsArray.length || uploadedFiles.length) &&
field.options?.maxSelect <= valueAsArray.length + uploadedFiles.length - deletedFileNames.length;
field.maxSelect <= valueAsArray.length + uploadedFiles.length - deletedFileNames.length;
$: if (uploadedFiles !== -1 || deletedFileNames !== -1) {
triggerListChange();
@@ -68,7 +69,7 @@
new CustomEvent("change", {
detail: { value, uploadedFiles, deletedFileNames },
bubbles: true,
})
}),
);
}
@@ -86,7 +87,7 @@
for (const file of files) {
const currentTotal = valueAsArray.length + uploadedFiles.length - deletedFileNames.length;
if (field.options?.maxSelect <= currentTotal) {
if (field.maxSelect <= currentTotal) {
break;
}
@@ -97,7 +98,7 @@
}
onMount(async () => {
fileToken = await ApiClient.getAdminFileToken(record.collectionId);
fileToken = await ApiClient.getSuperuserFileToken(record.collectionId);
});
</script>
@@ -121,10 +122,7 @@
name={field.name}
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
<div bind:this={filesListElem} class="list">
{#each valueAsArray as filename, i (filename + record.id)}
@@ -145,7 +143,7 @@
<div class="content">
<a
draggable={false}
href={ApiClient.files.getUrl(record, filename, { token: fileToken })}
href={ApiClient.files.getURL(record, filename, { token: fileToken })}
class="txt-ellipsis {isDeleted
? 'txt-strikethrough txt-hint'
: 'link-primary'}"
@@ -215,7 +213,7 @@
bind:this={fileInput}
type="file"
class="hidden"
accept={field.options?.mimeTypes?.join(",") || null}
accept={field.mimeTypes?.join(",") || null}
multiple={isMultiple}
on:change={() => {
for (let file of fileInput.files) {
@@ -1,8 +1,8 @@
<script>
import { onMount } from "svelte";
import tooltip from "@/actions/tooltip";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
@@ -45,9 +45,7 @@
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
<FieldLabel {uniqueId} {field}>
<span
class="json-state"
use:tooltip={{ position: "left", text: isValid ? "Valid JSON" : "Invalid JSON" }}
@@ -58,7 +56,8 @@
<i class="ri-error-warning-fill txt-danger" />
{/if}
</span>
</label>
</FieldLabel>
{#if editorComponent}
<svelte:component
this={editorComponent}
@@ -1,22 +1,20 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
<input
type="number"
id={uniqueId}
required={field.required}
min={field.options?.min}
max={field.options?.max}
min={field.min}
max={field.max}
step="any"
bind:value
/>
@@ -0,0 +1,13 @@
<script>
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<FieldLabel {uniqueId} {field} />
<input type="password" id={uniqueId} autocomplete="new-password" required={field.required} bind:value />
</Field>
@@ -1,12 +1,14 @@
<script>
import { onDestroy } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import { collections } from "@/stores/collections";
import Draggable from "@/components/base/Draggable.svelte";
import RecordsPicker from "@/components/records/RecordsPicker.svelte";
import Field from "@/components/base/Field.svelte";
import RecordInfo from "@/components/records/RecordInfo.svelte";
import RecordsPicker from "@/components/records/RecordsPicker.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
const batchSize = 100;
@@ -20,7 +22,7 @@
let loadTimeoutId;
let invalidIds = [];
$: isMultiple = field.options?.maxSelect != 1;
$: isMultiple = field.maxSelect != 1;
$: if (typeof value != "undefined") {
fieldRef?.changed();
@@ -55,13 +57,27 @@
list = [];
invalidIds = [];
if (!field?.options?.collectionId || !ids.length) {
if (!field?.collectionId || !ids.length) {
isLoading = false;
return;
}
isLoading = true;
let expand = "";
const presentableRelFields = $collections
.find((c) => c.id == field.collectionId)
?.fields?.filter((f) => !f.hidden && f.presentable && f.type == "relation");
for (const field of presentableRelFields) {
const expandItem = CommonHelper.getExpandPresentableRelField(field, $collections, 2);
if (expandItem) {
if (expand != "") {
expand += ",";
}
expand += expandItem;
}
}
// batch load all selected records to avoid parser stack overflow errors
const filterIds = ids.slice();
const loadPromises = [];
@@ -72,9 +88,10 @@
}
loadPromises.push(
ApiClient.collection(field?.options?.collectionId).getFullList(batchSize, {
ApiClient.collection(field.collectionId).getFullList(batchSize, {
filter: filters.join("||"),
fields: "*:excerpt(200)",
expand: expand,
requestKey: null,
}),
);
@@ -134,9 +151,7 @@
name={field.name}
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
<FieldLabel {uniqueId} {field}>
{#if invalidIds.length}
<i
class="ri-error-warning-line link-hint m-l-auto flex-order-10"
@@ -148,7 +163,7 @@
}}
/>
{/if}
</label>
</FieldLabel>
<div class="list">
<div class="relations-list">
@@ -1,37 +1,40 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Select from "@/components/base/Select.svelte";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
$: isMultiple = field.options?.maxSelect > 1;
$: isMultiple = field.maxSelect != 1;
$: if (typeof value === "undefined") {
value = isMultiple ? [] : "";
}
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
value = value.slice(value.length - field.options.maxSelect);
$: maxSelect = field.maxSelect || field.values.length;
$: if (isMultiple && Array.isArray(value)) {
value = value.filter((v) => field.values.includes(v));
if (value.length > maxSelect) {
value = value.slice(value.length - maxSelect);
}
}
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
<Select
id={uniqueId}
toggle={!field.required || isMultiple}
multiple={isMultiple}
closable={!isMultiple || value?.length >= field.options?.maxSelect}
items={field.options?.values}
searchable={field.options?.values?.length > 5}
closable={!isMultiple || value?.length >= field.maxSelect}
items={field.values}
searchable={field.values?.length > 5}
bind:selected={value}
/>
{#if field.options?.maxSelect > 1}
<div class="help-block">Select up to {field.options.maxSelect} items.</div>
{#if field.maxSelect != 1}
<div class="help-block">Select up to {maxSelect} items.</div>
{/if}
</Field>
@@ -2,15 +2,24 @@
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import AutoExpandTextarea from "@/components/base/AutoExpandTextarea.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let original;
export let field;
export let value = undefined;
$: hasAutogenerate = !CommonHelper.isEmpty(field.autogeneratePattern) && !original?.id;
$: isRequired = field.required && !hasAutogenerate;
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<AutoExpandTextarea id={uniqueId} required={field.required} bind:value />
<Field class="form-field {isRequired ? 'required' : ''}" name={field.name} let:uniqueId>
<FieldLabel {uniqueId} {field} />
<AutoExpandTextarea
id={uniqueId}
required={isRequired}
placeholder={hasAutogenerate ? "Leave empty to autogenerate..." : ""}
bind:value
/>
</Field>
@@ -1,15 +1,13 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import FieldLabel from "@/components/records/fields/FieldLabel.svelte";
export let field;
export let value = undefined;
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<FieldLabel {uniqueId} {field} />
<input type="url" id={uniqueId} required={field.required} bind:value />
</Field>