merge newui branch
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { onrecordduplicate } from "./onrecordduplicate";
|
||||
import { settings } from "./settings";
|
||||
import { view } from "./view";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.fieldTypes = window.app.fieldTypes || {};
|
||||
window.app.fieldTypes.autodate = {
|
||||
icon: "ri-calendar-check-line",
|
||||
label: "Autodate",
|
||||
settings,
|
||||
view,
|
||||
onrecordduplicate,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
if (forSubmit) {
|
||||
return undefined; // hide
|
||||
}
|
||||
|
||||
return new Date().toISOString().replaceAll("T", " ");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// {
|
||||
// field: {},
|
||||
// originalRecord: {},
|
||||
// clone: {},
|
||||
// }
|
||||
export function onrecordduplicate(props) {
|
||||
delete props.clone[props.field.name];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(props) {
|
||||
const ON_CREATE = 1;
|
||||
const ON_UPDATE = 2;
|
||||
const ON_CREATE_UPDATE = 3;
|
||||
|
||||
const options = [
|
||||
{ label: "Create", value: ON_CREATE },
|
||||
{ label: "Update", value: ON_UPDATE },
|
||||
{ label: "Create/Update", value: ON_CREATE_UPDATE },
|
||||
];
|
||||
|
||||
function getOptionFromField(field) {
|
||||
if (field.onCreate && field.onUpdate) {
|
||||
return ON_CREATE_UPDATE;
|
||||
}
|
||||
|
||||
if (field.onUpdate) {
|
||||
return ON_UPDATE;
|
||||
}
|
||||
|
||||
return ON_CREATE;
|
||||
}
|
||||
|
||||
function updateField(option) {
|
||||
switch (option) {
|
||||
case ON_CREATE:
|
||||
props.field.onCreate = true;
|
||||
props.field.onUpdate = false;
|
||||
break;
|
||||
case ON_UPDATE:
|
||||
props.field.onCreate = false;
|
||||
props.field.onUpdate = true;
|
||||
break;
|
||||
case ON_CREATE_UPDATE:
|
||||
props.field.onCreate = true;
|
||||
props.field.onUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const local = store({
|
||||
isDropdownOpen: false,
|
||||
});
|
||||
|
||||
return app.components.fieldSettings(props, {
|
||||
header: t.div(
|
||||
{
|
||||
className: "field header-select autodate-select",
|
||||
ariaDescription: app.attrs.tooltip("Auto set on", "left"),
|
||||
onmount: () => {
|
||||
// init default value
|
||||
updateField(getOptionFromField(props.field));
|
||||
},
|
||||
},
|
||||
app.components.select({
|
||||
required: true,
|
||||
options: options,
|
||||
disabled: () => props.originalCollection?.system,
|
||||
value: () => getOptionFromField(props.field),
|
||||
onchange: (opts) => updateField(opts?.[0]?.value),
|
||||
ondropdowntoggle: (e) => {
|
||||
local.isDropdownOpen = e.newState == "open";
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-autodate" },
|
||||
app.components.formattedDate({
|
||||
value: () => props.record[props.field.name],
|
||||
short: () => props.short,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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.bool = {
|
||||
icon: "ri-toggle-line",
|
||||
label: "Bool",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return [true, false][Math.floor(Math.random() * 2)];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "bool_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-bool" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
type: "checkbox",
|
||||
id: uniqueId,
|
||||
className: "switch",
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
checked: () => props.record[props.field.name] || false,
|
||||
onchange: (e) => (props.record[props.field.name] = e.target.checked || false),
|
||||
}),
|
||||
t.label({ htmlFor: uniqueId }, () => props.field.name),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
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" }, "(=true)"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value to be true."),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-bool" },
|
||||
t.span(
|
||||
{ className: () => `label ${props.record[props.field.name] ? "success" : ""}` },
|
||||
() => props.record[props.field.name] ? "True" : "False",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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.date = {
|
||||
icon: "ri-calendar-line",
|
||||
label: "Datetime",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return new Date().toISOString().replaceAll("T", " ");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "date_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-date" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.date.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.input({
|
||||
id: uniqueId,
|
||||
step: 1,
|
||||
type: "datetime-local",
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
value: () => app.utils.toDatetimeLocalInputValue(props.record[props.field.name]),
|
||||
onchange: (e) => {
|
||||
props.record[props.field.name] = app.utils.toRFC3339Datetime(e.target.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".min" }, t.span({ className: "txt" }, "Min date (Local)")),
|
||||
t.input({
|
||||
type: "datetime-local",
|
||||
id: uniqueId + ".min",
|
||||
step: 1,
|
||||
name: () => `fields.${data.fieldIndex}.min`,
|
||||
value: () => app.utils.toDatetimeLocalInputValue(data.field.min),
|
||||
onchange: (e) => {
|
||||
data.field.min = app.utils.toRFC3339Datetime(e.target.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".max" }, t.span({ className: "txt" }, "Max date (Local)")),
|
||||
t.input({
|
||||
type: "datetime-local",
|
||||
id: uniqueId + ".max",
|
||||
step: 1,
|
||||
name: () => `fields.${data.fieldIndex}.max`,
|
||||
value: () => app.utils.toDatetimeLocalInputValue(data.field.max),
|
||||
onchange: (e) => {
|
||||
data.field.max = app.utils.toRFC3339Datetime(e.target.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.${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,14 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-date" },
|
||||
app.components.formattedDate({
|
||||
value: () => props.record[props.field.name],
|
||||
short: () => props.short,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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.editor = {
|
||||
icon: "ri-edit-2-line",
|
||||
label: "Rich editor",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
filterModifiers: (f) => {
|
||||
return ["lower"];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return "Lorem ipsum dolor sit amet...";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "editor_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
lazyEditor: null,
|
||||
});
|
||||
|
||||
return t.div(
|
||||
{
|
||||
className: "record-field-input field-type-editor large-modal",
|
||||
onmount: () => {
|
||||
requestAnimationFrame(() => {
|
||||
local.lazyEditor = app.components.tinymce({
|
||||
id: uniqueId,
|
||||
required: () => props.field.required,
|
||||
convertURLs: () => props.field.convertURLs,
|
||||
name: () => props.field.name,
|
||||
value: () => props.record[props.field.name] || "",
|
||||
onchange: (val) => {
|
||||
props.record[props.field.name] = val;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.editor.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
() => local.lazyEditor,
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
showInfo: false,
|
||||
});
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".maxSize" },
|
||||
t.span(null, "Max size "),
|
||||
t.small(null, "(bytes)"),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".maxSize",
|
||||
name: () => `fields.${data.fieldIndex}.maxSize`,
|
||||
min: 0,
|
||||
step: 1,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "Default to max ~5MB",
|
||||
value: () => data.field.maxSize || "",
|
||||
oninput: (e) => {
|
||||
data.field.maxSize = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
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.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value to be nonempty string"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
className: "sm",
|
||||
type: "checkbox",
|
||||
id: uniqueId + ".convertURLs",
|
||||
name: () => `fields.${data.fieldIndex}.convertURLs`,
|
||||
checked: () => !!data.field.convertURLs,
|
||||
onchange: (e) => (data.field.convertURLs = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".convertURLs" },
|
||||
t.span({ className: "txt" }, "Strip URLs domain"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"This could help making the editor content more portable between environments since there will be no local base url to replace.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-editor" },
|
||||
() => {
|
||||
if (props.short) {
|
||||
const value = props.record[props.field.name];
|
||||
if (!value) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
return t.span({
|
||||
className: "txt",
|
||||
textContent: app.utils.truncate(app.utils.plainText(value), 200),
|
||||
});
|
||||
}
|
||||
|
||||
return app.components.tinymce({
|
||||
readonly: true,
|
||||
className: "large-modal",
|
||||
value: () => props.record[props.field.name] || "",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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.email = {
|
||||
icon: "ri-mail-line",
|
||||
label: "Email",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
filterModifiers: (f) => {
|
||||
return ["lower"];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return `test_${app.utils.randomString(3, "123567890")}@example.com`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "email_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-email" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.email.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.input({
|
||||
type: "email",
|
||||
id: uniqueId,
|
||||
spellcheck: false,
|
||||
autocomplete: false,
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
value: () => props.record[props.field.name] || "",
|
||||
oninput: (e) => (props.record[props.field.name] = e.target.value),
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".exceptDomains" },
|
||||
t.span({ className: "txt" }, "Except domains"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
`List of domains that are NOT allowed.\nThis field is disabled if "Only domains" is set.`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".exceptDomains",
|
||||
disabled: () => !app.utils.isEmpty(data.field.onlyDomains),
|
||||
name: () => `fields.${data.fieldIndex}.exceptDomains`,
|
||||
value: () => app.utils.joinNonEmpty(data.field.exceptDomains),
|
||||
onchange: (
|
||||
e,
|
||||
) => (data.field.exceptDomains = app.utils.splitNonEmpty(e.target.value, ",")),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Use comma as separator."),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".onlyDomains" },
|
||||
t.span({ className: "txt" }, "Only domains"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
`List of domains that are ONLY allowed.\nThis field is disabled if "Except domains" is set.`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".onlyDomains",
|
||||
disabled: () => !app.utils.isEmpty(data.field.exceptDomains),
|
||||
name: () => `fields.${data.fieldIndex}.onlyDomains`,
|
||||
value: () => app.utils.joinNonEmpty(data.field.onlyDomains),
|
||||
onchange: (e) => (data.field.onlyDomains = app.utils.splitNonEmpty(e.target.value, ",")),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Use comma as separator."),
|
||||
),
|
||||
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,26 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-email" },
|
||||
() => {
|
||||
const value = props.record[props.field.name] || "";
|
||||
|
||||
if (!value) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
if (props.short) {
|
||||
return t.span({
|
||||
className: "txt txt-ellipsis",
|
||||
textContent: app.utils.truncate(value),
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.geoPoint = {
|
||||
icon: "ri-map-pin-2-line",
|
||||
label: "Geo Point",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
identifierExtractor: function(field, prefix = "") {
|
||||
return [prefix + field.name + ".lon", prefix + field.name + ".lat"];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return { lon: 0, lat: 0 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
// {
|
||||
// get collection: undefined,
|
||||
// get originalRecord: undefined,
|
||||
// get record: undefined,
|
||||
// get field: undefined,
|
||||
// }
|
||||
export function input(data) {
|
||||
const uniqueId = "geo_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
showMap: false,
|
||||
});
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-geoPoint" },
|
||||
t.div(
|
||||
{ className: () => `field-list ${data.field.required ? "required" : ""}` },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.geoPoint.icon }),
|
||||
t.span({ className: "txt" }, () => data.field.name),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field-list-content" },
|
||||
t.div(
|
||||
{ className: "field-list-item p-0" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div({ className: "field addon" }, t.label({ htmlFor: uniqueId + ".lon" }, "Longitude:")),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: uniqueId + ".lon",
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "-180",
|
||||
max: "180",
|
||||
placeholder: 0,
|
||||
name: () => data.field.name,
|
||||
required: () => data.field.required,
|
||||
value: () => data.record[data.field.name]?.lon || "",
|
||||
onchange: (e) => {
|
||||
data.record[data.field.name] = data.record[data.field.name] || {};
|
||||
data.record[data.field.name].lon = Number(e.target.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.span({ className: "delimiter" }),
|
||||
t.div({ className: "field addon" }, t.label({ htmlFor: uniqueId + ".lat" }, "Latitude:")),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.input({
|
||||
id: uniqueId + ".lat",
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "-90",
|
||||
max: "90",
|
||||
placeholder: 0,
|
||||
name: () => data.field.name,
|
||||
required: () => data.field.required,
|
||||
value: () => data.record[data.field.name]?.lat || "",
|
||||
onchange: (e) => {
|
||||
data.record[data.field.name] = data.record[data.field.name] || {};
|
||||
data.record[data.field.name].lat = Number(e.target.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
t.span({ className: "delimiter" }),
|
||||
t.div(
|
||||
{ className: "field addon p-5" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn sm circle secondary ${local.showMap ? "" : "transparent"}`,
|
||||
onclick: () => (local.showMap = !local.showMap),
|
||||
},
|
||||
t.i({ className: "ri-map-2-line" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (!local.showMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "field-list-item p-0", style: "height: 250px" },
|
||||
app.components.leaflet({
|
||||
point: () => data.record[data.field.name] || { lat: 0, lon: 0 },
|
||||
onchange: (newPoint) => {
|
||||
data.record[data.field.name] = structuredClone(newPoint);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (data.field.help) {
|
||||
return t.div({ className: "field-help" }, data.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
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" }, "(=true)"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value to be true."),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(data) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-geoPoint" },
|
||||
t.span({ className: "label" }, () => {
|
||||
const coords = data.record[data.field.name];
|
||||
return `${coords?.lon || 0}, ${coords?.lat || 0}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { input } from "./input";
|
||||
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.json = {
|
||||
icon: "ri-braces-line",
|
||||
label: "JSON",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
onrecordsave,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return { "example": 123 };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "json_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
value: "",
|
||||
});
|
||||
|
||||
const watchers = [
|
||||
watch(
|
||||
() => props.record[props.field.name],
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== "" && newVal === local.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// quote string values if not already
|
||||
if (typeof newVal == "string" && !newVal.startsWith("\"") && !newVal.endsWith("\"")) {
|
||||
local.value = JSON.stringify(typeof newVal === "undefined" ? null : newVal);
|
||||
props.record[props.field.name] = local.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof newVal == "string" && newVal.startsWith("\"") && newVal.endsWith("\"")) {
|
||||
local.value = newVal; // already double quoted
|
||||
} else if (newVal === null) {
|
||||
local.value = "null";
|
||||
} else {
|
||||
local.value = JSON.stringify(typeof newVal === "undefined" ? null : newVal, null, 2);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
function updateRecordValue() {
|
||||
const trimmed = local.value.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
props.record[props.field.name] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed == "string") {
|
||||
props.record[props.field.name] = JSON.stringify(parsed);
|
||||
} else {
|
||||
props.record[props.field.name] = parsed;
|
||||
}
|
||||
} catch (_) {
|
||||
props.record[props.field.name] = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
let updateRecordValueTimeoutId;
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-json" },
|
||||
t.div(
|
||||
{
|
||||
className: "field",
|
||||
onunmount: () => {
|
||||
clearTimeout(updateRecordValueTimeoutId);
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.json.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
t.span(
|
||||
{
|
||||
hidden: () => isValidStringifiedJSON(local.value.trim()),
|
||||
className: "json-state",
|
||||
ariaDescription: app.attrs.tooltip("Invalid JSON", "left"),
|
||||
},
|
||||
t.i({ className: "ri-error-warning-fill txt-danger" }),
|
||||
),
|
||||
t.span(
|
||||
{
|
||||
hidden: () => !isValidStringifiedJSON(local.value.trim()),
|
||||
className: "json-state",
|
||||
ariaDescription: app.attrs.tooltip("Valid JSON", "left"),
|
||||
},
|
||||
t.i({ className: "ri-checkbox-circle-fill txt-success" }),
|
||||
),
|
||||
),
|
||||
app.components.codeEditor({
|
||||
language: "js",
|
||||
id: uniqueId,
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
value: () => local.value,
|
||||
oninput: (val) => (local.value = val),
|
||||
onblur: () => updateRecordValue(),
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isValidStringifiedJSON(val) {
|
||||
if (val === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
// {
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// payload: {},
|
||||
// }
|
||||
export function onrecordsave(props) {
|
||||
try {
|
||||
const val = props.record[props.field.name];
|
||||
if (typeof val == "string") {
|
||||
JSON.parse(val);
|
||||
}
|
||||
} catch (err) {
|
||||
// simulate API error
|
||||
throw new ClientResponseError({
|
||||
status: 400,
|
||||
response: {
|
||||
message: "Invalid JSON data",
|
||||
data: {
|
||||
[props.field.name]: {
|
||||
code: "invalid_json",
|
||||
message: err.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
const local = store({
|
||||
showInfo: false,
|
||||
});
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".maxSize" },
|
||||
t.span(null, "Max size "),
|
||||
t.small(null, "(bytes)"),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".maxSize",
|
||||
name: () => `fields.${data.fieldIndex}.maxSize`,
|
||||
min: 0,
|
||||
step: 1,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "Default to max ~1MB",
|
||||
value: () => data.field.maxSize || "",
|
||||
oninput: (e) => {
|
||||
data.field.maxSize = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
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),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-12" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn sm secondary ${local.showInfo ? "" : "transparent"}`,
|
||||
onclick: () => (local.showInfo = !local.showInfo),
|
||||
},
|
||||
t.span({ className: "txt" }, "String value normalizations"),
|
||||
t.i({
|
||||
className: () => (local.showInfo ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"),
|
||||
}),
|
||||
),
|
||||
app.components.slide(
|
||||
() => local.showInfo,
|
||||
t.div(
|
||||
{ className: "alert m-t-10 info" },
|
||||
t.div(
|
||||
{ className: "content" },
|
||||
"In order to support seamlessly both ",
|
||||
t.code(null, "application/json"),
|
||||
" and ",
|
||||
t.code(null, "multipart/form-data"),
|
||||
"requests, the following normalization rules are applied if the ",
|
||||
t.code(null, "json"),
|
||||
" field is a plain string:",
|
||||
t.ul(
|
||||
null,
|
||||
t.li(null, `"true" is converted to the json `, t.code(null, "true")),
|
||||
t.li(null, `"false" is converted to the json `, t.code(null, "false")),
|
||||
t.li(null, `"null" is converted to the json `, t.code(null, "null")),
|
||||
t.li(null, `"[1,2,3]" is converted to the json `, t.code(null, "[1,2,3]")),
|
||||
t.li(
|
||||
null,
|
||||
`'{"a":1,"b":2}' is converted to the json `,
|
||||
t.code(null, `{"a":1,"b":2}`),
|
||||
),
|
||||
t.li(null, `numeric strings are converted to json number`),
|
||||
t.li(
|
||||
null,
|
||||
`double quoted strings are left as they are (aka. without normalizations)`,
|
||||
),
|
||||
t.li(null, `any other string (empty string too) is double quoted`),
|
||||
),
|
||||
"Alternatively, if you want to avoid the string value normalizations, you can wrap your data inside an object, eg. ",
|
||||
t.code(null, "{\"data\": anything}"),
|
||||
".",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value NOT to be null, '', [], {}"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div({ className: "record-field-view field-type-json" }, () => {
|
||||
const rawValue = props.record[props.field.name];
|
||||
|
||||
if (props.short) {
|
||||
return t.span({
|
||||
className: "txt-code txt-ellipsis",
|
||||
textContent: app.utils.truncate(app.utils.trimQuotedValue(JSON.stringify(rawValue)) || ""),
|
||||
});
|
||||
}
|
||||
|
||||
return app.components.codeBlock({
|
||||
value: () => JSON.stringify(rawValue, null, 2),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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.number = {
|
||||
icon: "ri-hashtag",
|
||||
label: "Number",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return 123.456;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "number_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-number" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.number.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId,
|
||||
step: "any",
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
min: () => props.field.min,
|
||||
max: () => props.field.max,
|
||||
value: () => props.record[props.field.name] || "",
|
||||
oninput: (e) => (props.record[props.field.name] = Number(e.target.value)),
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".min" }, "Min"),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".min",
|
||||
name: () => `fields.${data.fieldIndex}.min`,
|
||||
value: () => data.field.min || "",
|
||||
oninput: (e) => (data.field.min = Number(e.target.value)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: uniqueId + ".max" }, "Max"),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".max",
|
||||
min: () => data.field.min,
|
||||
name: () => `fields.${data.fieldIndex}.max`,
|
||||
value: () => data.field.max || "",
|
||||
oninput: (e) => (data.field.max = Number(e.target.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.${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 + ".onlyInt",
|
||||
name: () => `fields.${data.fieldIndex}.onlyInt`,
|
||||
checked: () => !!data.field.onlyInt,
|
||||
onchange: (e) => (data.field.onlyInt = e.target.checked),
|
||||
}),
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".onlyInt" },
|
||||
t.span({ className: "txt" }, "No decimals"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Existing decimal numbers will not be affected."),
|
||||
}),
|
||||
),
|
||||
),
|
||||
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" }, "(!=0)"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Requires the field value to be not 0."),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-number" },
|
||||
t.span({ className: "txt" }, () => props.record[props.field.name]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { settings } from "./settings";
|
||||
|
||||
window.app = window.app || {};
|
||||
window.app.fieldTypes = window.app.fieldTypes || {};
|
||||
window.app.fieldTypes.password = {
|
||||
icon: "ri-lock-password-line",
|
||||
label: "Password",
|
||||
settings,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
showHidden: false,
|
||||
showPresentable: false,
|
||||
showDuplicate: false,
|
||||
content: t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".min" },
|
||||
t.span({ className: "txt" }, "Min length"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Clear the field or set it to 0 for no limit."),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".min",
|
||||
name: () => `fields.${data.fieldIndex}.min`,
|
||||
step: 1,
|
||||
min: 0,
|
||||
max: 71,
|
||||
placeholder: "No min limit",
|
||||
value: () => data.field.min || "",
|
||||
oninput: (e) => {
|
||||
data.field.min = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".max" },
|
||||
t.span({ className: "txt" }, "Max length"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Clear the field or set it to 0 to fallback to the default limit (71).",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".max",
|
||||
name: () => `fields.${data.fieldIndex}.max`,
|
||||
step: 1,
|
||||
min: () => data.field.min || 0,
|
||||
max: 71,
|
||||
placeholder: "Up to 71 chars",
|
||||
value: () => data.field.max || "",
|
||||
oninput: (e) => {
|
||||
data.field.max = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".max" },
|
||||
t.span({ className: "txt" }, "Bcrypt cost"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Clear the field or set it to 0 to fallback to the default (10).",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".cost",
|
||||
name: () => `fields.${data.fieldIndex}.cost`,
|
||||
step: 1,
|
||||
// https://pkg.go.dev/golang.org/x/crypto/bcrypt#pkg-constants
|
||||
min: 4,
|
||||
max: 31,
|
||||
placeholder: "Default to 10",
|
||||
value: () => data.field.cost || "",
|
||||
oninput: (e) => {
|
||||
data.field.cost = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".pattern" },
|
||||
t.span({ className: "txt" }, "Validation pattern"),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".pattern",
|
||||
placeholder: "ex. ^\\w+$",
|
||||
name: () => `fields.${data.fieldIndex}.pattern`,
|
||||
value: () => data.field.pattern || "",
|
||||
oninput: (e) => (data.field.pattern = e.target.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.${data.fieldIndex}.help`,
|
||||
value: () => data.field.help || "",
|
||||
oninput: (e) => (data.field.help = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
footer: () => {
|
||||
// the system password auth field is always required
|
||||
if (data.collection?.type == "auth" && data.field.name == "password") {
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
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,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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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.select = {
|
||||
icon: "ri-list-check",
|
||||
label: "Select",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
filterModifiers: (f) => {
|
||||
return f.maxSelect > 1 ? ["each", "length"] : [];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
if (f.maxSelect > 1) {
|
||||
return f.values?.slice(0, 2) || [];
|
||||
}
|
||||
return f.values?.[0] || "";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(data) {
|
||||
const uniqueId = "select_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-select" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.select.icon }),
|
||||
t.span({ className: "txt" }, () => data.field.name),
|
||||
),
|
||||
app.components.select({
|
||||
id: uniqueId,
|
||||
max: () => data.field.maxSelect || 1,
|
||||
required: () => data.field.required,
|
||||
options: () => {
|
||||
return data.field.values.map((v) => {
|
||||
return { value: v };
|
||||
});
|
||||
},
|
||||
value: () => {
|
||||
return app.utils.toArray(data.record[data.field.name]);
|
||||
},
|
||||
onchange: (opts) => {
|
||||
if (data.field.maxSelect <= 1) {
|
||||
data.record[data.field.name] = opts?.[0]?.value || "";
|
||||
return;
|
||||
}
|
||||
|
||||
data.record[data.field.name] = opts.map((o) => o.value);
|
||||
},
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (data.field.help) {
|
||||
return t.div({ className: "field-help" }, data.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(props) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
const isMultipleOptions = [
|
||||
{ label: "Single", value: false },
|
||||
{ label: "Multiple", value: true },
|
||||
];
|
||||
|
||||
const optionsDropdown = t.div(
|
||||
{
|
||||
popover: "manual",
|
||||
className: "dropdown field-select-choices-dropdown",
|
||||
},
|
||||
t.div({ className: "field-help m-t-0", style: "font-size: 0.9em" }, "New-line separated choices:"),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.textarea({
|
||||
className: "autoexpand",
|
||||
required: true,
|
||||
value: () => {
|
||||
const vals = app.utils.toArray(props.field.values, false);
|
||||
return vals.join("\n");
|
||||
},
|
||||
oninput: (e) => {
|
||||
const vals = e.target.value.trimStart().replaceAll("\n\n", "\n").split("\n");
|
||||
props.field.values = vals;
|
||||
|
||||
// clear previous errors
|
||||
app.utils.deleteByPath(app.store.errors, `fields.${props.fieldIndex}.values`);
|
||||
},
|
||||
onchange: (e) => {
|
||||
// filter duplicates and empty values
|
||||
const unique = new Set();
|
||||
const vals = e.target.value.split("\n");
|
||||
for (let val of vals) {
|
||||
if (val == "") {
|
||||
continue;
|
||||
}
|
||||
unique.add(val);
|
||||
}
|
||||
|
||||
props.field.values = Array.from(unique);
|
||||
},
|
||||
onblur: (e) => {
|
||||
if (!e.relatedTarget || !optionsDropdown.contains(e.relatedTarget)) {
|
||||
optionsDropdown.hidePopover();
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const watchers = [
|
||||
// cap maxSelect value
|
||||
watch(() => {
|
||||
if (props.field.values?.length && props.field.maxSelect > props.field.values.length) {
|
||||
props.field.maxSelect = props.field.values.length;
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return app.components.fieldSettings(props, {
|
||||
header: [
|
||||
t.div(
|
||||
{
|
||||
className: "field header-select field-select-choices-input",
|
||||
onunmount: () => {
|
||||
watchers.forEach((w) => w?.unwatch());
|
||||
},
|
||||
},
|
||||
t.input({
|
||||
type: "text",
|
||||
placeholder: "Add choices*",
|
||||
className: "txt-left inline-error",
|
||||
value: () => props.field.values?.join(" • ") || "",
|
||||
name: () => `fields.${props.fieldIndex}.values`,
|
||||
onfocus: (e) => {
|
||||
optionsDropdown?.showPopover({ source: e.target });
|
||||
optionsDropdown.querySelector("textarea")?.focus();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
optionsDropdown,
|
||||
),
|
||||
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) {
|
||||
props.field.maxSelect = props.field.values.length || 2;
|
||||
} else {
|
||||
props.field.maxSelect = 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
() => {
|
||||
if (props.field.maxSelect > 1) {
|
||||
return t.div(
|
||||
{ className: "col-sm-12" },
|
||||
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,
|
||||
max: () => props.field.values?.length || 2,
|
||||
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 + ".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",
|
||||
textContent: () => (props.field.maxSelect > 1 ? "(!=[])" : "(!='')"),
|
||||
}),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(() => {
|
||||
return `Requires the field value to be nonempty ${
|
||||
props.field.maxSelect > 1 ? "array" : "string"
|
||||
}.`;
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-select" },
|
||||
t.div({ className: "inline-flex gap-5" }, () => {
|
||||
const opts = app.utils.toArray(props.record[props.field.name], false);
|
||||
|
||||
if (!opts.length) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
return opts.map((opt) => {
|
||||
return t.span({
|
||||
className: "label",
|
||||
title: opt,
|
||||
textContent: app.utils.truncate(opt, 100),
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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.text = {
|
||||
icon: "ri-text",
|
||||
label: "Plain text",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
filterModifiers: (f) => {
|
||||
return ["lower"];
|
||||
},
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return f.primaryKey ? app.utils.randomString(15) : "example text";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "text_" + app.utils.randomString();
|
||||
|
||||
const data = store({
|
||||
get hasAutogenerate() {
|
||||
return !app.utils.isEmpty(props.field.autogeneratePattern) && app.utils.isEmpty(props.originalRecord?.id);
|
||||
},
|
||||
get isDisabled() {
|
||||
return !app.utils.isEmpty(props.originalRecord?.id) && props.field.primaryKey;
|
||||
},
|
||||
get isRequired() {
|
||||
return props.field.required && !data.hasAutogenerate && !data.isDisabled;
|
||||
},
|
||||
});
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-text" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({
|
||||
className: () => (props.field.primaryKey ? "ri-key-line" : app.fieldTypes.text.icon),
|
||||
}),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.textarea({
|
||||
id: uniqueId,
|
||||
className: "autoexpand",
|
||||
rows: 1,
|
||||
name: () => props.field.name,
|
||||
required: () => data.isRequired,
|
||||
disabled: () => data.isDisabled,
|
||||
placeholder: () => (data.hasAutogenerate ? "Leave empty to autogenerate..." : ""),
|
||||
value: () => props.record[props.field.name] || "",
|
||||
oninput: (e) => (props.record[props.field.name] = e.target.value || ""),
|
||||
}),
|
||||
),
|
||||
// list the autodate field values in a tooltip next to the primary key
|
||||
() => {
|
||||
if (!props.field.primaryKey || !props.originalRecord?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autodateFields = props.collection?.fields?.filter((f) => f.type == "autodate") || [];
|
||||
if (!autodateFields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autodateValues = [];
|
||||
for (let f of autodateFields) {
|
||||
autodateValues.push(`${f.name}: ${app.utils.stringifyValue(props.record[f.name])}`);
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{ className: "field addon" },
|
||||
t.i({
|
||||
className: "ri-information-line txt-hint link-faded",
|
||||
ariaDescription: app.attrs.tooltip(autodateValues.join("\n"), "left"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".min" },
|
||||
t.span({ className: "txt" }, "Min length"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip("Clear the field or set it to 0 for no limit."),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".min",
|
||||
name: () => `fields.${data.fieldIndex}.min`,
|
||||
step: 1,
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "No min limit",
|
||||
value: () => data.field.min || "",
|
||||
oninput: (e) => {
|
||||
data.field.min = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".max" },
|
||||
t.span({ className: "txt" }, "Max length"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Clear the field or set it to 0 to fallback to the default limit.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "number",
|
||||
id: uniqueId + ".max",
|
||||
name: () => `fields.${data.fieldIndex}.max`,
|
||||
step: 1,
|
||||
min: () => data.field.min || 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
placeholder: "Default to max 5000 characters",
|
||||
value: () => data.field.max || "",
|
||||
oninput: (e) => {
|
||||
data.field.max = parseInt(e.target.value, 10);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".pattern" },
|
||||
t.span({ className: "txt" }, "Validation pattern"),
|
||||
() => {
|
||||
if (data.field.primaryKey) {
|
||||
return t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"All record ids have forbidden characters and unique case-insensitive (ASCII) validations in addition to the user defined regex pattern.",
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".pattern",
|
||||
name: () => `fields.${data.fieldIndex}.pattern`,
|
||||
value: () => data.field.pattern || "",
|
||||
oninput: (e) => (data.field.pattern = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Ex. ", t.code(null, "^[a-z0-9]+$")),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".autogeneratePattern" },
|
||||
t.span({ className: "txt" }, "Autogenerate pattern"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
"Set and autogenerate text matching the pattern on missing record create value.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".autogeneratePattern",
|
||||
name: () => `fields.${data.fieldIndex}.autogeneratePattern`,
|
||||
value: () => data.field.autogeneratePattern || "",
|
||||
oninput: (e) => (data.field.autogeneratePattern = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Ex. ", t.code(null, "[a-z0-9]{30}")),
|
||||
),
|
||||
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,42 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div({ className: "record-field-view field-type-text" }, () => {
|
||||
const value = props.record[props.field.name] || "";
|
||||
|
||||
if (value == "") {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
if (props.field.primaryKey) {
|
||||
let superuserYou = null;
|
||||
if (
|
||||
props.record?.collectionName == "_superusers"
|
||||
&& app.store.superuser?.id == props.record?.id
|
||||
) {
|
||||
superuserYou = t.strong({
|
||||
className: "txt",
|
||||
textContent: " (you)",
|
||||
});
|
||||
}
|
||||
|
||||
return t.span(
|
||||
{ className: "label" },
|
||||
app.components.copyButton(value),
|
||||
t.span({ className: "txt-ellipsis" }, app.utils.truncate(value), superuserYou),
|
||||
);
|
||||
}
|
||||
|
||||
if (props.short) {
|
||||
return t.span({
|
||||
className: "txt txt-ellipsis",
|
||||
textContent: app.utils.truncate(value),
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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.url = {
|
||||
icon: "ri-link",
|
||||
label: "URL",
|
||||
settings,
|
||||
input,
|
||||
view,
|
||||
dummyData: (f, forSubmit = false) => {
|
||||
return "https://example.com";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
// {
|
||||
// collection: undefined,
|
||||
// originalRecord: undefined,
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// }
|
||||
export function input(props) {
|
||||
const uniqueId = "url_" + app.utils.randomString();
|
||||
|
||||
return t.div(
|
||||
{ className: "record-field-input field-type-url" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId },
|
||||
t.i({ className: app.fieldTypes.url.icon }),
|
||||
t.span({ className: "txt" }, () => props.field.name),
|
||||
),
|
||||
t.input({
|
||||
type: "url",
|
||||
id: uniqueId,
|
||||
spellcheck: false,
|
||||
name: () => props.field.name,
|
||||
required: () => props.field.required,
|
||||
value: () => props.record[props.field.name] || "",
|
||||
oninput: (e) => (props.record[props.field.name] = e.target.value),
|
||||
}),
|
||||
),
|
||||
() => {
|
||||
if (props.field.help) {
|
||||
return t.div({ className: "field-help" }, props.field.help);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// {
|
||||
// originalCollection: undefined,
|
||||
// collection: undefined,
|
||||
// field
|
||||
// get fieldIndex: int/-1,
|
||||
// get originalField: undefined
|
||||
// }
|
||||
export function settings(data) {
|
||||
const uniqueId = "f_" + app.utils.randomString();
|
||||
|
||||
return app.components.fieldSettings(data, {
|
||||
content: () =>
|
||||
t.div(
|
||||
{ className: "grid sm" },
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".exceptDomains" },
|
||||
t.span({ className: "txt" }, "Except domains"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
`List of domains that are NOT allowed.\nThis field is disabled if "Only domains" is set.`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".exceptDomains",
|
||||
disabled: () => !app.utils.isEmpty(data.field.onlyDomains),
|
||||
name: () => `fields.${data.fieldIndex}.exceptDomains`,
|
||||
value: () => app.utils.joinNonEmpty(data.field.exceptDomains),
|
||||
onchange: (
|
||||
e,
|
||||
) => (data.field.exceptDomains = app.utils.splitNonEmpty(e.target.value, ",")),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Use comma as separator."),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-sm-6" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: uniqueId + ".onlyDomains" },
|
||||
t.span({ className: "txt" }, "Only domains"),
|
||||
t.i({
|
||||
className: "ri-information-line link-hint",
|
||||
ariaDescription: app.attrs.tooltip(
|
||||
`List of domains that are ONLY allowed.\nThis field is disabled if "Except domains" is set.`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
t.input({
|
||||
type: "text",
|
||||
id: uniqueId + ".onlyDomains",
|
||||
disabled: () => !app.utils.isEmpty(data.field.exceptDomains),
|
||||
name: () => `fields.${data.fieldIndex}.onlyDomains`,
|
||||
value: () => app.utils.joinNonEmpty(data.field.onlyDomains),
|
||||
onchange: (e) => (data.field.onlyDomains = app.utils.splitNonEmpty(e.target.value, ",")),
|
||||
}),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Use comma as separator."),
|
||||
),
|
||||
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,29 @@
|
||||
// {
|
||||
// record: undefined,
|
||||
// field: undefined,
|
||||
// short: false,
|
||||
// }
|
||||
export function view(props) {
|
||||
return t.div(
|
||||
{ className: "record-field-view field-type-url" },
|
||||
() => {
|
||||
const value = props.record[props.field.name] || "";
|
||||
|
||||
if (!value) {
|
||||
return t.span({ className: "missing-value" });
|
||||
}
|
||||
|
||||
return t.a({
|
||||
href: () => value,
|
||||
className: "txt txt-ellipsis",
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
textContent: app.utils.truncate(value),
|
||||
ariaDescription: app.attrs.tooltip("Open in new tab"),
|
||||
onclick: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user