initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions
@@ -0,0 +1,87 @@
<script>
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import providersList from "@/providers.js";
const dispatch = createEventDispatcher();
export let record;
let externalAuths = [];
let isLoading = false;
function getProviderTitle(provider) {
return providersList[provider + "Auth"]?.title || CommonHelper.sentenize(auth.provider, false);
}
function getProviderIcon(provider) {
return providersList[provider + "Auth"]?.icon || `ri-${provider}-line`;
}
async function loadExternalAuths() {
if (!record?.id) {
externalAuths = [];
isLoading = false;
return;
}
isLoading = true;
try {
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
function unlinkExternalAuth(provider) {
if (!record?.id || !provider) {
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.errorResponseHandler(err);
});
});
}
loadExternalAuths();
</script>
{#if isLoading}
<div class="block txt-center">
<span class="loader" />
</div>
{:else if record?.id && externalAuths.length}
<div class="list">
{#each externalAuths as auth}
<div class="list-item">
<i class={getProviderIcon(auth.provider)} />
<span class="txt">{getProviderTitle(auth.provider)}</span>
<div class="txt-hint">ID: {auth.providerId}</div>
<button
type="button"
class="btn btn-secondary link-hint btn-circle btn-sm m-l-auto"
on:click={() => unlinkExternalAuth(auth.provider)}
>
<i class="ri-close-line" />
</button>
</div>
{/each}
</div>
{:else}
<p class="txt-hint txt-center">No linked OAuth2 providers.</p>
{/if}
@@ -0,0 +1,78 @@
<script>
import PocketBase, { getTokenPayload } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
import Field from "@/components/base/Field.svelte";
export let params;
let password = "";
let isLoading = false;
let success = false;
$: newEmail = CommonHelper.getJWTPayload(params?.token).newEmail || "";
async function submit() {
if (isLoading) {
return;
}
isLoading = true;
// init a custom client to avoid interfering with the admin state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client.collection(payload.collectionId).confirmEmailChange(params?.token, password);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Successfully changed the user email address.</p>
<p>You can now sign in with your new email address.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<form on:submit|preventDefault={submit}>
<div class="content txt-center m-b-base">
<h5>
Type your password to confirm changing your email address
{#if newEmail}
to <strong class="txt-nowrap">{newEmail}</strong>
{/if}
</h5>
</div>
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>Password</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="password" id={uniqueId} required autofocus bind:value={password} />
</Field>
<button
type="submit"
class="btn btn-lg btn-block"
class:btn-loading={isLoading}
disabled={isLoading}
>
<span class="txt">Confirm new email</span>
</button>
</form>
{/if}
</FullPage>
@@ -0,0 +1,86 @@
<script>
import PocketBase, { getTokenPayload } from "pocketbase";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import FullPage from "@/components/base/FullPage.svelte";
import Field from "@/components/base/Field.svelte";
export let params;
let newPassword = "";
let newPasswordConfirm = "";
let isLoading = false;
let success = false;
$: email = CommonHelper.getJWTPayload(params?.token).email || "";
async function submit() {
if (isLoading) {
return;
}
isLoading = true;
// init a custom client to avoid interfering with the admin state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
const payload = getTokenPayload(params?.token);
await client
.collection(payload.collectionId)
.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Successfully changed the user password.</p>
<p>You can now sign in with your new password.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<form on:submit|preventDefault={submit}>
<div class="content txt-center m-b-base">
<h5>
Reset your user password
{#if email}
for <strong>{email}</strong>
{/if}
</h5>
</div>
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>New password</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="password" id={uniqueId} required autofocus bind:value={newPassword} />
</Field>
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
<label for={uniqueId}>New password confirm</label>
<input type="password" id={uniqueId} required bind:value={newPasswordConfirm} />
</Field>
<button
type="submit"
class="btn btn-lg btn-block"
class:btn-loading={isLoading}
disabled={isLoading}
>
<span class="txt">Set new password</span>
</button>
</form>
{/if}
</FullPage>
@@ -0,0 +1,60 @@
<script>
import PocketBase, { getTokenPayload } from "pocketbase";
import FullPage from "@/components/base/FullPage.svelte";
export let params;
let success = false;
let isLoading = false;
send();
async function send() {
isLoading = true;
// init a custom client to avoid interfering with the admin 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;
} catch (err) {
success = false;
}
isLoading = false;
}
</script>
<FullPage nobranding>
{#if isLoading}
<div class="txt-center">
<div class="loader loader-lg">
<em>Please wait...</em>
</div>
</div>
{:else if success}
<div class="alert alert-success">
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
<div class="content txt-bold">
<p>Successfully verified email address.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{:else}
<div class="alert alert-danger">
<div class="icon"><i class="ri-error-warning-line" /></div>
<div class="content txt-bold">
<p>Invalid or expired verification token.</p>
</div>
</div>
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
Close
</button>
{/if}
</FullPage>
+12 -3
View File
@@ -5,6 +5,7 @@
activeCollection,
isCollectionsLoading,
loadCollections,
changeActiveCollectionById,
} from "@/stores/collections";
import tooltip from "@/actions/tooltip";
import { pageTitle, hideControls } from "@/stores/app";
@@ -13,7 +14,7 @@
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/docs/CollectionDocsPanel.svelte";
import CollectionDocsPanel from "@/components/collections/CollectionDocsPanel.svelte";
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
import RecordsList from "@/components/records/RecordsList.svelte";
@@ -29,7 +30,15 @@
let sort = queryParams.get("sort") || "-created";
let selectedCollectionId = queryParams.get("collectionId") || "";
$: viewableCollections = $collections.filter((c) => c.name != import.meta.env.PB_PROFILE_COLLECTION);
$: reactiveParams = new URLSearchParams($querystring);
$: if (
!$isCollectionsLoading &&
reactiveParams.has("collectionId") &&
reactiveParams.get("collectionId") != selectedCollectionId
) {
changeActiveCollectionById(reactiveParams.get("collectionId"));
}
// reset filter and sort on collection change
$: if ($activeCollection?.id && selectedCollectionId != $activeCollection.id) {
@@ -62,7 +71,7 @@
<h1>Loading collections...</h1>
</div>
</PageWrapper>
{:else if !viewableCollections.length}
{:else if !$collections.length}
<PageWrapper center>
<div class="placeholder-section m-b-base">
<div class="icon">
@@ -25,7 +25,7 @@
class="txt-ellipsis"
href={record[field.name]}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
use:tooltip={"Open in new tab"}
on:click|stopPropagation
>
@@ -45,9 +45,12 @@
</div>
{:else if field.type === "relation" || field.type === "user"}
<div class="inline-flex">
{#each CommonHelper.toArray(record[field.name]) as item, i (i + item)}
{#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}
</div>
{:else if field.type === "file"}
<div class="inline-flex">
@@ -13,7 +13,7 @@
$: hasPreview = CommonHelper.hasImageExtension(filename);
$: if (hasPreview) {
originalUrl = ApiClient.records.getFileUrl(record, `${filename}`);
originalUrl = ApiClient.getFileUrl(record, `${filename}`);
}
$: thumbUrl = originalUrl ? originalUrl + "?thumb=100x100" : "";
+67 -8
View File
@@ -3,6 +3,7 @@
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);
@@ -21,8 +22,12 @@
let totalItems = 0;
let isLoadingList = false;
let isLoadingSelected = false;
let isLoadingCollection = false;
let collection = null;
let upsertPanel;
$: if (collectionId) {
loadCollection();
loadSelected().then(() => {
loadList(true);
});
@@ -32,6 +37,26 @@
$: 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);
@@ -41,21 +66,34 @@
isLoadingSelected = true;
try {
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 selectedIds) {
for (const id of filterIds.splice(0, 50)) {
filters.push(`id="${id}"`);
}
const result = await ApiClient.records.getFullList(collectionId, 200, {
filter: filters.join("||"),
$cancelKey: uniqueId + "loadSelected",
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(result, "id", id);
const item = CommonHelper.findByKey(loadedItems, "id", id);
if (item) {
selected.push(item);
}
@@ -80,7 +118,7 @@
try {
const page = reset ? 1 : currentPage + 1;
const result = await ApiClient.records.getList(collectionId, page, 200, {
const result = await ApiClient.collection(collectionId).getList(page, 200, {
sort: "-created",
$cancelKey: uniqueId + "loadList",
});
@@ -108,6 +146,7 @@
searchable={list.length > 5}
selectionKey="id"
labelComponent={optionComponent}
disabled={isLoading}
{optionComponent}
{multiple}
bind:keyOfSelected
@@ -118,10 +157,19 @@
{...$$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"
class="btn btn-block btn-sm m-t-5"
class:btn-loading={isLoadingList}
class:btn-disabled={isLoadingList}
on:click|stopPropagation={() => loadList()}
@@ -131,3 +179,14 @@
{/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);
}}
/>
@@ -13,13 +13,15 @@
const props = [
// prioritized common displayable props
"name",
"title",
"name",
"email",
"username",
"label",
"key",
"email",
"heading",
"content",
"description",
// fallback to the available props
...Object.keys(model),
];
@@ -3,12 +3,14 @@
import { Record } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import ApiClient from "@/utils/ApiClient";
import tooltip from "@/actions/tooltip";
import { setErrors } from "@/stores/errors";
import { confirm } from "@/stores/confirmation";
import { addSuccessToast } from "@/stores/toasts";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.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";
@@ -19,10 +21,13 @@
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 UserField from "@/components/records/fields/UserField.svelte";
import ExternalAuthsList from "@/components/records/ExternalAuthsList.svelte";
const dispatch = createEventDispatcher();
const formId = "record_" + CommonHelper.randomString(5);
const TAB_FORM = "form";
const TAB_PROVIDERS = "providers";
export let collection;
@@ -34,6 +39,7 @@
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...}
let initialFormHash = "";
let activeTab = TAB_FORM;
$: hasFileChanges =
CommonHelper.hasNonEmptyProps(uploadedFilesMap) ||
@@ -43,13 +49,13 @@
$: canSave = record.isNew || hasChanges;
$: isProfileCollection = collection?.name !== import.meta.env.PB_PROFILE_COLLECTION;
export function show(model) {
load(model);
confirmClose = true;
activeTab = TAB_FORM;
return recordPanel?.show();
}
@@ -60,7 +66,11 @@
async function load(model) {
setErrors({}); // reset errors
original = model || {};
record = model?.clone ? model.clone() : new Record();
if (model?.clone) {
record = model.clone();
} else {
record = new Record();
}
uploadedFilesMap = {};
deletedFileIndexesMap = {};
await tick(); // wait to populate the fields to get the normalized values
@@ -72,7 +82,7 @@
}
function save() {
if (isSaving || !canSave) {
if (isSaving || !canSave || !collection?.id) {
return;
}
@@ -82,13 +92,13 @@
let request;
if (record.isNew) {
request = ApiClient.records.create(collection?.id, data);
request = ApiClient.collection(collection.id).create(data);
} else {
request = ApiClient.records.update(collection?.id, record.id, data);
request = ApiClient.collection(collection.id).update(record.id, data);
}
request
.then(async (result) => {
.then((result) => {
addSuccessToast(
record.isNew ? "Successfully created record." : "Successfully updated record."
);
@@ -110,7 +120,8 @@
}
confirm(`Do you really want to delete the selected record?`, () => {
return ApiClient.records.delete(original["@collectionId"], original.id)
return ApiClient.collection(original.collectionId)
.delete(original.id)
.then(() => {
hide();
addSuccessToast("Successfully deleted record.");
@@ -126,15 +137,24 @@
const data = record?.export() || {};
const formData = new FormData();
const schemaMap = {};
const exportableFields = {};
for (const field of collection?.schema || []) {
schemaMap[field.name] = field;
exportableFields[field.name] = true;
}
if (collection?.isAuth) {
exportableFields["username"] = true;
exportableFields["email"] = true;
exportableFields["emailVisibility"] = true;
exportableFields["password"] = true;
exportableFields["passwordConfirm"] = true;
exportableFields["verified"] = true;
}
// export base fields
for (const key in data) {
// skip non-schema fields
if (!schemaMap[key]) {
if (!exportableFields[key]) {
continue;
}
@@ -164,11 +184,45 @@
return formData;
}
function sendVerificationEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent verification email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestVerification(original.email)
.then(() => {
addSuccessToast(`Successfully sent verification email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
});
}
function sendPasswordResetEmail() {
if (!collection?.id || !original?.email) {
return;
}
confirm(`Do you really want to sent password reset email to ${original.email}?`, () => {
return ApiClient.collection(collection.id)
.requestPasswordReset(original.email)
.then(() => {
addSuccessToast(`Successfully sent password reset email to ${original.email}.`);
})
.catch((err) => {
ApiClient.errorResponseHandler(err);
});
});
}
</script>
<OverlayPanel
bind:this={recordPanel}
class="overlay-panel-lg record-panel"
class="overlay-panel-lg record-panel {collection?.isAuth && !record.isNew ? 'colored-header' : ''}"
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
@@ -177,6 +231,7 @@
});
return false;
}
setErrors({});
return true;
}}
on:hide
@@ -185,73 +240,143 @@
<svelte:fragment slot="header">
<h4>
{record.isNew ? "New" : "Edit"}
{collection.name} record
<strong>{collection?.name}</strong> record
</h4>
{#if !record.isNew && isProfileCollection}
{#if !record.isNew}
<div class="flex-fill" />
<button type="button" class="btn btn-sm btn-circle btn-secondary">
<div class="content">
<i class="ri-more-line" />
<Toggler class="dropdown dropdown-right m-t-5">
<div tabindex="0" class="dropdown-item closable" on:click={() => deleteConfirm()}>
<Toggler class="dropdown dropdown-right dropdown-nowrap">
{#if collection.isAuth && !original.verified && original.email}
<button
type="button"
class="dropdown-item closable"
on:click={() => sendVerificationEmail()}
>
<i class="ri-mail-check-line" />
<span class="txt">Send verification email</span>
</button>
{/if}
{#if collection.isAuth && original.email}
<button
type="button"
class="dropdown-item closable"
on:click={() => sendPasswordResetEmail()}
>
<i class="ri-mail-lock-line" />
<span class="txt">Send password reset email</span>
</button>
{/if}
<button
type="button"
class="dropdown-item txt-danger closable"
on:click|preventDefault|stopPropagation={() => deleteConfirm()}
>
<i class="ri-delete-bin-7-line" />
<span class="txt">Delete</span>
</div>
</button>
</Toggler>
</div>
</button>
{/if}
{#if collection.isAuth && !record.isNew}
<div class="tabs-header stretched">
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_FORM}
on:click={() => (activeTab = TAB_FORM)}
>
Account
</button>
<button
type="button"
class="tab-item"
class:active={activeTab === TAB_PROVIDERS}
on:click={() => (activeTab = TAB_PROVIDERS)}
>
Authorized providers
</button>
</div>
{/if}
</svelte:fragment>
<form id={formId} class="block" on:submit|preventDefault={save}>
{#if !record.isNew}
<Field class="form-field disabled" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
<span class="flex-fill" />
</label>
<input type="text" id={uniqueId} value={record.id} disabled />
</Field>
{/if}
{#each collection?.schema || [] as field (field.name)}
{#if field.type === "text"}
<TextField {field} bind:value={record[field.name]} />
{:else if field.type === "number"}
<NumberField {field} bind:value={record[field.name]} />
{:else if field.type === "bool"}
<BoolField {field} bind:value={record[field.name]} />
{:else if field.type === "email"}
<EmailField {field} bind:value={record[field.name]} />
{:else if field.type === "url"}
<UrlField {field} bind:value={record[field.name]} />
{:else if field.type === "date"}
<DateField {field} bind:value={record[field.name]} />
{:else if field.type === "select"}
<SelectField {field} bind:value={record[field.name]} />
{:else if field.type === "json"}
<JsonField {field} bind:value={record[field.name]} />
{:else if field.type === "file"}
<FileField
{field}
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileIndexes={deletedFileIndexesMap[field.name]}
/>
{:else if field.type === "relation"}
<RelationField {field} bind:value={record[field.name]} />
{:else if field.type === "user"}
<UserField {field} bind:value={record[field.name]} />
<div class="tabs-content">
<form
id={formId}
class="tab-item"
class:active={activeTab === TAB_FORM}
on:submit|preventDefault={save}
>
{#if !record.isNew}
<Field class="form-field disabled" name="id" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("primary")} />
<span class="txt">id</span>
<span class="flex-fill" />
</label>
<div class="form-field-addon">
<i
class="ri-calendar-event-line txt-disabled"
use:tooltip={{
text: `Created: ${record.created}\nUpdated: ${record.updated}`,
position: "left",
}}
/>
</div>
<input type="text" id={uniqueId} value={record.id} readonly />
</Field>
{/if}
{:else}
<div class="block txt-center txt-disabled">
<h5>No custom fields to be set</h5>
{#if collection?.isAuth}
<AuthFields bind:record {collection} />
<hr />
{/if}
{#each collection?.schema || [] as field (field.name)}
{#if field.type === "text"}
<TextField {field} bind:value={record[field.name]} />
{:else if field.type === "number"}
<NumberField {field} bind:value={record[field.name]} />
{:else if field.type === "bool"}
<BoolField {field} bind:value={record[field.name]} />
{:else if field.type === "email"}
<EmailField {field} bind:value={record[field.name]} />
{:else if field.type === "url"}
<UrlField {field} bind:value={record[field.name]} />
{:else if field.type === "date"}
<DateField {field} bind:value={record[field.name]} />
{:else if field.type === "select"}
<SelectField {field} bind:value={record[field.name]} />
{:else if field.type === "json"}
<JsonField {field} bind:value={record[field.name]} />
{:else if field.type === "file"}
<FileField
{field}
{record}
bind:value={record[field.name]}
bind:uploadedFiles={uploadedFilesMap[field.name]}
bind:deletedFileIndexes={deletedFileIndexesMap[field.name]}
/>
{:else if field.type === "relation"}
<RelationField {field} bind:value={record[field.name]} />
{/if}
{:else}
<div class="block txt-center txt-disabled">
<h5>No custom fields to be set</h5>
</div>
{/each}
</form>
{#if collection.isAuth && !record.isNew}
<div class="tab-item" class:active={activeTab === TAB_PROVIDERS}>
<ExternalAuthsList {record} />
</div>
{/each}
</form>
{/if}
</div>
<svelte:fragment slot="footer">
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
+149 -15
View File
@@ -3,11 +3,15 @@
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 SortHeader from "@/components/base/SortHeader.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import Field from "@/components/base/Field.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
import IdLabel from "@/components/base/IdLabel.svelte";
import HorizontalScroller from "@/components/base/HorizontalScroller.svelte";
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
const dispatch = createEventDispatcher();
@@ -22,8 +26,12 @@
let bulkSelected = {};
let isLoading = true;
let isDeleting = false;
let yieldedRecordsId = 0;
let hiddenColumns = [];
let columnsTrigger;
$: if (collection?.id) {
loadStoredHiddenColumns();
clearList();
}
@@ -35,43 +43,84 @@
$: fields = collection?.schema || [];
$: visibleFields = fields.filter((field) => !hiddenColumns.includes(field.id));
$: totalBulkSelected = Object.keys(bulkSelected).length;
$: areAllRecordsSelected = records.length && totalBulkSelected === records.length;
$: if (hiddenColumns !== -1) {
updateStoredHiddenColumns();
}
function updateStoredHiddenColumns() {
if (!collection?.id) {
return;
}
localStorage.setItem(collection?.id + "@hiddenCollumns", JSON.stringify(hiddenColumns));
}
function loadStoredHiddenColumns() {
hiddenColumns = [];
if (!collection?.id) {
return;
}
try {
const encoded = localStorage.getItem(collection.id + "@hiddenCollumns");
if (encoded) hiddenColumns = JSON.parse(encoded) || [];
} catch (_) {}
}
export async function reloadLoadedPages() {
const loadedPages = currentPage;
for (let i = 1; i <= loadedPages; i++) {
if (i === 1 || canLoadMore) {
await load(i);
await load(i, false);
}
}
}
export async function load(page = 1) {
export async function load(page = 1, breakTasks = true) {
if (!collection?.id) {
return;
}
isLoading = true;
return ApiClient.records
.getList(collection.id, page, 50, {
return ApiClient.collection(collection.id)
.getList(page, 30, {
sort: sort,
filter: filter,
})
.then((result) => {
.then(async (result) => {
if (page <= 1) {
clearList();
}
isLoading = false;
records = records.concat(result.items);
currentPage = result.page;
totalRecords = result.totalItems;
dispatch("load", records.concat(result.items));
dispatch("load", records);
// optimize the records listing by rendering the rows in task batches
if (breakTasks) {
const currentYieldId = ++yieldedRecordsId;
while (result.items.length) {
if (yieldedRecordsId != currentYieldId) {
break; // new yeild has been started
}
records = records.concat(result.items.splice(0, 15));
await CommonHelper.yieldToMain();
}
} else {
records = records.concat(result.items);
}
})
.catch((err) => {
if (!err?.isAbort) {
@@ -128,13 +177,13 @@
}
async function deleteSelected() {
if (isDeleting || !totalBulkSelected) {
if (isDeleting || !totalBulkSelected || !collection?.id) {
return;
}
let promises = [];
for (const recordId of Object.keys(bulkSelected)) {
promises.push(ApiClient.records.delete(collection?.id, recordId));
promises.push(ApiClient.collection(collection.id).delete(recordId));
}
isDeleting = true;
@@ -158,7 +207,31 @@
}
</script>
<div class="table-wrapper">
<HorizontalScroller class="table-wrapper">
<svelte:fragment slot="before">
<Toggler class="dropdown dropdown-right dropdown-nowrap columns-dropdown" trigger={columnsTrigger}>
<div class="txt-hint txt-sm p-5 m-b-5">Toggle Columns</div>
{#each fields as field (field.id + field.name)}
<Field class="form-field form-field-sm form-field-toggle m-0 p-5" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
checked={!hiddenColumns.includes(field.id)}
on:change={(e) => {
if (e.target.checked) {
CommonHelper.removeByValue(hiddenColumns, field.id);
} else {
CommonHelper.pushUnique(hiddenColumns, field.id);
}
hiddenColumns = hiddenColumns;
}}
/>
<label for={uniqueId}>{field.name}</label>
</Field>
{/each}
</Toggler>
</svelte:fragment>
<table class="table" class:table-loading={isLoading}>
<thead>
<tr>
@@ -178,13 +251,30 @@
</div>
{/if}
</th>
<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>
{#each fields as field (field.name)}
{#if collection.isAuth}
<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>
<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}
{#each visibleFields as field (field.name)}
<SortHeader
class="col-type-{field.type} col-field-{field.name}"
name={field.name}
@@ -196,19 +286,26 @@
</div>
</SortHeader>
{/each}
<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>
<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>
<th class="col-type-action min-width" />
<th class="col-type-action min-width">
<button bind:this={columnsTrigger} type="button" class="btn btn-sm btn-secondary p-0">
<i class="ri-more-line" />
</button>
</th>
</tr>
</thead>
<tbody>
@@ -237,10 +334,47 @@
</td>
<td class="col-type-text col-field-id">
<IdLabel id={record.id} />
<div class="flex flex-gap-5">
<IdLabel id={record.id} />
{#if collection.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>
{#each fields as field (field.name)}
{#if collection.isAuth}
<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>
<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}
{#each visibleFields as field (field.name)}
<RecordFieldCell {record} {field} />
{/each}
@@ -282,7 +416,7 @@
{/each}
</tbody>
</table>
</div>
</HorizontalScroller>
{#if records.length}
<small class="block txt-hint txt-right m-t-sm">Showing {records.length} of {totalRecords}</small>
@@ -0,0 +1,155 @@
<script>
import { slide } from "svelte/transition";
import { Collection, Record } from "pocketbase";
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";
export let collection = new Collection();
export let record = new Record();
let originalUsername = record.username || null;
let changePasswordToggle = false;
$: if (!record.username && record.username !== null) {
record.username = null;
}
$: if (!changePasswordToggle) {
record.password = null;
record.passwordConfirm = null;
removeError("password");
removeError("passwordConfirm");
}
</script>
<div class="grid m-b-base">
<div class="col-lg-6">
<Field class="form-field {!record.isNew ? 'required' : ''}" name="username" let:uniqueId>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("user")} />
<span class="txt">Username</span>
</label>
<input
type="text"
requried={!record.isNew}
placeholder={record.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
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon("email")} />
<span class="txt">Email</span>
</label>
<div class="form-field-addon email-visibility-addon">
<button
type="button"
class="btn btn-sm btn-secondary {record.emailVisibility ? 'btn-success' : 'btn-hint'}"
use:tooltip={{
text: "Make email public or private",
position: "top-right",
}}
on:click={() => (record.emailVisibility = !record.emailVisibility)}
>
<span class="txt">Public: {record.emailVisibility ? "On" : "Off"}</span>
</button>
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
type="email"
autofocus={record.isNew}
autocomplete="off"
id={uniqueId}
required={collection.options?.requireEmail}
bind:value={record.email}
/>
</Field>
</div>
<div class="col-lg-12">
{#if !record.isNew}
<Field class="form-field form-field-toggle" name="verified" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
<label for={uniqueId}>Change password</label>
</Field>
{/if}
{#if record.isNew || changePasswordToggle}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="grid" class:p-t-xs={changePasswordToggle}>
<div class="col-sm-6">
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>
<i class="ri-lock-line" />
<span class="txt">Password</span>
</label>
<input
type="password"
autocomplete="new-password"
id={uniqueId}
required
bind:value={record.password}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
<label for={uniqueId}>
<i class="ri-lock-line" />
<span class="txt">Password confirm</span>
</label>
<input
type="password"
autocomplete="new-password"
id={uniqueId}
required
bind:value={record.passwordConfirm}
/>
</Field>
</div>
</div>
</div>
{/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 (record.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>
</div>
<style>
.email-visibility-addon ~ input {
padding-right: 100px;
}
</style>
@@ -6,6 +6,13 @@
export let field = new SchemaField();
export let value = undefined;
// strip ms and zone for backwards compatibility with the older format
// and because flatpickr currently doesn't have integrated
// zones support and requires manual parsing and formatting
$: if (value && value.length > 19) {
value = value.substring(0, 19);
}
</script>
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
@@ -83,12 +83,12 @@
<RecordFilePreview {record} {filename} />
</figure>
<a
href={ApiClient.records.getFileUrl(record, filename)}
href={ApiClient.getFileUrl(record, filename)}
class="filename link-hint"
class:txt-strikethrough={deletedFileIndexes.includes(i)}
use:tooltip={{ position: "right", text: "Download" }}
target="_blank"
rel="noopener"
rel="noopener noreferrer"
>
{filename}
</a>
@@ -7,9 +7,14 @@
export let field = new SchemaField();
export let value = undefined;
$: isMultiple = field.options?.maxSelect > 1;
$: isMultiple = field.options?.maxSelect != 1;
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
$: if (
isMultiple &&
Array.isArray(value) &&
field.options?.maxSelect &&
value.length > field.options.maxSelect
) {
value = value.slice(field.options.maxSelect - 1);
}
</script>
@@ -1,33 +0,0 @@
<script>
import { SchemaField } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import UserSelect from "@/components/users/UserSelect.svelte";
import Field from "@/components/base/Field.svelte";
export let field = new SchemaField();
export let value = undefined;
// to prevent accidental changes, disable editing system user field values from the UI
$: isDisabled = !CommonHelper.isEmpty(value) && field.system;
$: isMultiple = field.options?.maxSelect > 1;
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
value = value.slice(field.options.maxSelect - 1);
}
</script>
<Field
class="form-field {field.required ? 'required' : ''} {isDisabled ? 'disabled' : ''}"
name={field.name}
let:uniqueId
>
<label for={uniqueId}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
<span class="txt">{field.name}</span>
</label>
<UserSelect toggle id={uniqueId} multiple={isMultiple} disabled={isDisabled} bind:keyOfSelected={value} />
{#if field.options?.maxSelect > 1}
<div class="help-block">Select up to {field.options.maxSelect} users.</div>
{/if}
</Field>