merge newui branch
This commit is contained in:
440
ui/src/records/recordFilePickerModal.js
Normal file
440
ui/src/records/recordFilePickerModal.js
Normal 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;
|
||||
}
|
||||
149
ui/src/records/recordFileThumb.js
Normal file
149
ui/src/records/recordFileThumb.js
Normal 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" });
|
||||
},
|
||||
);
|
||||
};
|
||||
203
ui/src/records/recordImpersonateModal.js
Normal file
203
ui/src/records/recordImpersonateModal.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
246
ui/src/records/recordPreviewModal.js
Normal file
246
ui/src/records/recordPreviewModal.js
Normal 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;
|
||||
}
|
||||
219
ui/src/records/recordSummary.js
Normal file
219
ui/src/records/recordSummary.js
Normal 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,
|
||||
});
|
||||
};
|
||||
1440
ui/src/records/recordUpsertModal.js
Normal file
1440
ui/src/records/recordUpsertModal.js
Normal file
File diff suppressed because it is too large
Load Diff
735
ui/src/records/recordsList.js
Normal file
735
ui/src/records/recordsList.js
Normal 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,
|
||||
);
|
||||
}
|
||||
484
ui/src/records/recordsPickerModal.js
Normal file
484
ui/src/records/recordsPickerModal.js
Normal 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;
|
||||
}
|
||||
62
ui/src/records/recordsSearchbar.js
Normal file
62
ui/src/records/recordsSearchbar.js
Normal 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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user