merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions

View File

@@ -0,0 +1,440 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
const recordsPerPage = 100;
const defaultSettings = {
btnText: "Insert",
fileTypes: [], // "image", "document", "video", "audio", "file"
onselect: function(selectedFile) {},
};
const LAST_SELECTED_STORAGE_KEY = "pbLastRecordFilePickerCollection";
const RECORDS_REQUEST_KEY = "listFilePickerRecords";
/**
* Opens a new record file picker.
*
* @example
* ```js
* app.modals.openRecordFilePicker({
* onselect: (selectedFile) => { ... }
* })
* ```
*
* @param {Object} settings
*/
window.app.modals.openRecordFilePicker = function(settings = {}) {
settings = Object.assign({}, defaultSettings, settings);
const modal = recordFilePickerModal(settings);
document.body.appendChild(modal);
app.modals.open(modal);
};
function recordFilePickerModal(settings = defaultSettings) {
let modal;
const uniqueId = "file_picker_" + app.utils.randomString();
const data = store({
selectedFile: {},
records: [],
activeCollectionId: "",
searchTerm: "",
lastRecordsPage: 1,
lastTotalRecords: 0,
isLoadingRecords: false,
get collections() {
return app.utils.sortedCollections(
app.store.collections.filter((c) => {
if (c.type == "view") {
return false;
}
// has at least one public file key
return !!c.fields?.find((f) => {
return f.type === "file" && !f.protected;
});
}),
);
},
get activeCollection() {
const collection = data.collections.find((c) => c.id == data.activeCollectionId);
if (collection) {
return collection;
}
// always fallback to the first one (if available)
return data.collections[0];
},
get activeCollectionFileFields() {
return data.activeCollection?.fields?.filter((f) => f.type === "file" && !f.protected) || [];
},
get isLoading() {
return app.store.isLoadingCollections || data.isLoadingRecords;
},
get canLoadMore() {
return !data.isLoadingRecords && data.lastTotalRecords == recordsPerPage;
},
get hasAtleastOneFile() {
return !!data.records.find((r) => extractFiles(r).length > 0);
},
});
const watchers = [];
// load and sync activeCollectionId from/to localStorage
watchers.push(
watch(() => {
if (!data.activeCollectionId) {
data.activeCollectionId = window.localStorage.getItem(LAST_SELECTED_STORAGE_KEY);
} else {
window.localStorage.setItem(LAST_SELECTED_STORAGE_KEY, data.activeCollectionId);
data.searchTerm = ""; // reset
}
}),
);
// reload records on search or active collection change
watchers.push(
watch(
() => [data.activeCollection, data.searchTerm],
() => loadRecords(true),
),
);
function resetList() {
app.pb.cancelRequest(RECORDS_REQUEST_KEY);
data.isLoadingRecords = false;
data.records = [];
data.lastTotalRecords = 0;
data.lastRecordsPage = 1;
data.selectedFile = {};
}
async function loadRecords(reset = false) {
if (!data.activeCollection) {
resetList();
return;
}
if (reset) {
resetList();
}
data.isLoadingRecords = true;
try {
const page = reset ? 1 : data.lastRecordsPage + 1;
const fallbackSearchFields = app.utils.getAllCollectionIdentifiers(data.activeCollection);
let normalizedFilter = app.utils.normalizeSearchFilter(data.searchTerm, fallbackSearchFields) || "";
if (normalizedFilter) {
normalizedFilter += " && ";
}
normalizedFilter += "(" + data.activeCollectionFileFields.map((f) => `${f.name}:length>0`).join("||")
+ ")";
const result = await app.pb.collection(data.activeCollection.id).getList(page, recordsPerPage, {
requestKey: RECORDS_REQUEST_KEY,
filter: normalizedFilter,
skipTotal: 1,
sort: data.activeCollection.type != "view" ? "-@rowid" : "",
});
data.lastRecordsPage = result.page;
data.lastTotalRecords = result.items.length;
data.records = app.utils.filterDuplicatesByKey(data.records.concat(result.items));
data.isLoadingRecords = false;
} catch (err) {
if (!err.isAbort) {
data.isLoadingRecords = false;
app.checkApiError(err);
}
}
}
function extractFiles(record) {
let result = [];
for (const field of data.activeCollectionFileFields) {
const names = app.utils.toArray(record[field.name]);
for (const name of names) {
if (
app.utils.isEmpty(settings.fileTypes)
|| settings.fileTypes?.includes(app.utils.getFileType(name))
) {
result.push(name);
}
}
}
return result;
}
function selectFile(record, name) {
data.selectedFile = { record, name, thumb: "" };
}
function isSelected(record, name) {
return data.selectedFile?.name == name && data.selectedFile?.record?.id == record?.id;
}
const documentEvents = {
"record:create": (e) => {
if (e.detail.collectionId != data.activeCollection?.id) {
return;
}
if (data.selectedFile?.record?.id == e.detail.id) {
data.selectedFile.record = e.detail;
}
loadRecords(true);
},
"record:delete": (e) => {
if (
// check both because for delete we don't know which one was assigned to
e.detail.collectionId != data.activeCollection?.id
&& e.detail.collectionName != data.activeCollection?.name
) {
return;
}
if (data.selectedFile?.record?.id == e.detail.id) {
data.selectedFile = {};
}
loadRecords(true);
},
};
modal = t.div(
{
className: "modal popup record-file-picker-modal",
onafterclose: (el) => {
el?.remove();
},
onmount: (el) => {
for (let event in documentEvents) {
document.addEventListener(event, documentEvents[event]);
}
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
for (let event in documentEvents) {
document.removeEventListener(event, documentEvents[event]);
}
},
},
t.header(
{ className: "modal-header" },
// collections select
t.button(
{
className: () =>
`btn primary outline record-file-picker-collection-select-btn ${
app.store.isLoadingCollections ? "loading" : ""
}`,
disabled: () => app.store.isLoadingCollections,
"html-popovertarget": "collections_dropdown" + uniqueId,
},
t.span(
{ className: "txt-lg collection-name m-r-auto" },
() => data.activeCollection?.name || "Select collection",
),
t.i({ className: "ri-arrow-drop-down-line" }),
),
t.div(
{ id: "collections_dropdown" + uniqueId, className: "dropdown", popover: "hint" },
() => {
return data.collections.map((c) => {
return t.button(
{
type: "button",
className: () => `dropdown-item ${data.activeCollectionId == c.id ? "active" : ""}`,
onclick: (e) => {
data.activeCollectionId = c.id;
e.target?.closest(".dropdown")?.hidePopover();
},
},
c.name,
);
});
},
),
// search
app.components.recordsSearchbar({
disabled: () => !data.activeCollection?.id,
collection: () => data.activeCollection,
value: () => data.searchTerm,
onsubmit: (newFilter) => (data.searchTerm = newFilter),
}),
// new record
t.button(
{
type: "button",
className: "btn circle transparent",
ariaDescription: app.attrs.tooltip("Add new record"),
onclick: () => app.modals.openRecordUpsert(data.activeCollection),
},
t.i({ className: "ri-add-line txt-hint" }),
),
),
t.div(
{ className: "modal-content" },
// initial loader
t.div(
{
className: "block txt-center",
hidden: () => data.hasAtleastOneFile || !data.isLoading,
},
t.span({ className: "loader" }),
),
// files list
t.div({ className: "record-file-picker-list" }, () => {
const result = [];
for (const record of data.records) {
const files = extractFiles(record);
for (const name of files) {
result.push(
t.button(
{
rid: record.id + ":" + name,
className: () => `list-item thumb ${isSelected(record, name) ? "success" : ""}`,
ariaDescription: app.attrs.tooltip(name, "bottom"),
onclick: () => selectFile(record, name),
},
() => {
if (app.utils.hasImageExtension(name)) {
return t.img({
loading: "lazy",
src: app.pb.files.getURL(record, name, { thumb: "100x100" }),
alt: name,
});
}
const ftype = app.utils.getFileType(name);
return t.i({ className: app.utils.fileTypeIcons[ftype] || "ri-file-line" });
},
),
);
}
}
return result;
}),
// load more
t.div(
{
hidden: () => !data.canLoadMore || !data.hasAtleastOneFile,
className: "block txt-center",
},
t.button(
{
className: () => `btn secondary expanded-lg m-t-base ${data.isLoadingRecords ? "loading" : ""}`,
disabled: () => data.isLoadingRecords,
onclick: () => loadRecords(),
},
t.span({ className: "txt" }, "Load more"),
),
),
// no files
t.div(
{
className: "block txt-center txt-hint p-t-10 p-b-10",
hidden: () => data.hasAtleastOneFile || data.isLoading,
},
() => {
if (app.utils.isEmpty(settings.fileTypes)) {
return t.p(null, "No records with selectable files found.");
}
return t.p(null, `No "${settings.fileTypes.join("\", \"")}" files found.`);
},
t.button({
type: "button",
className: "btn sm secondary",
textContent: "Clear search",
hidden: () => !data.searchTerm?.length,
onclick: () => {
data.searchTerm = "";
},
}),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
// image thumb selector
() => {
if (!data.selectedFile?.name || !app.utils.hasImageExtension(data.selectedFile.name)) {
return;
}
const options = [
{ value: "", label: "Original size" },
{ value: "100x100", label: "100x100 thumb" },
];
// find the related field and its thumbs
const fileField = data.activeCollectionFileFields.find((f) => {
return data.selectedFile.record[f.name].includes(data.selectedFile.name);
});
const thumbs = app.utils.toArray(fileField.thumbs);
for (let thumb of thumbs) {
options.push({
value: thumb,
label: `${thumb} thumb`,
});
}
return t.div(
{ className: "record-file-picker-thumb-select" },
app.components.select({
required: true,
value: data.selectedFile.thumb || "",
options: options,
onchange: (opts) => {
data.selectedFile.thumb = opts?.[0].value;
},
}),
);
},
// submit selected
t.button(
{
type: "button",
className: "btn expanded",
disabled: () => data.isLoading || !data.selectedFile?.name,
onclick: () => {
const selected = JSON.parse(JSON.stringify(data.selectedFile));
if (settings.onselect && settings.onselect(selected) === false) {
return false;
}
app.modals.close(modal);
},
},
t.span({ className: "txt" }, () => settings.btnText || defaultSettings.btnText),
),
),
);
return modal;
}

View File

@@ -0,0 +1,149 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
// rudimentary semaphore to resolve the images url on batches to prevent
// overhelming and freezing the browser tab with rendering too many images at once
const semaphore = {
max: 10,
pending: new Set(),
processing: new Set(),
};
function semaphoreAdd(fn) {
semaphore.pending.add(fn);
if (semaphore.processing.size <= semaphore.max) {
semaphoreProcess();
}
return () => {
semaphore.pending.delete(fn);
semaphore.processing.delete(fn);
if (semaphore.processing.size < semaphore.max) {
semaphoreProcess();
}
};
}
function semaphoreProcess() {
for (const fn of semaphore.pending) {
semaphore.pending.delete(fn);
semaphore.processing.add(fn);
fn();
return;
}
}
/**
* Creates a record file thumb element.
*
* @example
* ```js
* app.components.recordFileThumb({
* record: () => data.record,
* filename: () => data.record.myFile,
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.recordFileThumb = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
record: {},
filename: "",
extraClasses: "sm", // any .thumb related classes
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
isPreviewLoading: false,
previewToken: "",
get fileType() {
return app.utils.getFileType(props.filename);
},
get hasPreview() {
return ["image", "audio", "video"].includes(data.fileType) || props.filename.endsWith(".pdf");
},
previewURL: undefined,
});
return t.button(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
type: "button",
draggable: false,
className: () => `thumb ${props.extraClasses} ${data.isPreviewLoading ? "loading" : ""}`,
title: () => (data.hasPreview ? "Preview" : "Download") + " " + props.filename,
onclick: async (e) => {
e.stopPropagation();
async function resolveURL() {
const token = await app.getFileToken(props.record.collectionId);
return app.pb.files.getURL(props.record, props.filename, { token });
}
if (data.hasPreview) {
app.modals.openFilePreview(resolveURL);
} else {
const url = await resolveURL();
app.utils.download(url, props.filename);
}
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (data.fileType == "image") {
const img = t.img({
draggable: false,
alt: () => "Thumb of " + props.filename,
src: () => data.previewURL,
onerror: (err) => {
console.warn("[recordFileThumb] load err:", err);
data.isPreviewLoading = false;
img?._semaphoreRelease?.();
},
onload: () => {
data.isPreviewLoading = false;
img?._semaphoreRelease?.();
},
onmount: (el) => {
data.isPreviewLoading = true;
el._semaphoreRelease = semaphoreAdd(async () => {
try {
data.previewToken = await app.getFileToken(props.record.collectionId);
data.previewURL = app.pb.files.getURL(props.record, props.filename, {
thumb: "100x100",
token: data.previewToken,
});
} catch (err) {
console.warn(err);
}
});
},
onunmount: (el) => {
data.isPreviewLoading = false;
el._semaphoreRelease?.();
},
});
return img;
}
return t.i({ className: app.utils.fileTypeIcons[data.fileType] || "ri-file-line" });
},
);
};

View File

@@ -0,0 +1,203 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
// @todo events for the generated token
/**
* Opens an auth record impersonate token generation modal.
*
* @example
* ```js
* app.modals.openRecordImpersontate(record)
* ```
*
* @param {Object} record
*/
window.app.modals.openRecordImpersontate = function(record) {
const modal = recordImpersonateModal(record);
document.body.appendChild(modal);
app.modals.open(modal);
};
function recordImpersonateModal(record) {
const uniqueId = "impersonate_" + app.utils.randomString();
const data = store({
isLoading: false,
token: "",
duration: 0,
get collection() {
return app.store.collections.find((c) => {
return c.id == record.collectionId || c.name == record.collectionName;
});
},
});
const baseURL = app.utils.getApiExampleURL();
async function createToken() {
if (data.isLoading) {
return;
}
data.isLoading = true;
try {
const impersonateClient = await app.pb
.collection(data.collection.name)
.impersonate(record.id, data.duration);
data.token = impersonateClient.authStore.token;
} catch (err) {
app.checkApiError(err);
}
data.isLoading = false;
}
function reset() {
data.token = "";
data.duration = 0;
}
return t.div(
{
className: "modal popup record-impersonate-auth-modal",
onbeforeclose: () => {
return !data.isLoading;
},
onafterclose: (el) => {
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h6(
null,
"Generate nonrenewable auth token for ",
t.strong(null, () => record.email || record.id),
),
),
t.div(
{ className: "modal-content" },
t.form(
{
id: uniqueId + "_form",
hidden: () => data.token,
className: "block",
onsubmit: (e) => {
e.preventDefault();
createToken();
},
},
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + "_duration" }, "Token duration (in seconds)"),
t.input({
id: uniqueId + "_duration",
type: "number",
name: "duration",
min: 0,
step: 1,
placeholder: () =>
`Default to the collection settings (${data.collection?.authToken?.duration || 0}s)`,
value: (e) => data.duration || "",
oninput: (e) => (data.duration = parseInt(e.target.value, 10)),
}),
),
),
t.div(
{
hidden: () => !data.token,
className: "alert success impersonate-success",
},
t.strong(null, () => data.token),
" ",
app.components.copyButton(() => data.token),
),
// SDKs example
app.components.codeBlockTabs({
hidden: () => !data.token,
className: "sdk-examples m-t-base",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
// load the token into the store
const token = '...';
pb.authStore.save(token, null);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
// load the token into the store
final token = '...';
pb.authStore.save(token, null);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
],
}),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
disabled: () => data.isLoading,
onclick: () => app.modals.close(),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
"hidden": () => data.token,
"type": "submit",
"html-form": uniqueId + "_form",
"className": () => `btn expanded-lg ${data.isLoading ? "loading" : ""}`,
"disabled": () => data.isLoading,
},
t.span({ className: "txt" }, "Generate token"),
),
t.button(
{
hidden: () => !data.token,
type: "button",
className: () => `btn secondary expanded-lg ${data.isLoading ? "loading" : ""}`,
onclick: () => reset(),
},
t.span({ className: "txt" }, "Generate new one"),
),
),
);
}

