Files
pocketbase/ui/src/records/recordsPickerModal.js
T

486 lines
16 KiB
JavaScript

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",
ariaLabel: app.attrs.tooltip("Add new record"),
onclick: () => {
app.modals.openRecordUpsert(data.collection);
},
},
t.i({ className: "ri-add-line txt-hint", ariaHidden: true }),
),
),
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({
ariaHidden: true,
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",
ariaLabel: app.attrs.tooltip("Edit"),
onclick: (e) => {
e.stopPropagation();
app.modals.openRecordUpsert(data.collection, record);
},
},
t.i({ className: "ri-pencil-line", ariaHidden: true }),
),
),
);
});
},
// 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;
}