fixed relation and file custom change event trigger

This commit is contained in:
Gani Georgiev
2026-04-19 08:52:40 +03:00
parent 5cc95a2e63
commit 6012ba701d
5 changed files with 204 additions and 203 deletions

File diff suppressed because one or more lines are too long

2
ui/dist/index.html vendored
View File

@@ -13,7 +13,7 @@
<!-- prism --> <!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script> <script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-DhtJzO0I.js"></script> <script type="module" crossorigin src="./assets/index-OxsdchXY.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js"> <link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-BLIFQr7L.css"> <link rel="stylesheet" crossorigin href="./assets/index-BLIFQr7L.css">
</head> </head>

View File

@@ -4,8 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "dprint fmt && vite build"
"format": "dprint fmt"
}, },
"dependencies": { "dependencies": {
"leaflet": "^1.9.4", "leaflet": "^1.9.4",

View File

@@ -57,7 +57,7 @@ export function input(props) {
// trigger custom change event for clearing field errors // trigger custom change event for clearing field errors
function triggerChangeEvent() { function triggerChangeEvent() {
fieldEl?.dispatchEvent( fieldContentEl?.dispatchEvent(
new CustomEvent("change", { new CustomEvent("change", {
detail: { data: props }, detail: { data: props },
bubbles: true, bubbles: true,
@@ -92,7 +92,6 @@ export function input(props) {
const fileInput = t.input({ const fileInput = t.input({
type: "file", type: "file",
hidden: true, hidden: true,
name: () => props.field.name,
multiple: () => props.field.maxSelect > 1, multiple: () => props.field.maxSelect > 1,
accept: () => props.field.mimeTypes?.join(",") || undefined, accept: () => props.field.mimeTypes?.join(",") || undefined,
onchange: (e) => { onchange: (e) => {
@@ -101,7 +100,125 @@ export function input(props) {
}, },
}); });
const fieldEl = t.div( const fieldContentEl = t.output(
{
className: "field-content",
name: () => props.field.name,
},
// @todo enable ordering new files before/inbetween existing
app.components.sortable({
className: "list",
data: () => {
const vals = app.utils.toArray(props.record[props.field.name]);
let hadInvalid = false;
// filter empty or invalid values (e.g. from old serialized draft)
for (let i = vals.length - 1; i >= 0; i--) {
if (typeof vals[i] == "string" || vals[i] instanceof Blob) {
continue; // valid
}
hadInvalid = true;
vals.splice(i, 1);
}
// update record model to prevent conflict with required and other validators
if (hadInvalid) {
props.record[props.field.name] = vals;
}
return vals;
},
onchange: (sortedList) => {
props.record[props.field.name] = sortedList;
triggerChangeEvent();
},
dataItem: (nameOrFile, i) => {
return t.div(
{
rid: nameOrFile,
className: () => `list-item highlight ${isDeleted(nameOrFile) ? "deleted" : ""}`,
},
t.div({ className: "content gap-10" }, () => {
if (typeof nameOrFile == "string") {
return [
app.components.recordFileThumb({
record: props.record,
filename: nameOrFile,
}),
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Open in new tab"),
onclick: async () => {
const token = await app.getFileToken(props.record.collectionId);
const url = app.pb.files.getURL(props.record, nameOrFile, {
token,
});
window.open(url, "_blank", "noreferrer,noopener");
},
},
t.span({ className: "txt link-primary" }, nameOrFile),
),
];
}
return [
app.components.uploadedFileThumb({
file: nameOrFile,
}),
t.span({ className: "label success" }, "New"),
t.span({ className: "txt" }, nameOrFile.name),
];
}),
t.div(
{ className: "actions" },
t.button(
{
type: "button",
className: "btn sm secondary transparent circle",
ariaLabel: app.attrs.tooltip("Remove file"),
hidden: () => isDeleted(nameOrFile),
onclick: () => toDelete(nameOrFile),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
t.button(
{
type: "button",
className: "btn sm warning transparent",
hidden: () => !isDeleted(nameOrFile),
onclick: () => restoreDeleted(nameOrFile),
},
t.span({ className: "txt" }, "Restore"),
),
),
);
},
}),
t.hr({
className: "m-t-5 m-b-0",
hidden: () => app.utils.toArray(props.record[props.field.name]).length > 0,
}),
t.button(
{
type: "button",
className: "btn sm secondary block",
title: () => local.maxReached ? "Max allowed files reached" : undefined,
disabled: () => local.maxReached,
onclick: (e) => {
if (!local.maxReached) {
fileInput?.click();
}
document.activeElement?.blur();
},
},
t.i({ className: "ri-upload-cloud-line", ariaHidden: true }),
t.span({ className: "txt" }, "Upload or drop new file"),
),
);
return t.div(
{ {
className: "record-field-input field-type-file", className: "record-field-input field-type-file",
ondragover: (e) => { ondragover: (e) => {
@@ -130,120 +247,7 @@ export function input(props) {
t.span({ className: "txt" }, () => props.field.name), t.span({ className: "txt" }, () => props.field.name),
), ),
fileInput, fileInput,
t.div( fieldContentEl,
{ className: "field-content" },
// @todo enable ordering new files before/inbetween existing
app.components.sortable({
className: "list",
data: () => {
const vals = app.utils.toArray(props.record[props.field.name]);
let hadInvalid = false;
// filter empty or invalid values (e.g. from old serialized draft)
for (let i = vals.length - 1; i >= 0; i--) {
if (typeof vals[i] == "string" || vals[i] instanceof Blob) {
continue; // valid
}
hadInvalid = true;
vals.splice(i, 1);
}
// update record model to prevent conflict with required and other validators
if (hadInvalid) {
props.record[props.field.name] = vals;
}
return vals;
},
onchange: (sortedList) => {
props.record[props.field.name] = sortedList;
triggerChangeEvent();
},
dataItem: (nameOrFile, i) => {
return t.div(
{
rid: nameOrFile,
className: () => `list-item highlight ${isDeleted(nameOrFile) ? "deleted" : ""}`,
},
t.div({ className: "content gap-10" }, () => {
if (typeof nameOrFile == "string") {
return [
app.components.recordFileThumb({
record: props.record,
filename: nameOrFile,
}),
t.button(
{
type: "button",
ariaDescription: app.attrs.tooltip("Open in new tab"),
onclick: async () => {
const token = await app.getFileToken(props.record.collectionId);
const url = app.pb.files.getURL(props.record, nameOrFile, {
token,
});
window.open(url, "_blank", "noreferrer,noopener");
},
},
t.span({ className: "txt link-primary" }, nameOrFile),
),
];
}
return [
app.components.uploadedFileThumb({
file: nameOrFile,
}),
t.span({ className: "label success" }, "New"),
t.span({ className: "txt" }, nameOrFile.name),
];
}),
t.div(
{ className: "actions" },
t.button(
{
type: "button",
className: "btn sm secondary transparent circle",
ariaLabel: app.attrs.tooltip("Remove file"),
hidden: () => isDeleted(nameOrFile),
onclick: () => toDelete(nameOrFile),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
t.button(
{
type: "button",
className: "btn sm warning transparent",
hidden: () => !isDeleted(nameOrFile),
onclick: () => restoreDeleted(nameOrFile),
},
t.span({ className: "txt" }, "Restore"),
),
),
);
},
}),
t.hr({
className: "m-t-5 m-b-0",
hidden: () => app.utils.toArray(props.record[props.field.name]).length > 0,
}),
t.button(
{
type: "button",
className: "btn sm secondary block",
title: () => local.maxReached ? "Max allowed files reached" : undefined,
disabled: () => local.maxReached,
onclick: (e) => {
if (!local.maxReached) {
fileInput.click();
}
document.activeElement?.blur();
},
},
t.i({ className: "ri-upload-cloud-line", ariaHidden: true }),
t.span({ className: "txt" }, "Upload or drop new file"),
),
),
), ),
() => { () => {
if (props.field.help) { if (props.field.help) {
@@ -251,6 +255,4 @@ export function input(props) {
} }
}, },
); );
return fieldEl;
} }

View File

@@ -9,7 +9,7 @@ export function input(props) {
// trigger custom change event for clearing field errors // trigger custom change event for clearing field errors
function triggerChangeEvent() { function triggerChangeEvent() {
fieldEl?.dispatchEvent( fieldContentEl?.dispatchEvent(
new CustomEvent("change", { new CustomEvent("change", {
detail: { data: props }, detail: { data: props },
bubbles: true, bubbles: true,
@@ -87,6 +87,7 @@ export function input(props) {
function updateRecordValue(ids = []) { function updateRecordValue(ids = []) {
props.record[props.field.name] = props.field.maxSelect > 1 ? ids : ids?.[0] || ""; props.record[props.field.name] = props.field.maxSelect > 1 ? ids : ids?.[0] || "";
triggerChangeEvent();
} }
const watchers = [ const watchers = [
@@ -96,7 +97,82 @@ export function input(props) {
), ),
]; ];
const fieldEl = t.div( const fieldContentEl = 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));
},
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",
ariaLabel: app.attrs.tooltip("Remove"),
onclick: () => remove(record.id),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
),
);
},
}),
// 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", ariaHidden: true }),
t.span({ className: "txt" }, "Open records picker"),
),
);
return t.div(
{ {
className: "record-field-input field-type-relation", className: "record-field-input field-type-relation",
onunmount: () => { onunmount: () => {
@@ -110,81 +186,7 @@ export function input(props) {
t.i({ className: app.fieldTypes.relation.icon, ariaHidden: true }), t.i({ className: app.fieldTypes.relation.icon, ariaHidden: true }),
t.span({ className: "txt" }, () => props.field.name), t.span({ className: "txt" }, () => props.field.name),
), ),
t.output( fieldContentEl,
{
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",
ariaLabel: app.attrs.tooltip("Remove"),
onclick: () => remove(record.id),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
),
);
},
}),
// 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", ariaHidden: true }),
t.span({ className: "txt" }, "Open records picker"),
),
),
), ),
() => { () => {
if (props.field.help) { if (props.field.help) {
@@ -192,6 +194,4 @@ export function input(props) {
} }
}, },
); );
return fieldEl;
} }