View File

@@ -0,0 +1,246 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
/**
* Opens a record preview modal.
*
* @example
*
* ```js
* // full record model
* app.modals.openRecordPreview(record)
*
* // or partial record
* app.modals.openRecordPreview({ id: "rId", collectionId: "cId"})
* ```
*
* @param {Object} record
*/
window.app.modals.openRecordPreview = function(record, modalSettings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
}) {
const modal = recordPreviewModal(record, modalSettings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function downloadJSON(record) {
// clear expand if any
if (record.expand) {
record = Object.assign({}, record);
delete record.expand;
}
app.utils.downloadJSON(record, record.collectionName + "_" + record.id + ".json");
}
function copyJSON(record) {
// clear expand if any
if (record.expand) {
record = Object.assign({}, record);
delete record.expand;
}
app.utils.copyToClipboard(JSON.stringify(record, null, 2));
app.toasts.success("Record copied to clipboard!");
}
function recordPreviewModal(rawRecord, modalSettings) {
let modal;
const uniqueId = app.utils.randomString();
const data = store({
isLoading: false,
record: null,
get collection() {
return app.store.collections.find((c) => {
return c.id == rawRecord.collectionId || c.name == rawRecord.collectionName;
});
},
});
async function loadRecord() {
if (!rawRecord?.id) {
app.toasts.error("Failed to load record.");
setTimeout(() => app.modals.close(modal), 0);
console.warn("[recordPreviewModal] missing required record id field:", rawRecord);
return;
}
if (!rawRecord.collectionId && !rawRecord.collectionName) {
app.toasts.error("Failed to load record.");
setTimeout(() => app.modals.close(modal), 0);
console.warn("[recordPreviewModal] missing required collectionId or collectionName field:", rawRecord);
return;
}
data.isLoading = true;
try {
// eagerly expand first level presentable relations (if any and the collections are loaded)
let relExpands = [];
const presentableRelationFields = data.collection?.fields?.filter(
(f) => !f.hidden && f.presentable && f.type == "relation",
) || [];
for (let field of presentableRelationFields) {
relExpands.push(field.name);
}
data.record = await app.pb
.collection(rawRecord.collectionId || rawRecord.collectionName)
.getOne(rawRecord.id, {
requestKey: "record_preview_" + rawRecord.id,
expand: relExpands.join(",") || undefined,
});
data.isLoading = false;
} catch (err) {
if (!err?.isAbort) {
data.isLoading = false;
app.checkApiError(err);
setTimeout(() => app.modals.close(modal), 0);
}
}
}
modal = t.div(
{
pbEvent: "recordPreviewModal",
className: "modal record-preview-modal",
onbeforeopen: (el) => {
loadRecord();
return modalSettings.onbeforeopen?.(el);
},
onafteropen: (el) => {
modalSettings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return modalSettings.onbeforeclose?.(el);
},
onafterclose: (el) => {
modalSettings.onafterclose?.(el);
el?.remove();
},
onmount: (el) => {
},
onunmount: (el) => {
},
},
t.header(
{ className: "modal-header" },
t.h6(
null,
t.strong(null, () => rawRecord?.collectionName || data.collection?.name),
" record preview",
),
t.button(
{
"className": "btn sm circle transparent m-l-auto",
"html-popovertarget": uniqueId + "preview-dropdown",
},
t.i({ className: "ri-more-line" }),
),
t.div({ id: uniqueId + "preview-dropdown", className: "dropdown", popover: "auto" }, (el) => {
return t.button(
{
className: "dropdown-item",
onclick: () => {
copyJSON(data.record);
el.hidePopover();
},
},
t.i({ className: "ri-braces-line" }),
t.span({ className: "txt" }, "Copy JSON"),
);
}),
),
t.div({ className: "modal-content" }, () => {
// loader
if (data.isLoading || !data.record?.id || !data.collection?.id) {
return t.table(
null,
t.tbody(null, () => {
const totalRows = data.collection?.fields?.filter((f) => f.type != "password").length || 1;
const rows = [];
for (let i = 0; i < totalRows; i++) {
rows.push(t.tr(null, t.td(null, t.span({ className: "skeleton-loader" }))));
}
return rows;
}),
);
}
// attrs
return t.table(
{
pbEvent: "recordPreviewTable",
className: "record-preview-table responsive-table",
},
t.tbody(null, () => {
const fields = data.collection?.fields?.filter((f) => f.type != "password") || [];
return fields.map((f) => {
return t.tr(
null,
t.th(
{ className: () => `min-width p-r-0 col-field-name-${f.name}` },
f.name,
),
t.td(
{ className: () => `col-field-name-${f.name}` },
() => {
if (app.fieldTypes[f.type]?.view) {
return app.fieldTypes[f.type].view({
short: false,
get record() {
return data.record;
},
get field() {
return f;
},
});
}
return app.utils.stringifyValue(data.record[f.name]);
},
),
);
});
}),
);
}),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
type: "button",
className: "btn",
onclick: () => downloadJSON(data.record),
},
t.i({ className: "ri-download-line" }),
t.span({ className: "txt" }, "Download JSON"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,219 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates an element with a short representation of the specified record
* (usually based on the presentable fields of the record).
*
* @example
* ```js
* app.components.recordSummary(record)
* ```
*
* @param {Object} record
* @param {Object} meta
* @return {Element}
*/
window.app.components.recordSummary = function(record, meta = null) {
const local = store({
get collection() {
return app.store.collections.find(
(c) => c.id == record.collectionId || c.name == record.collectionName,
);
},
get presentableFields() {
if (!local.collection?.id) {
return [];
}
const result = local.collection.fields
.filter((f) => f.presentable)
.sort((f1, f2) => {
const f1Priority = app.fieldTypes[f1.type].summaryPriority || 0;
const f2Priority = app.fieldTypes[f2.type].summaryPriority || 0;
if (f1Priority > f2Priority) {
return 1;
}
if (f1Priority < f2Priority) {
return -1;
}
return 0;
});
// autoset the first found fallback field as presentable
if (!result.length) {
for (let name of app.utils.fallbackPresentableProps) {
const field = local.collection?.fields?.find((f) => f.name == name);
if (field) {
result.push(field);
break;
}
}
}
return result;
},
});
return t.div(
{ className: "label record-summary" },
t.i({
ariaHidden: true,
className: "ri-eye-line link-hint record-preview-icon",
onclick: (e) => {
e.stopImmediatePropagation();
e.preventDefault();
},
onmouseenter: (e) => {
showRecordSummaryDropdown(e.target, record, 100);
},
onmouseleave: (e) => {
hideRecordSummaryDropdown(e.target, 100);
},
onunmount: (el) => {
hideRecordSummaryDropdown(el, 0);
},
}),
() => {
const result = [];
function add(val) {
if (val == null || val == "") {
val = t.span({ className: "missing-value" });
}
result.push(val);
}
for (const field of local.presentableFields) {
const viewFunc = app.fieldTypes[field.type]?.view;
if (viewFunc) {
const val = viewFunc({
short: true,
get record() {
return record;
},
get field() {
return field;
},
get meta() {
return meta;
},
});
add(val);
} else {
const values = app.utils.toArray(record[field.name]).splice(0, 3);
for (const val of values) {
add(val);
}
}
}
return result;
},
);
};
function hideRecordSummaryDropdown(target, delay = 150) {
if (!target) {
return;
}
clearTimeout(target._summaryDropdownTimeoutId);
if (delay <= 0) {
target?._summaryDropdown?.hidePopover?.();
return;
}
target._summaryDropdownTimeoutId = setTimeout(() => {
target?._summaryDropdown?.hidePopover?.();
}, delay);
}
function showRecordSummaryDropdown(target, record, delay = 150) {
if (!target) {
return;
}
clearTimeout(target._summaryDropdownTimeoutId);
if (delay <= 0) {
showRecordSummaryDropdownNoDelay(target, record);
return;
}
target._summaryDropdownTimeoutId = setTimeout(() => {
showRecordSummaryDropdownNoDelay(target, record);
}, delay);
}
const showRecordSummaryDropdownNoDelay = function(target, record) {
if (!target) {
return;
}
if (!target._summaryDropdown) {
target._summaryDropdown = t.div(
{
className: "dropdown record-summary-dropdown",
popover: "manual",
onclick: (e) => {
e.stopImmediatePropagation();
e.preventDefault();
},
},
t.div(
{ className: "record-header" },
t.a(
{
className: "link-hint txt-bold m-r-auto",
target: "_blank",
href: `#/collections?collection=${record.collectionName}&record=${record.id}`,
onclick: (e) => {
e.stopImmediatePropagation();
},
},
t.span({ className: "txt" }, "Edit relation record"),
t.i({ className: "ri-external-link-line" }),
),
t.button(
{
type: "button",
className: "link-hint",
title: "Close",
onclick: () => hideRecordSummaryDropdown(target, 0),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
),
t.hr(),
t.pre(
{ className: "record-json" },
() => {
const fields = app.store.collections.find((c) =>
c.id == record.collectionId || c.name == record.collectionName
)?.fields || [];
if (!fields.length) {
return;
}
const orderedProps = {
collectionId: record.collectionId,
collectionName: record.collectionName,
};
for (const field of fields) {
orderedProps[field.name] = record[field.name];
}
return JSON.stringify(app.utils.truncateObject(orderedProps, 27), null, 2);
},
),
);
target.appendChild(target._summaryDropdown);
}
target._summaryDropdown?.showPopover({
source: target,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,735 @@
import { fieldsWithExcerpt } from "@/fields/relation/view";
window.app = window.app || {};
window.app.components = window.app.components || {};
const perPage = 40;
const sortRegex = /^([\+\-])?(\w+)$/;
window.app.consts = window.app.consts || {};
window.app.consts.COLUMNS_STORAGE_PREFIX = "pbColumns_";
/**
* Creates new page records listing element.
*
* @example
* ```js
* app.components.recordsList({
* collection: () => data.activeCollection,
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.recordsList = function(propsArg = {}) {
const uniqueId = "records_list_" + app.utils.randomString();
const props = store({
collection: {},
filter: "",
sort: "",
reset: undefined,
// ---
rid: undefined,
id: undefined,
hidden: undefined,
className: "",
onchange: (newFilter, newSort) => {},
onselect: (record) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
isLoading: false,
records: [],
lastPage: 0,
lastTotalItems: 0,
bulkSelected: {},
columnsPreferences: {},
get canLoadMore() {
return data.lastTotalItems >= perPage;
},
get totalSelected() {
return Object.keys(data.bulkSelected).length;
},
get areAllSelected() {
return data.records.length && data.records.length == data.totalSelected;
},
get firstAutoUpdatedField() {
return props.collection?.fields?.find((f) => f.type == "autodate" && f.onUpdate);
},
get isSuperusersCollection() {
return props.collection?.type == "auth" && props.collection?.name == "_superusers";
},
});
async function clearList() {
data.records = [];
data.lastPage = 0;
data.lastTotalItems = 0;
data.bulkSelected = {};
}
function triggerOnchange() {
props.onchange?.(props.filter, props.sort);
}
async function loadRecords(reset = false) {
if (!props.collection?.id) {
return;
}
data.isLoading = true;
try {
// (note if changed update the related counter query too!)
const normalizedFilter = app.utils.normalizeSearchFilter(
props.filter,
props.collection.fields.filter((f) => !f.hidden).map((f) => f.name),
);
// eagerly expand first level relations
// (to prevent too many relation queries)
const relExpands = [];
const relationFields = props.collection.fields.filter(
(f) => !f.hidden && f.type == "relation",
);
for (const field of relationFields) {
relExpands.push(field.name);
}
let requestFields = fieldsWithExcerpt(props.collection.id, relationFields);
// allow sorting by the top level relation presentable fields
let normalizedSort = props.sort || undefined;
const sortMatch = normalizedSort?.match(sortRegex);
const sortField = sortMatch
? props.collection.fields.find((f) => !f.hidden && f.name === sortMatch[2])
: null;
if (!sortField) {
// default fallback to -@rowid when available
normalizedSort = props.collection.type != "view" ? "-@rowid" : undefined;
} else if (sortField?.type == "relation") {
normalizedSort = app.store.collections
?.find((c) => c.id == sortField.collectionId)
?.fields?.filter((f) => f.presentable)
?.map((f) => (sortMatch[1] || "") + sortMatch[2] + "." + f.name)
?.join(",");
}
const page = reset ? 1 : data.lastPage + 1;
const result = await app.pb.collection(props.collection.name).getList(page, perPage, {
requestKey: uniqueId,
skipTotal: 1,
filter: normalizedFilter,
sort: normalizedSort,
expand: relExpands.join(",") || undefined,
fields: requestFields,
});
if (result.page == 1) {
clearList();
}
data.lastPage = result.page;
data.lastTotalItems = result.items.length;
for (let i = 0; i < result.items.length; i++) {
app.utils.pushOrReplaceObject(data.records, result.items[i]);
// yield to main (with room to "breathe")
if (i > 1 && i % 15 == 0) {
await new Promise((r) => setTimeout(r, 20));
}
}
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
data.isLoading = false;
clearList();
app.checkApiError(err);
}
}
}
function selectAll(state = true) {
// note: always assign a new object to trigger the getter's Object.keys
const selected = {};
if (state) {
for (let record of data.records) {
selected[record.id] = record;
}
}
data.bulkSelected = selected;
}
function downloadSelected() {
const selected = JSON.parse(JSON.stringify(Object.values(data.bulkSelected)));
if (!selected.length) {
return; // nothing to download
}
// unset expand
for (const record of selected) {
if (record.expand) {
delete record.expand;
}
}
if (selected.length == 1) {
return app.utils.downloadJSON(selected[0], props.collection.name + "_" + selected[0].id + ".json");
}
return app.utils.downloadJSON(selected, `${selected.length}_${props.collection.name}_records.json`);
}
async function deleteSelected() {
const idsToDelete = Object.keys(data.bulkSelected);
if (!idsToDelete.length) {
return; // nothing to delete
}
const remainingIdsToDelete = idsToDelete.slice();
// delete requests in batches to avoid sending too many requests
while (remainingIdsToDelete.length) {
const ids = remainingIdsToDelete.splice(0, 100);
const promises = [];
for (const id of ids) {
promises.push(app.pb.collection(props.collection.name).delete(id));
}
try {
await Promise.all(promises);
} catch (err) {
app.checkApiError(err);
selectAll(false);
loadRecords(true);
return;
}
}
selectAll(false);
app.toasts.success(
`Successfully deleted ${idsToDelete.length} ${idsToDelete.length == 1 ? "record" : "records"}.`,
);
}
function recordRid(record) {
if (data.firstAutoUpdatedField) {
// - the collection update is added in case the collection fields have changed
// - the record keys are added in case of a record field rename
// (the collection update and the refreshed records load doesn't happen at the same time)
return record.id + record[data.firstAutoUpdatedField.name] + props.collection?.updated
+ Object.keys(record);
}
return JSON.stringify(record) + props.collection?.updated;
}
function isFieldColumnHidden(field) {
if (typeof data.columnsPreferences[field.id] != "undefined") {
return !data.columnsPreferences[field.id];
}
return field.hidden;
}
// trigger load before mount and on props change
watchers.push(
watch(
() => JSON.stringify([props.collection?.id, props.filter, props.sort, props.reset]),
(newVal, oldVal) => {
if (newVal != oldVal) {
loadRecords(true);
}
},
),
);
let deleteRefreshTimeoutId;
const documentEvents = {
"record:save": (e) => {
if (e.detail.collectionId != props.collection?.id) {
return;
}
// optimistically merge with existing to minimize flickering
const found = data.records.find((r) => r.id == e.detail.id);
if (found) {
Object.assign(found, JSON.parse(JSON.stringify(e.detail)));
}
loadRecords(true);
},
"record:delete": (e) => {
if (
// check both because for delete we don't know which one was assigned to
e.detail.collectionId != props.collection?.id
&& e.detail.collectionName != props.collection?.name
) {
return;
}
delete data.bulkSelected[e.detail.id];
app.utils.removeByKey(data.records, "id", e.detail.id);
clearTimeout(deleteRefreshTimeoutId);
deleteRefreshTimeoutId = setTimeout(() => {
if (!data.records?.length) {
loadRecords(true);
}
}, 100);
},
};
return t.div(
{
pbEvent: "recordsList",
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
className: () => `page-table-wrapper ${props.className}`,
onmount: (el) => {
for (let event in documentEvents) {
document.addEventListener(event, documentEvents[event]);
}
watchers.push(
watch(() => props.collection?.id, (newId, oldId) => {
data.columnsPreferences = app.utils.getLocalHistory(
app.consts.COLUMNS_STORAGE_PREFIX + newId,
{},
);
if (oldId && oldId != newId) {
clearList();
}
}),
);
watchers.push(
watch(
() => JSON.stringify(data.columnsPreferences),
(newVal, oldVal) => {
if (props.collection?.id && oldVal) {
app.utils.saveLocalHistory(
app.consts.COLUMNS_STORAGE_PREFIX + props.collection.id,
data.columnsPreferences,
);
}
},
),
);
watchers.push(
watch(
() => data.lastPage,
(page) => {
if (page == 1 && el) {
el.scrollTop = 0;
}
},
),
);
},
onunmount: () => {
app.pb.cancelRequest(uniqueId);
clearTimeout(deleteRefreshTimeoutId);
watchers.forEach((w) => w?.unwatch());
for (let event in documentEvents) {
document.removeEventListener(event, documentEvents[event]);
}
},
},
t.table(
{
pbEvent: "recordsListTable",
className: () => `records-table responsive-table ${data.records.length > perPage ? "optimize" : ""}`,
},
t.thead(
{ className: "sticky" },
t.tr(
null,
t.th(
{ className: "col-bulk-select" },
t.div(
{
className: "field",
hidden: () => data.isLoading,
},
t.input({
id: "all_" + uniqueId,
type: "checkbox",
disabled: () => !data.records.length,
checked: () => data.areAllSelected,
onchange: (e) => selectAll(e.target.checked),
}),
t.label({ htmlFor: "all_" + uniqueId }),
),
t.span({
className: "loader",
hidden: () => !data.isLoading,
}),
),
() => {
const fields = props.collection?.fields || [];
const columns = [];
for (const field of fields) {
if (
!app.fieldTypes[field.type]?.view
// superusers are always verified
|| (data.isSuperusersCollection && field.name == "verified")
) {
continue;
}
columns.push(
t.th(
{
hidden: () => isFieldColumnHidden(field),
className: () => {
let sortDir = "";
if (props.sort == field.name || props.sort == "+" + field.name) {
sortDir = "asc";
} else if (props.sort == "-" + field.name) {
sortDir = "desc";
}
return `sort-handle ${sortDir} col-field-type-${field.type} col-field-name-${field.name}`;
},
onclick: (e) => {
let newSort = "-" + field.name;
if (props.sort == newSort) {
newSort = field.name;
}
props.sort = newSort;
triggerOnchange();
},
},
t.div(
{ className: "inline-flex gap-5" },
t.i({
className: () => {
if (field.primaryKey) {
return "ri-key-line";
}
return app.fieldTypes[field.type]?.icon || app.utils.fallbackFieldIcon;
},
}),
t.span({ className: "txt", textContent: field.name }),
),
),
);
}
return columns;
},
t.th({ className: "col-meta" }, () => columnsDropdown(props, data)),
),
),
t.tbody(
null,
() => {
if (!data.records.length) {
return t.tr(
null,
t.td({ colSpan: 99, style: "height:59px" }, () => {
if (data.isLoading) {
return t.span({ className: "skeleton-loader" });
}
return t.div(
{ className: "sticky-content txt-center txt-hint" },
t.p({ className: "txt-bold" }, "No records found."),
t.button(
{
hidden: () => props.filter?.length || props.collection?.type == "view",
type: "button",
className: "btn secondary expanded-lg",
onclick() {
app.modals.openRecordUpsert(props.collection);
},
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt" }, "New record"),
),
t.button(
{
hidden: () => !props.filter?.length,
type: "button",
className: "btn secondary expanded-lg",
onclick() {
props.filter = "";
triggerOnchange();
},
},
t.span({ className: "txt" }, "Clear search"),
),
);
}),
);
}
return data.records.map((record, i) => {
return t.tr(
{
rid: recordRid(record),
tabIndex: 0,
className: "handle",
// disable out-of-view detection for now as it can cause more issues than help
// onmount: (el) => {
// el._intersectionObserver?.disconnect();
// el._intersectionObserver = new IntersectionObserver((entries) => {
// if (entries[0].intersectionRatio <= 0) {
// el?.classList?.add("out-of-view")
// return
// }
// el?.classList?.remove("out-of-view")
// });
// el._intersectionObserver.observe(el);
// },
// onunmount: (el) => {
// el._intersectionObserver.disconnect();
// el._intersectionObserver = null;
// },
onclick: (e) => {
e.preventDefault();
props.onselect(record);
},
onkeypress: (e) => {
if (e.key == "Enter" || e.key == " ") {
e.preventDefault();
props.onselect(record);
}
},
},
t.td(
{
className: "col-bulk-select",
onclick: (e) => e.stopPropagation(),
onkeypress: (e) => e.stopPropagation(),
},
t.div(
{ className: "field" },
t.input({
type: "checkbox",
id: () => uniqueId + record.id,
checked: () => !!data.bulkSelected[record.id],
onchange: (e) => {
const bulkSelected = JSON.parse(JSON.stringify(data.bulkSelected));
if (e.target.checked) {
bulkSelected[record.id] = record;
} else {
delete bulkSelected[record.id];
}
// reassign to trigger the getter's Object.keys
data.bulkSelected = bulkSelected;
},
}),
t.label({ htmlFor: uniqueId + record.id }),
),
),
() => {
const columns = [];
// prepopulate outside of the tr to ensure that in case of a collection updated
// it will still reflect the column value change even if the record itself didn't get an update event
// (e.g. on field rename or single/multiple normalization)
const fields = props.collection?.fields || [];
for (const field of fields) {
const viewFunc = app.fieldTypes[field.type]?.view;
if (!viewFunc) {
continue;
}
// superusers are always verified
if (data.isSuperusersCollection && field.name == "verified") {
continue;
}
columns.push(
t.td(
{
"html-data-name": field.name,
hidden: () => isFieldColumnHidden(field),
className: `col-field-type-${field.type} col-field-name-${field.name}`,
},
() => {
return viewFunc({
short: true,
get record() {
return record;
},
get field() {
return field;
},
});
},
),
);
}
return columns;
},
// columns,
t.td({ className: "col-meta" }, t.i({ className: "ri-arrow-right-line m-r-10" })),
);
});
},
// load more btn
t.tr(
{ hidden: () => !data.canLoadMore },
t.td(
{ colSpan: 99 },
t.button(
{
className: () =>
`btn lg secondary load-more-btn ${data.isLoading ? "transparent loading" : ""}`,
disabled: () => data.isLoading,
onclick: () => loadRecords(),
},
t.span({ className: "txt" }, "Load more"),
),
),
),
),
),
t.div(
{ className: "bulkbar-wrapper" },
t.div(
{
hidden: () => !data.totalSelected,
className: "bulkbar records-bulkbar",
},
t.span(
{ className: "txt" },
"Selected ",
t.strong(null, () => data.totalSelected),
() => ` ${data.totalSelected == 1 ? "record" : "records"}`,
),
t.button(
{
type: "button",
className: "btn sm secondary pill m-r-auto",
onclick: () => selectAll(false),
},
t.span({ className: "txt" }, "Reset"),
),
() => {
if (props.collection?.type == "view") {
return;
}
return t.button(
{
type: "button",
className: "btn sm pill outline danger",
onclick: () => {
app.modals.confirm(
"Do you really want to delete the selected records?",
deleteSelected,
);
},
},
t.i({ className: "ri-delete-bin-7-line" }),
t.span({ className: "txt" }, "Delete"),
);
},
t.button(
{
type: "button",
className: "btn sm pill",
onclick: () => downloadSelected(),
},
t.i({ className: "ri-download-line" }),
t.span({ className: "txt" }, "JSON"),
),
),
),
);
};
function columnsDropdown(props, data) {
const uniqueId = "cols_" + app.utils.randomString();
const dropdown = t.div(
{ className: "dropdown sm nowrap records-list-columns-dropdown gap-0", popover: "auto" },
() => {
if (!props.collection?.fields) {
return;
}
const result = [];
const isAuth = props.collection.type == "auth";
for (const field of props.collection.fields) {
if (
field.primaryKey
|| !app.fieldTypes[field.type].view
|| (isAuth && field.name == "tokenKey")
) {
continue;
}
result.push(
t.div(
{
className: "dropdown-item",
onclick: (e) => {
// workaround for clicking on the padded area
e.target.querySelector("label")?.click();
},
},
t.div(
{ className: "field" },
t.input({
type: "checkbox",
className: "switch sm",
id: () => uniqueId + field.name,
checked: () => {
if (typeof data.columnsPreferences[field.id] != "undefined") {
return !!data.columnsPreferences[field.id];
}
// no explicit preference
return !field.hidden;
},
onchange: (e) => {
data.columnsPreferences[field.id] = e.target.checked;
},
}),
t.label({ htmlFor: () => uniqueId + field.name }, field.name),
),
),
);
}
return result;
},
);
return t.button(
{
hidden: () => props.collection?.fields.length <= 1,
type: "button",
className: "btn sm secondary transparent circle",
popoverTargetElement: dropdown,
},
t.i({ className: "ri-more-2-line" }),
dropdown,
);
}

View File

@@ -0,0 +1,484 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
const recordsPerPage = 50;
const selectedBatchSize = 100;
const RECORDS_REQUEST_KEY = "listRelationPickerRecords";
const defaultSettings = {
collection: "", // model, id or name
selectedIds: [],
maxSelect: 1,
btnText: "Set selection",
onselect: function(records) {},
};
/**
* Opens a new records relation picker.
*
* @example
*
* ```js
* app.modals.openRecordsPicker({
* collection: "yourCollection",
* selectedIds: ["id1", "id2"],
* maxSelect: 1,
* onselect: (records) => { ... }
* })
* ```
*
* @param {Object} settings
*/
window.app.modals.openRecordsPicker = function(settings = {}) {
settings = Object.assign({}, defaultSettings, settings);
const modal = recordsPickerModal(settings);
document.body.appendChild(modal);
app.modals.open(modal);
};
function recordsPickerModal(settings = defaultSettings) {
let modal;
const data = store({
searchTerm: "",
selected: [],
preselected: [],
isLoadingPreselected: false,
records: [],
isLoadingRecords: false,
lastRecordsPage: 1,
lastRecordsTotal: 0,
get collection() {
let idOrName = settings.collection;
if (typeof settings.collection == "object" && settings.collection?.id) {
idOrName = settings.collection?.id;
}
return app.store.collections.find((c) => c.id == idOrName || c.name == idOrName);
},
get isLoading() {
return data.isLoadingPreselected || data.isLoadingRecords;
},
get canLoadMore() {
return !data.isLoadingRecords && data.lastRecordsTotal == recordsPerPage;
},
});
const watchers = [
watch(
() => [settings.collection, settings.selectedIds],
() => {
loadSelected();
},
),
// load initial records and reload on search
watch(
() => [data.collection, data.searchTerm],
() => {
loadRecords(true);
},
),
];
function close() {
setTimeout(() => app.modals.close(modal), 0); // the popup may not be yet opened
}
async function loadSelected() {
const selectedIds = app.utils.toArray(settings.selectedIds);
const collectionId = settings.collection?.id || settings.collection;
if (!collectionId || !selectedIds.length) {
return;
}
data.isLoadingSelected = true;
let loaded = [];
// batch load all selected records to avoid filter length errors
const loadIds = selectedIds.slice();
const loadPromises = [];
while (loadIds.length > 0) {
const filters = [];
const ids = loadIds.splice(0, selectedBatchSize);
for (const id of ids) {
filters.push(`id="${id}"`);
}
loadPromises.push(
app.pb.collection(collectionId).getFullList({
requestKey: null,
filter: filters.join("||"),
}),
);
}
try {
await Promise.all(loadPromises).then((values) => {
loaded = loaded.concat(...values);
});
// preserve selected order
const orderedSelected = [];
for (const id of selectedIds) {
const record = loaded.find((r) => r.id == id);
if (record) {
orderedSelected.push(record);
}
}
// add the ordered selected models to the list (if not already)
if (!data.searchTerm.trim()) {
data.records = app.utils.filterDuplicatesByKey(orderedSelected.concat(data.records));
}
data.selected = orderedSelected;
data.isLoadingSelected = false;
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isLoadingSelected = false;
}
}
}
async function loadRecords(reset = false) {
if (!data.collection?.id) {
return;
}
if (reset) {
resetList();
if (!data.searchTerm.trim()) {
// prepend the loaded selected items
data.records = data.selected.slice();
}
}
data.isLoadingRecords = true;
try {
const page = reset ? 1 : data.lastRecordsPage + 1;
const fallbackSearchFields = app.utils.getAllCollectionIdentifiers(data.collection);
let normalizedFilter = app.utils.normalizeSearchFilter(data.searchTerm, fallbackSearchFields) || "";
const result = await app.pb.collection(data.collection.id).getList(page, recordsPerPage, {
requestKey: RECORDS_REQUEST_KEY,
filter: normalizedFilter,
skipTotal: 1,
sort: data.collection.type != "view" ? "-@rowid" : "",
});
data.lastRecordsPage = result.page;
data.lastRecordsTotal = result.items.length;
data.records = app.utils.filterDuplicatesByKey(data.records.concat(result.items));
data.isLoadingRecords = false;
} catch (err) {
if (!err.isAbort) {
data.isLoadingRecords = false;
app.checkApiError(err);
}
}
}
function resetList() {
app.pb.cancelRequest(RECORDS_REQUEST_KEY);
data.isLoadingRecords = false;
data.records = [];
data.lastTotalRecords = 0;
data.lastRecordsPage = 1;
}
function scrollHandler(e) {
const offset = e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop;
if (offset <= 100 && data.canLoadMore) {
loadRecords();
}
}
function toggleSelected(record) {
const index = data.selected.findIndex((r) => r.id == record.id);
if (index >= 0) {
data.selected.splice(index, 1);
} else {
const maxSelect = settings.maxSelect || 1;
// clear last redundant elements (leaving place for the new selected)
let toRemove = data.selected.length - maxSelect;
while (toRemove >= 0) {
data.selected.pop();
toRemove--;
}
data.selected.push(record);
}
}
function isSelected(record) {
return data.selected.findIndex((r) => r.id == record.id) >= 0;
}
const documentEvents = {
"record:save": (e) => {
if (e.detail.collectionId != data.collection?.id) {
return;
}
const selectedIndex = data.selected?.findIndex((r) => r.id == e.detail.id);
if (selectedIndex >= 0) {
data.selected[selectedIndex] = e.detail;
}
app.utils.pushOrReplaceObject(data.records, e.detail);
loadRecords(true);
},
"record:delete": (e) => {
if (
// check both because for delete we don't know which one was assigned to
e.detail.collectionId != data.collection?.id
&& e.detail.collectionName != data.collection?.name
) {
return;
}
if (isSelected(e.detail)) {
toggleSelected(e.detail);
}
app.utils.removeByKey(data.records, "id", e.detail.id);
loadRecords(true);
},
};
modal = t.div(
{
className: "modal popup lg records-picker-modal",
onafterclose: (el) => {
el.remove();
},
onmount: (el) => {
for (let event in documentEvents) {
document.addEventListener(event, documentEvents[event]);
}
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
for (let event in documentEvents) {
document.removeEventListener(event, documentEvents[event]);
}
},
},
t.header(
{ className: "modal-header" },
t.h6({ className: "collection-name" }, () => data.collection.name),
app.components.recordsSearchbar({
disabled: () => !data.collection?.id,
collection: () => data.collection,
value: () => data.searchTerm,
onsubmit: (newFilter) => (data.searchTerm = newFilter),
}),
t.button(
{
type: "button",
className: "btn circle transparent",
ariaDescription: app.attrs.tooltip("Add new record"),
onclick: () => {
app.modals.openRecordUpsert(data.collection);
},
},
t.i({ className: "ri-add-line txt-hint" }),
),
),
t.div(
{ className: "modal-content", hidden: () => data.isLoadingCollection },
t.div(
{
className: "list records-picker-list",
onscroll: scrollHandler,
onresize: scrollHandler,
},
() => {
return data.records.map((record) => {
return t.div(
{
tabIndex: 0,
className: "list-item handle",
onclick: () => {
toggleSelected(record);
document.activeElement?.blur();
},
},
t.div(
{ className: "content" },
t.span(
{ className: "state-icon" },
t.i({
className: () =>
isSelected(record)
? "ri-checkbox-circle-fill txt-success"
: "ri-checkbox-blank-circle-line txt-disabled",
}),
),
() => app.components.recordSummary(record),
),
t.div(
{ className: "actions autohide" },
t.button(
{
className: "btn sm secondary transparent circle",
ariaDescription: app.attrs.tooltip("Edit"),
onclick: (e) => {
e.stopPropagation();
app.modals.openRecordUpsert(data.collection, record);
},
},
t.i({ className: "ri-pencil-line" }),
),
),
);
});
},
// loader
t.div(
{
className: "list-item",
hidden: () => !data.isLoading,
},
t.div({ className: "skeleton-loader" }),
),
// no records
t.div(
{
className: "list-item",
hidden: () => data.records.length || data.isLoading,
},
t.div(
{ className: "content txt-hint" },
t.span({ className: "txt" }, "No records found."),
t.button({
type: "button",
className: "btn sm secondary",
textContent: "Clear search",
hidden: () => !data.searchTerm.trim().length,
onclick: () => {
data.searchTerm = "";
},
}),
),
),
),
t.div(
{ className: "block m-t-base" },
t.p(
{ className: "txt-bold" },
() => `Selected (${data.selected.length} of max ${settings.maxSelect || 1})`,
),
t.span({ className: "txt-hint", hidden: () => data.selected }, "No selected records."),
app.components.sortable({
className: "records-picker-selected-list",
data: () => data.selected,
dataItem: (record, i) => {
return t.div(
{ rid: record, className: "label handle" },
() => app.components.recordSummary(record, [], true),
t.span(
{
className: "link-hint",
title: "Remove",
role: "button",
onclick: () => toggleSelected(record),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
);
},
onchange: (sortedList, fromIndex, toIndex) => {
data.selected = sortedList;
},
}),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => close(),
},
t.span({ className: "txt" }, "Close"),
),
// image thumb selector
() => {
if (!data.selectedFile?.name || !app.utils.hasImageExtension(data.selectedFile.name)) {
return;
}
const options = [
{ value: "", label: "Original size" },
{ value: "100x100", label: "100x100 thumb" },
];
// find the related field and its thumbs
const fileField = data.activeCollectionFileFields.find((f) => {
return data.selectedFile.record[f.name].includes(data.selectedFile.name);
});
const thumbs = app.utils.toArray(fileField.thumbs);
for (let thumb of thumbs) {
options.push({
value: thumb,
label: `${thumb} thumb`,
});
}
return t.div(
{ className: "record-file-picker-thumb-select" },
app.components.select({
required: true,
value: data.selectedFile.thumb || "",
options: options,
onchange: (opts) => {
data.selectedFile.thumb = opts?.[0].value;
},
}),
);
},
// submit selected
t.button(
{
type: "button",
className: "btn expanded",
disabled: () => data.isLoadingCollection,
onclick: () => {
const selected = JSON.parse(JSON.stringify(data.selected));
if (settings.onselect && settings.onselect(selected) === false) {
return false;
}
close();
},
},
t.span({ className: "txt" }, settings.btnText || defaultSettings.btnText),
),
),
);
return modal;
}

View File

@@ -0,0 +1,62 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a new records searchbar element with builtin autocomplete.
* The searchbar is based on `app.components.search`.
*
* Note that the created element doesn't do any search. It is responsible
* only for binding a reactive search input value.
*
* @example
* ```js
* app.components.recordsSearchbar({
* value: () => data.searchTerm,
* onsubmit: (newVal) => data.searchTerm = newVal,
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.recordsSearchbar = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
disabled: undefined,
value: "",
className: "",
collection: undefined,
onsubmit: (newValue) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `full-width records-searchbar-wrapper ${props.className}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
app.components.searchbar({
placeholder: () => (!props.disabled && !props.collection?.id ? "Loading..." : "Search term or filter..."),
historyKey: () => "pbRecordsSearchHistory_" + props.collection?.id,
disabled: () => props.disabled || !props.collection,
value: () => props.value,
autocomplete: (word) => {
return app.utils.collectionAutocompleteKeys(props.collection, word, {
requestKeys: false,
collectionJoinKeys: false,
});
},
onsubmit: props.onsubmit,
}),
);
};