mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-10-18 03:58:15 +02:00
merge newui branch
This commit is contained in:
1 parent
58f605e90c
commit
4c44044c0c
804 files changed
+58660
-56663
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { input } from "./input";
|
||||
import { onrecordduplicate } from "./onrecordduplicate";
|
||||
import { onrecordsave } from "./onrecordsave";
|
||||
import { settings } from "./settings";
|
||||
import { view } from "./view";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.fieldTypes = window.app.fieldTypes || {};
|
||||
window.app.fieldTypes.file = {
|
||||
icon: "ri-image-line",
|
||||
label: "File",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
summaryPriority: -1,
|
||||
onrecordsave,
|
||||
onrecordduplicate,
|
||||
filterModifiers: (f) => {
|
||||
return f.maxSelect > 1 ? ["each", "length"] : [];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
if (forSubmit) {
|
||||
if (f.maxSelect > 1) {
|
||||
return [dummyFileObject("test1.txt"), dummyFileObject("test2.txt")];
|
||||
}
|
||||
|
||||
return dummyFileObject("test1.txt");
|
||||
}
|
||||
|
||||
if (f.maxSelect > 1) {
|
||||
return [
|
||||
"test1_" + app.utils.randomString(10) + ".txt",
|
||||
"test2_" + app.utils.randomString(10) + ".txt",
|
||||
];
|
||||
}
|
||||
|
||||
return "test_" + app.utils.randomString(10) + ".txt";
|
||||
},
|
||||
};
|
||||
|
||||
function dummyFileObject(name) {
|
||||
return {
|
||||
toString() {
|
||||
return `new File([...], '${name}')`;
|
||||
},
|
||||
toJSON() {
|
||||
// "[[ and ]]" will have to be manualy replaced after JSON.stringify
|
||||
return `[[new File([...], '${name}')]]`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { filesToDeleteProp } from "./onrecordsave.js";
|
||||
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "file_" + app.utils.randomString();
|
||||
|
||||
function isDeleted(nameOrFile) {
|
||||
if (typeof nameOrFile != "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!props.record[filesToDeleteProp]?.[props.field.name]?.includes(nameOrFile);
|
||||
}
|
||||
|
||||
function toDelete(nameOrFile) {
|
||||
// existing files are just marked for delete to allow restore
|
||||
if (typeof nameOrFile == "string") {
|
||||
props.record[filesToDeleteProp] = props.record[filesToDeleteProp] || {};
|
||||
props.record[filesToDeleteProp][props.field.name] = props.record[filesToDeleteProp][props.field.name] || [];
|
||||
app.utils.pushUnique(props.record[filesToDeleteProp][props.field.name], nameOrFile);
|
||||
triggerChangeEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
// new files are directly removed
|
||||
const normalized = app.utils.toArray(props.record[props.field.name]);
|
||||
const index = normalized.indexOf(nameOrFile);
|
||||
if (index >= 0) {
|
||||
normalized.splice(index, 1);
|
||||
props.record[props.field.name] = normalized;
|
||||
triggerChangeEvent();
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDeleted(nameOrFile) {
|
||||
if (typeof nameOrFile != "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
app.utils.removeByValue(props.record[filesToDeleteProp]?.[props.field.name], nameOrFile);
|
||||
triggerChangeEvent();
|
||||
}
|
||||
|
||||
function totalDeletedFiles() {
|
||||
return props.record[filesToDeleteProp]?.[props.field.name]?.length || 0;
|
||||
}
|
||||
|
||||
function totalFiles() {
|
||||
const totalNormalized = app.utils.toArray(props.record[props.field.name]).length;
|
||||
return totalNormalized - totalDeletedFiles();
|
||||
}
|
||||
|
||||
// trigger custom change event for clearing field errors
|
||||
function triggerChangeEvent() {
|
||||
fieldEl?.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { data: props },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const local = store({
|
||||
get maxReached() {
|
||||
const maxSelect = props.field.maxSelect || 1;
|
||||
return totalFiles() >= maxSelect;
|
||||
},
|
||||
});
|
||||
|
||||
function addFiles(files) {
|
||||
const normalized = app.utils.toArray(props.record[props.field.name]);
|
||||
|
||||
for (let file of files) {
|
||||
if (local.maxReached) {
|
||||
console.warn("can't add more files - max allowed files reached");
|
||||
break;
|
||||
}
|
||||
|
||||
normalized.push(file);
|
||||
}
|
||||
|
||||
props.record[props.field.name] = normalized;
|
||||
|
||||
triggerChangeEvent();
|
||||
}
|
||||
|
||||
const fileInput = t.input({
|
||||
type: "file",
|
||||
hidden: true,
|
||||
name: () => props.field.name,
|
||||
multiple: () => props.field.maxSelect > 1,
|
||||
accept: () => props.field.mimeTypes?.join(",") || undefined,
|
||||
onchange: (e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = null; // reset
|
||||
},
|
||||
});
|
||||
|
||||
const fieldEl = t.div(
|
||||
{
|
||||
className: "record-field-input field-type-file",
|
||||
ondragover: (e) => {
|
||||
e.preventDefault(); // prevent default to allow drop
|
||||
},
|
||||
ondrop: (e) => {
|
||||
const files = e.dataTransfer?.files || [];
|
||||
if (!files.length) {
|
||||
return; // not a file drop
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (local.maxReached) {
|
||||
return;
|
||||
}
|
||||
|
||||
addFiles(files);
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: () => `field ${props.field.required ? "required" : ""}` },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.file.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
fileInput,
|
||||
t.div(
|
||||
{ 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",
|
||||
ariaDescription: app.attrs.tooltip("Remove file"),
|
||||
hidden: () => isDeleted(nameOrFile),
|
||||
onclick: () => toDelete(nameOrFile),
|
||||
},
|
||||
t.i({ className: "ri-close-line" }),
|
||||
),
|
||||
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" }),
|
||||
t.span({ className: "txt" }, "Upload or drop new file"),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return fieldEl;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// {
|
||||
// collection: {},
|
||||
// field: {},
|
||||
// originalRecord: {},
|
||||
// clone: {},
|
||||
// }
|
||||
export function onrecordduplicate(props) {
|
||||
delete props.clone[props.field.name];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const filesToDeleteProp = "@@filesToDelete"; // symbols are not used because they are not reactive
|
||||
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// payload: {},
|
||||
// }
|
||||
export function onrecordsave(props) {
|
||||
const files = app.utils.toArray(props.payload[props.field.name]);
|
||||
|
||||
const toDelete = app.utils.toArray(props.record[filesToDeleteProp]?.[props.field.name]);
|
||||
|
||||
for (let filename of toDelete) {
|
||||
const index = files.indexOf(filename);
|
||||
if (index >= 0) {
|
||||
files.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
props.payload[props.field.name] = files;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
const isMultipleOptions = [
|
||||
{ label: "Single", value: false },
|
||||
{ label: "Multiple", value: true },
|
||||
];
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
header: [
|
||||
t.div(
|
||||
{
|
||||
className: "field header-select single-multiple-select",
|
||||
},
|
||||
app.components.select({
|
||||
required: true,
|
||||
options: isMultipleOptions,
|
||||
value: () => {
|
||||
return data.field.maxSelect > 1;
|
||||
},
|
||||
onchange: (opts) => {
|
||||
if (opts?.[0]?.value) {
|
||||
if (!data.field.maxSelect || data.field.maxSelect < 2) {
|
||||
data.field.maxSelect = 10;
|
||||
}
|
||||
} else {
|
||||
data.field.maxSelect = 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".mimeTypes" },
|
||||
t.span({ className: "txt" }, "Allowed mime types"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Allow files ONLY with the listed mime types.\n Leave empty for no restriction.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
app.components.select({
|
||||
max: 99,
|
||||
placeholder: "No restriction",
|
||||
options: app.utils.mimeTypes.map((opt) => {
|
||||
return {
|
||||
value: opt.mimeType,
|
||||
label: () =>
|
||||
t.div(
|
||||
{ className: "inline-flex gap-10" },
|
||||
t.span({ className: "txt" }, opt.ext || "-"),
|
||||
t.small({ className: "txt-hint" }, opt.mimeType),
|
||||
),
|
||||
};
|
||||
}),
|
||||
name: () => `fields.${data.fieldIndex}.mimeTypes`,
|
||||
value: () => app.utils.toArray(data.field.mimeTypes),
|
||||
onchange: (opts) => (data.field.mimeTypes = opts.map((opt) => opt.value)),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
t.button(
|
||||
{
|
||||
"type": "button",
|
||||
"className": "link-hint gap-0",
|
||||
"html-popovertarget": uniqueId + "mimeTypesDropdown",
|
||||
},
|
||||
t.span({ className: "txt" }, "Choose presets"),
|
||||
t.i({ className: "ri-arrow-drop-down-fill", ariaHidden: true }),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "mimeTypesDropdown",
|
||||
className: "dropdown sm nowrap left p-10",
|
||||
popover: "auto",
|
||||
},
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
role: "menuitem",
|
||||
onclick: (e) => {
|
||||
data.field.mimeTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
},
|
||||
textContent: "Images (jpg, png, svg, gif, webp)",
|
||||
}),
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
role: "menuitem",
|
||||
onclick: (e) => {
|
||||
data.field.mimeTypes = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
];
|
||||
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
},
|
||||
textContent: "Documents (pdf, doc/docx, xls/xlsx)",
|
||||
}),
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
role: "menuitem",
|
||||
onclick: (e) => {
|
||||
data.field.mimeTypes = [
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/quicktime",
|
||||
"video/3gpp",
|
||||
];
|
||||
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
},
|
||||
textContent: "Videos (mp4, mpeg, avi, mov, 3gp)",
|
||||
}),
|
||||
t.button({
|
||||
type: "button",
|
||||
className: "dropdown-item",
|
||||
role: "menuitem",
|
||||
onclick: (e) => {
|
||||
data.field.mimeTypes = [
|
||||
"application/zip",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
];
|
||||
|
||||
e.target.closest(".dropdown").hidePopover();
|
||||
},
|
||||
textContent: "Archives (zip, 7zip, rar)",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: () => (data.field.maxSelect > 1 ? "col-sm-6" : "col-sm-9") },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{
|
||||
htmlFor: uniqueId + ".thumbs",
|
||||
},
|
||||
t.span({ className: "txt" }, "Thumb sizes"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"List of additional thumb sizes for image files, along with the default thumb size of 100x100. The thumbs are generated lazily on first access.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".thumbs",
|
||||
placeholder: "e.g. 50x50, 480x720",
|
||||
name: () => `fields.${data.fieldIndex}.thumbs`,
|
||||
value: () => app.utils.joinNonEmpty(data.field.thumbs),
|
||||
onchange: (e) => (data.field.thumbs = app.utils.splitNonEmpty(e.target.value, ",")),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-help" },
|
||||
t.span({ className: "txt m-r-5" }, "Use comma as separator."),
|
||||
t.button(
|
||||
{
|
||||
"type": "button",
|
||||
"className": "link-hint gap-0",
|
||||
"html-popovertarget": uniqueId + "thumbFormatsDropdown",
|
||||
},
|
||||
t.span({ className: "txt" }, "Supported formats"),
|
||||
t.i({ className: "ri-arrow-drop-down-fill", ariaHidden: true }),
|
||||
),
|
||||
t.div(
|
||||
{
|
||||
id: uniqueId + "thumbFormatsDropdown",
|
||||
className: "dropdown sm nowrap left p-10",
|
||||
popover: "auto",
|
||||
},
|
||||
t.ul(
|
||||
{ className: "m-0 p-l-sm" },
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "WxH"),
|
||||
t.span(null, " (e.g. 100x50) - crop to WxH viewbox (from center)"),
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "WxHt"),
|
||||
t.span(null, " (e.g. 100x50t) - crop to WxH viewbox (from top)"),
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "WxHb"),
|
||||
t.span(null, " (e.g. 100x50b) - crop to WxH viewbox (from bottom)"),
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "WxHf"),
|
||||
t.span(null, " (e.g. 100x50f) - fit inside a WxH viewbox (without cropping)"),
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "0xH"),
|
||||
t.span(null, " (e.g. 0x50) - resize to H height preserving the aspect ratio"),
|
||||
),
|
||||
t.li(
|
||||
null,
|
||||
t.strong(null, "Wx0"),
|
||||
t.span(null, " (e.g. 100x0) - resize to W width preserving the aspect ratio"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-3" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".maxSize" }, "Max size"),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".maxSize",
|
||||
step: 1,
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "~5MB default",
|
||||
name: () => `fields.${data.fieldIndex}.maxSize`,
|
||||
value: () => data.field.maxSize || "",
|
||||
oninput: (e) => (data.field.maxSize = parseInt(e.target.value, 10)),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "In bytes."),
|
||||
),
|
||||
() => {
|
||||
if (data.field.maxSelect > 1) {
|
||||
return t.div(
|
||||
{ className: "col-sm-3" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".maxSelect" }, "Max select"),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".maxSelect",
|
||||
placeholder: "Default to single",
|
||||
step: 1,
|
||||
min: 2,
|
||||
required: true,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
name: () => `fields.${data.fieldIndex}.maxSelect`,
|
||||
value: () => data.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 m-t-5 m-b-5" },
|
||||
t.input({
|
||||
className: "switch",
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".protected",
|
||||
name: () => `fields.${data.fieldIndex}.protected`,
|
||||
checked: () => !!data.field.protected,
|
||||
onchange: (e) => (data.field.protected = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".protected" },
|
||||
t.span({ className: "txt" }, "Protected"),
|
||||
t.small(
|
||||
{ className: "txt-hint" },
|
||||
"Files will require View API rule permissions and file token (",
|
||||
t.a({
|
||||
href: import.meta.env.PB_PROTECTED_FILE_DOCS,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
textContent: "Learn more",
|
||||
}),
|
||||
").",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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.${data.fieldIndex}.help`,
|
||||
value: () => data.field.help || "",
|
||||
oninput: (e) => (data.field.help = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
footer: () => [
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
className: "sm",
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".required",
|
||||
name: () => `fields.${data.fieldIndex}.required`,
|
||||
checked: () => !!data.field.required,
|
||||
onchange: (e) => (data.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,34 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div({ className: "record-field-view field-type-file" }, () => {
|
||||
const filenames = app.utils.toArray(props.record[props.field.name]);
|
||||
if (!filenames.length) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// truncate "full" view too to prevent freezing the browser tab
|
||||
const maxIndex = props.short ? 5 : 100;
|
||||
|
||||
for (let i = 0; i < filenames.length; i++) {
|
||||
if (i >= maxIndex) {
|
||||
result.push(t.span({ className: "thumb sm" }, "+", filenames.length - maxIndex));
|
||||
break;
|
||||
}
|
||||
|
||||
result.push(
|
||||
app.components.recordFileThumb({
|
||||
record: props.record,
|
||||
filename: filenames[i],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user