mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
merge newui branch
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { input } from "./input";
|
||||
import { settings } from "./settings";
|
||||
import { view } from "./view";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.fieldTypes = window.app.fieldTypes || {};
|
||||
window.app.fieldTypes.relation = {
|
||||
icon: "ri-mind-map",
|
||||
label: "Relation",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
filterModifiers: (f) => {
|
||||
return f.maxSelect > 1 ? ["each", "length"] : [];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return f.maxSelect > 1 ? ["RECORD_ID1", "RECORD_ID2"] : "RECORD_ID";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "rel_" + app.utils.randomString();
|
||||
|
||||
// trigger custom change event for clearing field errors
|
||||
function triggerChangeEvent() {
|
||||
fieldEl?.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { data: props },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const local = store({
|
||||
selected: [],
|
||||
isLoading: true,
|
||||
get maxReached() {
|
||||
const maxSelect = props.field.maxSelect || 1;
|
||||
return app.utils.toArray(props.record[props.field.name]).length >= maxSelect;
|
||||
},
|
||||
});
|
||||
|
||||
async function loadSelected() {
|
||||
local.isLoading = true;
|
||||
|
||||
const ids = app.utils.toArray(props.record[props.field.name]);
|
||||
if (!ids.length) {
|
||||
local.selected = [];
|
||||
local.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fieldCollection = app.store.collections.find((c) => c.id == props.field.collectionId);
|
||||
|
||||
// eagerly expand first level presentable relations (if any and the collections are loaded)
|
||||
const relExpands = [];
|
||||
const presentableRelationFields = fieldCollection?.fields?.filter(
|
||||
(f) => !f.hidden && f.presentable && f.type == "relation",
|
||||
) || [];
|
||||
for (const field of presentableRelationFields) {
|
||||
relExpands.push(field.name);
|
||||
}
|
||||
|
||||
const records = await app.pb.collection(props.field.collectionId).getFullList({
|
||||
requestKey: null,
|
||||
filter: ids.map((id) => app.pb.filter("id={:id}", { id })).join("||"),
|
||||
expand: relExpands.join(",") || undefined,
|
||||
});
|
||||
|
||||
// preserve the original order
|
||||
const orderedRecords = [];
|
||||
for (let id of ids) {
|
||||
const record = records.find((r) => r.id == id);
|
||||
if (record) {
|
||||
orderedRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
local.selected = orderedRecords;
|
||||
local.isLoading = false;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
app.checkApiError(err);
|
||||
local.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const ids = app.utils.toArray(props.record[props.field.name]);
|
||||
const propIndex = ids.indexOf(id);
|
||||
if (propIndex >= 0) {
|
||||
ids.splice(propIndex, 1);
|
||||
updateRecordValue(ids);
|
||||
}
|
||||
|
||||
const selectedIndex = local.selected.findIndex((r) => r.id == id);
|
||||
local.selected.splice(selectedIndex, 1);
|
||||
}
|
||||
|
||||
function updateRecordValue(ids = []) {
|
||||
props.record[props.field.name] = props.field.maxSelect > 1 ? ids : ids?.[0] || "";
|
||||
}
|
||||
|
||||
const watchers = [
|
||||
watch(
|
||||
() => props.record[props.field.name],
|
||||
() => loadSelected(),
|
||||
),
|
||||
];
|
||||
|
||||
const fieldEl = t.div(
|
||||
{
|
||||
className: "record-field-input field-type-relation",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: () => `field ${props.field.required ? "required" : ""}` },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.relation.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.output(
|
||||
{
|
||||
className: "field-content",
|
||||
name: () => props.field.name,
|
||||
},
|
||||
// loader
|
||||
t.div(
|
||||
{
|
||||
hidden: () => !local.isLoading,
|
||||
className: "list",
|
||||
},
|
||||
() => {
|
||||
const ids = app.utils.toArray(props.record[props.field.name]);
|
||||
return ids.map(() => {
|
||||
return t.div({ className: "list-item" }, t.span({ className: "skeleton-loader" }));
|
||||
});
|
||||
},
|
||||
),
|
||||
// list
|
||||
app.components.sortable({
|
||||
className: "list",
|
||||
hidden: () => local.isLoading,
|
||||
data: () => local.selected,
|
||||
onchange: (sortedList) => {
|
||||
local.selected = sortedList;
|
||||
updateRecordValue(sortedList.map((r) => r.id));
|
||||
triggerChangeEvent();
|
||||
},
|
||||
dataItem: (record, relIndex) => {
|
||||
return t.div(
|
||||
{
|
||||
rid: record,
|
||||
className: "list-item highlight",
|
||||
},
|
||||
t.div({ className: "content" }, () => app.components.recordSummary(record)),
|
||||
t.div(
|
||||
{ className: "actions" },
|
||||
t.button(
|
||||
{
|
||||
className: "btn sm secondary transparent circle",
|
||||
ariaDescription: app.attrs.tooltip("Remove"),
|
||||
onclick: () => remove(record.id),
|
||||
},
|
||||
t.i({ className: "ri-close-line" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
// picker btn
|
||||
t.hr({
|
||||
hidden: () => !app.utils.isEmpty(props.record[props.field.name]),
|
||||
className: "m-t-5 m-b-0",
|
||||
}),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm secondary block",
|
||||
disabled: () => local.isLoading,
|
||||
onclick: (e) => {
|
||||
app.modals.openRecordsPicker({
|
||||
collection: props.field.collectionId,
|
||||
selectedIds: app.utils.toArray(props.record[props.field.name]),
|
||||
maxSelect: props.field.maxSelect,
|
||||
onselect: (records) => {
|
||||
local.selected = records;
|
||||
updateRecordValue(records.map((r) => r.id));
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-magic-line" }),
|
||||
t.span({ className: "txt" }, "Open records picker"),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return fieldEl;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(props) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
const cascadeOptions = [
|
||||
{ label: "False", value: false },
|
||||
{ label: "True", value: true },
|
||||
];
|
||||
|
||||
const isMultipleOptions = [
|
||||
{ label: "Single", value: false },
|
||||
{ label: "Multiple", value: true },
|
||||
];
|
||||
|
||||
const watchers = [
|
||||
// reset minSelect
|
||||
watch(
|
||||
() => props.field.maxSelect,
|
||||
(maxSelect) => {
|
||||
maxSelect = maxSelect || 1;
|
||||
if (maxSelect <= 1) {
|
||||
props.field.minSelect = 0;
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return app.components.fieldSettings(props, {
|
||||
header: [
|
||||
t.div(
|
||||
{
|
||||
className: "field header-select collections-select",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
app.components.select({
|
||||
required: true,
|
||||
className: "inline-error",
|
||||
placeholder: "Select collection*",
|
||||
name: () => `fields.${props.fieldIndex}.collectionId`,
|
||||
disabled: () => !!props.originalField?.id,
|
||||
options: () =>
|
||||
app.utils.sortedCollections(app.store.collections.filter((c) => c.type != "view")).map(
|
||||
(c) => {
|
||||
return { value: c.id, label: c.name };
|
||||
},
|
||||
),
|
||||
value: () => props.field.collectionId,
|
||||
onchange: (opts) => {
|
||||
props.field.collectionId = opts?.[0]?.value || "";
|
||||
},
|
||||
after: () => {
|
||||
return [
|
||||
t.hr({ className: "m-t-5 m-b-5" }),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "btn sm outline",
|
||||
onclick: () => {
|
||||
app.modals.openCollectionUpsert({}, {
|
||||
onsave: (newCollection) => {
|
||||
props.field.collectionId = newCollection.id;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
t.i({ className: "ri-add-line" }),
|
||||
t.span({ className: "txt" }, "New collection"),
|
||||
),
|
||||
];
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
className: "field header-select single-multiple-select",
|
||||
},
|
||||
app.components.select({
|
||||
required: true,
|
||||
options: isMultipleOptions,
|
||||
value: () => {
|
||||
return props.field.maxSelect > 1;
|
||||
},
|
||||
onchange: (opts) => {
|
||||
if (opts?.[0]?.value) {
|
||||
if (props.field.maxSelect << 0 < 2) {
|
||||
props.field.maxSelect = 10;
|
||||
}
|
||||
} else {
|
||||
props.field.maxSelect = 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6", hidden: () => props.field.maxSelect << 0 < 2 },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".minSelect" }, "Min select"),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".minSelect",
|
||||
step: 1,
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "No min limit",
|
||||
name: () => `fields.${props.fieldIndex}.minSelect`,
|
||||
value: () => props.field.minSelect || "",
|
||||
onchange: (e) => (props.field.minSelect = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6", hidden: () => props.field.maxSelect << 0 < 2 },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".maxSelect" }, "Max select"),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".maxSelect",
|
||||
step: 1,
|
||||
min: () => props.field.minSelect || 2,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "Default to single",
|
||||
name: () => `fields.${props.fieldIndex}.maxSelect`,
|
||||
value: () => props.field.maxSelect || "",
|
||||
onchange: (e) => {
|
||||
const maxSelect = parseInt(e.target.value, 10);
|
||||
if (maxSelect > 1) {
|
||||
props.field.maxSelect = maxSelect;
|
||||
} else {
|
||||
props.field.maxSelect = 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".cascadeDelete" }, "Cascade delete"),
|
||||
app.components.select({
|
||||
required: true,
|
||||
id: uniqueId + ".cascadeDelete",
|
||||
name: () => `fields.${props.fieldIndex}.cascadeDelete`,
|
||||
options: cascadeOptions,
|
||||
value: () => props.field.cascadeDelete || false,
|
||||
onchange: (opts) => {
|
||||
props.field.cascadeDelete = !!opts?.[0].value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".help" }, "Help text"),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".help",
|
||||
name: () => `fields.${props.fieldIndex}.help`,
|
||||
value: () => props.field.help || "",
|
||||
oninput: (e) => (props.field.help = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
footer: () => [
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
className: "sm",
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".required",
|
||||
name: () => `fields.${props.fieldIndex}.required`,
|
||||
checked: () => !!props.field.required,
|
||||
onchange: (e) => (props.field.required = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".required" },
|
||||
t.span({ className: "txt" }, "Required"),
|
||||
t.small({ className: "txt-hint" }, "(!='')"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value to be nonempty string"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
const maxNestedLazyExpand = 10;
|
||||
const lazyLoadBatchSize = 500;
|
||||
const LAZY_EXPAND_EVENT_NAME = "pb:lazyExpandSummaryRels";
|
||||
|
||||
const recordsToLoad = {}; // collectionId: new Set(id1, id2, id3)
|
||||
const queueTimeoutIds = {}; // collectionId: timeoutId
|
||||
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// meta: undefined,
|
||||
// }
|
||||
export function view(props) {
|
||||
let subsToRemove = new Set();
|
||||
|
||||
return t.div(
|
||||
{
|
||||
className: "record-field-view field-type-relation",
|
||||
onunmount: () => {
|
||||
for (const sub of subsToRemove) {
|
||||
document.removeEventListener(LAZY_EXPAND_EVENT_NAME, sub);
|
||||
}
|
||||
subsToRemove.clear();
|
||||
subsToRemove = null;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
const ids = app.utils.toArray(props.record[props.field.name]);
|
||||
if (!ids.length) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
// stop at cyclic references
|
||||
const meta = props.meta || {};
|
||||
let parents = app.utils.toArray(meta.parents);
|
||||
if (parents.includes(props.record.id)) {
|
||||
return t.span({ className: "marker recursive" }, "(recursive)");
|
||||
}
|
||||
|
||||
const newMeta = JSON.parse(JSON.stringify(meta));
|
||||
newMeta.parents = parents.concat(props.record.id);
|
||||
|
||||
const result = [];
|
||||
|
||||
// truncate "full" view too to prevent freezing the browser tab
|
||||
const maxIndex = props.short ? 3 : 1000;
|
||||
|
||||
const expanded = app.utils.toArray(props.record.expand?.[props.field.name]);
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (i >= maxIndex) {
|
||||
result.push(t.span({ className: "marker more" }, "(", ids.length - maxIndex, " more)"));
|
||||
break;
|
||||
}
|
||||
|
||||
const id = ids[i];
|
||||
const rel = expanded.find((r) => r?.id == id);
|
||||
|
||||
if (rel) {
|
||||
result.push(app.components.recordSummary(rel, newMeta));
|
||||
} else {
|
||||
result.push(
|
||||
t.span(
|
||||
{ className: "label relation-id animate-delayed-fadeIn" },
|
||||
app.components.copyButton(id),
|
||||
id,
|
||||
),
|
||||
);
|
||||
|
||||
// lazy expand
|
||||
if (newMeta.parents.length < maxNestedLazyExpand) {
|
||||
const preferredIndex = i;
|
||||
recordsToLoad[props.field.collectionId] = recordsToLoad[props.field.collectionId] || new Set();
|
||||
recordsToLoad[props.field.collectionId].add(id);
|
||||
const sub = (e) => {
|
||||
if (e.detail.id == id && e.detail.collectionId == props.field.collectionId) {
|
||||
setExpand(props.record, props.field, structuredClone(e.detail), preferredIndex);
|
||||
|
||||
document.removeEventListener(LAZY_EXPAND_EVENT_NAME, sub);
|
||||
subsToRemove.delete(sub);
|
||||
}
|
||||
};
|
||||
subsToRemove.add(sub);
|
||||
document.addEventListener(LAZY_EXPAND_EVENT_NAME, sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchQueuedItems(props.field.collectionId);
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setExpand(record, field, rel, preferredIndex = 0) {
|
||||
record.expand = record.expand || {};
|
||||
|
||||
if (field.maxSelect > 1) {
|
||||
record.expand[field.name] = app.utils.toArray(record.expand[field.name]);
|
||||
|
||||
const existingIndex = record.expand[field.name].findIndex((r) => r?.id == rel.id);
|
||||
if (existingIndex >= 0) {
|
||||
record.expand[field.name][existingIndex] = rel;
|
||||
} else if (!record.expand[field.name][preferredIndex]) {
|
||||
record.expand[field.name][preferredIndex] = rel;
|
||||
} else {
|
||||
record.expand[field.name].push(rel);
|
||||
}
|
||||
} else {
|
||||
record.expand[field.name] = rel;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchQueuedItems(collectionId) {
|
||||
if (!collectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(queueTimeoutIds[collectionId]);
|
||||
queueTimeoutIds[collectionId] = setTimeout(() => {
|
||||
const relIds = Array.from(recordsToLoad[collectionId] || []);
|
||||
if (!relIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// clear without awaiting the fetch calls to allow other queue items to start loading
|
||||
//
|
||||
// it is OK if the same rel id is being requested multiple times,
|
||||
// since the event will be detached after the first call
|
||||
recordsToLoad[collectionId].clear();
|
||||
recordsToLoad[collectionId] = null;
|
||||
queueTimeoutIds[collectionId] = null;
|
||||
|
||||
// split in multiple batches to minimize filter length errors
|
||||
while (relIds.length) {
|
||||
const ids = relIds.splice(0, lazyLoadBatchSize);
|
||||
|
||||
// eagerly expand first level presentable relations (if any)
|
||||
let relExpands = [];
|
||||
const presentableRelationFields = app.store.collections
|
||||
.find((c) => c.id == collectionId)
|
||||
?.fields?.filter((f) => !f.hidden && f.presentable && f.type == "relation") || [];
|
||||
for (let field of presentableRelationFields) {
|
||||
relExpands.push(field.name);
|
||||
}
|
||||
relExpands = relExpands.join(",") || undefined;
|
||||
|
||||
const requestFields = fieldsWithExcerpt(collectionId, presentableRelationFields);
|
||||
|
||||
let request;
|
||||
if (ids.length == 1) {
|
||||
request = app.pb.collection(collectionId).getOne(ids[0], {
|
||||
requestKey: null,
|
||||
expand: relExpands,
|
||||
fields: requestFields,
|
||||
});
|
||||
} else {
|
||||
request = app.pb.collection(collectionId).getFullList({
|
||||
requestKey: null,
|
||||
expand: relExpands,
|
||||
filter: ids.map((id) => app.pb.filter("id={:id}", { id })).join("||"),
|
||||
fields: requestFields,
|
||||
});
|
||||
}
|
||||
|
||||
request
|
||||
.then((expanded) => {
|
||||
expanded = app.utils.toArray(expanded);
|
||||
if (!expanded.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of expanded) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(LAZY_EXPAND_EVENT_NAME, {
|
||||
detail: item,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("failed to lazily expand presentable relation", err);
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function fieldsWithExcerpt(collectionId, expandedRelFields = [], maxLength = 200) {
|
||||
const collection = app.store.collections.find((c) => c.id == collectionId);
|
||||
|
||||
let requestFields = collection?.fields?.filter((f) => f.type == "editor")
|
||||
.map((f) => `${f.name}:excerpt(${maxLength},true)`) || [];
|
||||
|
||||
for (const relField of expandedRelFields) {
|
||||
const excerptRelFields = app.store.collections?.find((c) => c.id == relField.collectionId)
|
||||
?.fields?.filter((f) => f.type == "editor")?.map((f) =>
|
||||
`expand.${relField.name}.${f.name}:excerpt(${maxLength},true)`
|
||||
);
|
||||
if (excerptRelFields?.length) {
|
||||
requestFields.push(`expand.${relField.name}.*`);
|
||||
requestFields = requestFields.concat(excerptRelFields);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestFields.length > 0) {
|
||||
return ["*", "expand.*"].concat(requestFields).join(",");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user