diff --git a/packages/app/src/app.js b/packages/app/src/app.js
index 5cd71c17..40bb88bf 100644
--- a/packages/app/src/app.js
+++ b/packages/app/src/app.js
@@ -1,5 +1,5 @@
import ds from "@wq/store";
-import orm from "@wq/model";
+import orm, { getRootFields } from "@wq/model";
import outbox from "@wq/outbox";
import router from "@wq/router";
import spinner from "./spinner.js";
@@ -504,8 +504,7 @@ const syncUpdateUrl = {
// Return a list of all foreign key fields
app.getParents = function (page) {
- var conf = _getConf(page);
- return conf.form
+ return getRootFields(_getConf(page))
.filter(function (field) {
return field["wq:ForeignKey"];
})
@@ -718,9 +717,19 @@ async function _displayList(ctx, parentInfo) {
}
}
if (parentInfo) {
- conf.form.forEach(function (field) {
+ getRootFields(conf).forEach(function (field) {
if (field["wq:ForeignKey"] == parentInfo.parent_page) {
- filter[field.name + "_id"] = parentInfo.parent_id;
+ const naturalKey = field.name.match(
+ /^([^\]]+)\[([^\]]+)\]$/
+ );
+ if (naturalKey) {
+ filter[naturalKey.slice(1).join("__")] =
+ parentInfo.parent_id;
+ } else if (field.type === "select") {
+ filter[field.name + "_id"] = [parentInfo.parent_id];
+ } else {
+ filter[field.name + "_id"] = parentInfo.parent_id;
+ }
}
});
}
diff --git a/packages/app/src/auth.js b/packages/app/src/auth.js
index ef9faaea..08726e67 100644
--- a/packages/app/src/auth.js
+++ b/packages/app/src/auth.js
@@ -133,7 +133,7 @@ export default {
wqPageConf =
(config && config.pages && config.pages[pageConf.name]) || {};
return {
- user,
+ user: ctx.user_id ? ctx.user : user,
is_authenticated: !!user,
app_config: this.app.config,
user_config: config,
diff --git a/packages/material-web/src/components/Header.js b/packages/material-web/src/components/Header.js
index dc526194..2529430b 100644
--- a/packages/material-web/src/components/Header.js
+++ b/packages/material-web/src/components/Header.js
@@ -6,7 +6,8 @@ import { useMinWidth } from "../hooks.js";
export default function Header() {
const title = useSiteTitle(),
links = useBreadcrumbs(),
- { Logo, Breadcrumbs, IconButton, NavMenuPopup } = useComponents(),
+ { Logo, SiteTitle, Breadcrumbs, IconButton, NavMenuPopup } =
+ useComponents(),
fixedMenu = useMinWidth(600),
[open, setOpen] = useState(false);
return (
@@ -24,7 +25,9 @@ export default function Header() {
edge="start"
/>
)}
- {title}
+
+
+
diff --git a/packages/model/src/index.js b/packages/model/src/index.js
index 1838d760..0cdb615c 100644
--- a/packages/model/src/index.js
+++ b/packages/model/src/index.js
@@ -1,4 +1,4 @@
-import { Model, model as createModel } from "./model.js";
+import { Model, model as createModel, getRootFields } from "./model.js";
const orm = {
// Plugin attributes
@@ -50,4 +50,4 @@ const orm = {
export default orm;
-export { Model, createModel, createModel as model };
+export { Model, createModel, createModel as model, getRootFields };
diff --git a/packages/model/src/model.js b/packages/model/src/model.js
index 2b4b8f8c..01865c47 100644
--- a/packages/model/src/model.js
+++ b/packages/model/src/model.js
@@ -594,9 +594,20 @@ class Model {
filterFields() {
let fields = [this.idCol];
fields = fields.concat(
- (this.config.form || []).map((field) =>
- field["wq:ForeignKey"] ? `${field.name}_id` : field.name
- )
+ getRootFields(this.config).map((field) => {
+ if (field["wq:ForeignKey"]) {
+ const naturalKey = field.name.match(
+ /^([^\]]+)\[([^\]]+)\]$/
+ );
+ if (naturalKey) {
+ return naturalKey.slice(1).join("__");
+ } else {
+ return `${field.name}_id`;
+ }
+ } else {
+ return field.name;
+ }
+ })
);
fields = fields.concat(Object.keys(this.functions));
fields = fields.concat(this.config.filter_fields || []);
@@ -793,6 +804,7 @@ class Model {
this.functions[attr] ||
isPotentialBoolean(comp) ||
isPotentialNumber(comp) ||
+ isPotentialNaturalKey(attr) ||
Array.isArray(comp)
);
}
@@ -802,6 +814,13 @@ class Model {
if (this.functions[attr]) {
value = this.compute(attr, item);
+ } else if (attr.includes("__")) {
+ const parts = attr.split("__");
+ if (parts.length === 2 && item[parts[0]]) {
+ value = item[parts[0]][parts[1]];
+ } else {
+ value = item[attr];
+ }
} else {
value = item[attr];
}
@@ -850,4 +869,15 @@ function isPotentialNumber(value) {
return typeof value !== "number" && !Number.isNaN(+value);
}
+function isPotentialNaturalKey(attr) {
+ return typeof attr == "string" && attr.includes("__");
+}
+
+export function getRootFields(conf) {
+ const root = (conf.form || []).find(
+ (field) => field.name === "" && field.type === "group"
+ );
+ return (conf.form || []).concat((root && root.children) || []);
+}
+
export { model, Model };
diff --git a/packages/react/src/components/AutoForm.js b/packages/react/src/components/AutoForm.js
index b0b741d2..1e1230dd 100644
--- a/packages/react/src/components/AutoForm.js
+++ b/packages/react/src/components/AutoForm.js
@@ -96,9 +96,19 @@ export function initData(form, data) {
}
form.forEach((field) => {
- const fieldName = field["wq:ForeignKey"]
- ? `${field.name}_id`
- : field.name;
+ let fieldName = field.name;
+ if (field["wq:ForeignKey"]) {
+ const naturalKey = field.name.match(/^([^\]]+)\[([^\]]+)\]$/);
+ if (
+ naturalKey &&
+ data[naturalKey[1]] &&
+ data[naturalKey[1]][naturalKey[2]]
+ ) {
+ fieldName = naturalKey[1];
+ } else {
+ fieldName = `${field.name}_id`;
+ }
+ }
let value;
if (field.type === "repeat") {
diff --git a/packages/react/src/components/AutoInput.js b/packages/react/src/components/AutoInput.js
index b124d754..f3ba57ca 100644
--- a/packages/react/src/components/AutoInput.js
+++ b/packages/react/src/components/AutoInput.js
@@ -16,7 +16,12 @@ export default function AutoInput({ name, choices, type, bind = {}, ...rest }) {
let inputType,
required = bind.required;
if (rest["wq:ForeignKey"]) {
- name = `${name}_id`;
+ const naturalKey = name.match(/^([^\]]+)\[([^\]]+)\]$/);
+ if (naturalKey) {
+ name = naturalKey.slice(1).join(".");
+ } else {
+ name = `${name}_id`;
+ }
inputType = "foreign-key";
} else if (type === "select1" || type === "select one") {
if (!choices) {
diff --git a/packages/react/src/components/ForeignKeyLink.js b/packages/react/src/components/ForeignKeyLink.js
new file mode 100644
index 00000000..4cb6b212
--- /dev/null
+++ b/packages/react/src/components/ForeignKeyLink.js
@@ -0,0 +1,21 @@
+import React from "react";
+import { useComponents, useModel, useReverse } from "../hooks.js";
+import PropTypes from "prop-types";
+
+export default function ForeignKeyLink({ id, label, model }) {
+ const { Link } = useComponents(),
+ reverse = useReverse(),
+ obj = useModel(model, id || -1) || { label: id };
+ if (!id) {
+ return null;
+ }
+ return (
+ {label || obj.label}
+ );
+}
+
+ForeignKeyLink.propTypes = {
+ id: PropTypes.string,
+ label: PropTypes.string,
+ model: PropTypes.string,
+};
diff --git a/packages/react/src/components/ManyToManyLink.js b/packages/react/src/components/ManyToManyLink.js
new file mode 100644
index 00000000..98f38dfc
--- /dev/null
+++ b/packages/react/src/components/ManyToManyLink.js
@@ -0,0 +1,26 @@
+import React from "react";
+import { useComponents } from "../hooks.js";
+import PropTypes from "prop-types";
+
+export default function ManyToManyLink({ ids, labels, model }) {
+ const { ForeignKeyLink, Text } = useComponents();
+ if (labels && typeof labels === "string") {
+ labels = [labels];
+ }
+ return (ids || []).map((id, index) => (
+
+
+
+
+ ));
+}
+
+ManyToManyLink.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.string),
+ labels: PropTypes.arrayOf(PropTypes.string),
+ model: PropTypes.string,
+};
diff --git a/packages/react/src/components/PropertyTable.js b/packages/react/src/components/PropertyTable.js
index 144b7340..7b7d1174 100644
--- a/packages/react/src/components/PropertyTable.js
+++ b/packages/react/src/components/PropertyTable.js
@@ -2,8 +2,14 @@ import React from "react";
import { useComponents } from "../hooks.js";
import PropTypes from "prop-types";
-const Value = ({ values, field }) => {
- const { FormatJson, ImagePreview, FileLink } = useComponents(),
+const Value = ({ values, field, ormState }) => {
+ const {
+ FormatJson,
+ ImagePreview,
+ FileLink,
+ ForeignKeyLink,
+ ManyToManyLink,
+ } = useComponents(),
value = values[field.name];
if (field.children && values[field.name]) {
@@ -23,15 +29,21 @@ const Value = ({ values, field }) => {
);
}
} else if (field["wq:ForeignKey"]) {
- if (value && typeof value === "object" && value.label) {
- return value.label;
- } else {
- return (
- values[field.name + "_label"] ||
- values[field.name + "_id"] ||
- value + ""
- );
- }
+ const id = getForeignKeyId(values, field),
+ label = getForeignKeyLabel(values, field);
+ return field["type"] === "select" ? (
+
+ ) : (
+
+ );
} else if (field.choices) {
const choice = field.choices.find((c) => c.name === value);
if (choice && choice.label) {
@@ -59,6 +71,42 @@ const isInteractive = (values, field) => {
}
};
+const getForeignKeyId = (values, field) => {
+ const naturalKey = field.name.match(/^([^\]]+)\[([^\]]+)\]$/);
+ if (naturalKey) {
+ return (values[naturalKey[1]] || {})[naturalKey[2]] || "";
+ }
+ const value = values[field.name];
+ if (value && typeof value === "object" && value.id) {
+ return value.id;
+ } else if (values[field.name + "_id"]) {
+ return values[field.name + "_id"];
+ } else {
+ return (value || "") + "";
+ }
+};
+
+const getForeignKeyLabel = (values, field, ormState) => {
+ const value = values[field.name];
+ if (value && typeof value === "object" && value.label) {
+ return value.label;
+ } else if (values[field.name + "_label"]) {
+ return values[field.name + "_label"];
+ } else {
+ return null;
+ }
+};
+
+const showInTable = (field) => {
+ if (field.show_in_table === false) {
+ return false;
+ }
+ if (field.type && field.type.startsWith("geo")) {
+ return field.show_in_table || false;
+ }
+ return true;
+};
+
export default function PropertyTable({ form, values }) {
const { Table, TableBody, TableRow, TableCell } = useComponents(),
rootFieldset = form.find(
@@ -72,7 +120,7 @@ export default function PropertyTable({ form, values }) {
return (
- {fields.map((field) => (
+ {fields.filter(showInTable).map((field) => (
{field.label || field.name}
@@ -91,19 +139,37 @@ PropertyTable.propTypes = {
};
function PropertyTableList({ form, values }) {
- const { Divider } = useComponents();
- if (!Array.isArray(values)) {
+ const { Table, TableHead, TableBody, TableRow, TableTitle, TableCell } =
+ useComponents();
+ if (!Array.isArray(values) || values.length === 0) {
return null;
}
return (
- <>
- {values.map((vals, i) => (
-
- {i > 0 && }
-
-
- ))}
- >
+
+
+
+ {form.filter(showInTable).map((field) => (
+
+ {field.label || field.name}
+
+ ))}
+
+
+
+ {values.map((vals, i) => (
+
+ {form.filter(showInTable).map((field) => (
+
+
+
+ ))}
+
+ ))}
+
+
);
}
PropertyTableList.propTypes = {
diff --git a/packages/react/src/components/RelatedLinks.js b/packages/react/src/components/RelatedLinks.js
new file mode 100644
index 00000000..972a1f07
--- /dev/null
+++ b/packages/react/src/components/RelatedLinks.js
@@ -0,0 +1,64 @@
+import React from "react";
+import {
+ useConfig,
+ useComponents,
+ useReverse,
+ useRouteTitle,
+ useApp,
+} from "../hooks.js";
+import PropTypes from "prop-types";
+
+export default function RelatedLinks({ id, model }) {
+ const { pages } = useConfig(),
+ app = useApp(),
+ { List, ListSubheader, ListItemLink } = useComponents(),
+ reverse = useReverse(),
+ routeTitle = useRouteTitle();
+
+ const related = Object.values(pages).filter((conf) =>
+ app.getParents(conf.name).includes(model)
+ );
+ if (related.length === 0) {
+ return null;
+ }
+ related.sort(relSort);
+ return (
+
+ Related
+ {related.map((rel) => (
+
+ {routeTitle(`${rel.name}_list`)}
+
+ ))}
+
+ );
+}
+
+RelatedLinks.propTypes = {
+ id: PropTypes.string,
+ model: PropTypes.string,
+};
+
+function relSort(r1, r2) {
+ const label1 = relLabel(r1),
+ label2 = relLabel(r2);
+ if (r1.order > r2.order) {
+ return 1;
+ } else if (r1.order < r2.order) {
+ return -1;
+ } else if (label1 < label2) {
+ return 1;
+ } else if (label1 > label2) {
+ return -1;
+ } else {
+ return 0;
+ }
+}
+
+function relLabel(rel) {
+ return rel.verbose_name_plural || rel.url || rel.name;
+}
diff --git a/packages/react/src/components/SiteTitle.js b/packages/react/src/components/SiteTitle.js
new file mode 100644
index 00000000..ca4a5a41
--- /dev/null
+++ b/packages/react/src/components/SiteTitle.js
@@ -0,0 +1,3 @@
+export default function SiteTitle({ title }) {
+ return title;
+}
diff --git a/packages/react/src/components/index.js b/packages/react/src/components/index.js
index 9e8cc285..0c262215 100644
--- a/packages/react/src/components/index.js
+++ b/packages/react/src/components/index.js
@@ -1,6 +1,7 @@
import Container from "./Container.js";
import Header from "./Header.js";
import Logo from "./Logo.js";
+import SiteTitle from "./SiteTitle.js";
import NavMenuPopup from "./NavMenuPopup.js";
import NavMenuFixed from "./NavMenuFixed.js";
import Main from "./Main.js";
@@ -55,6 +56,9 @@ import AutoSubformArray from "./AutoSubformArray.js";
import PropertyTable from "./PropertyTable.js";
import ImagePreview from "./ImagePreview.js";
import FileLink from "./FileLink.js";
+import ForeignKeyLink from "./ForeignKeyLink.js";
+import ManyToManyLink from "./ManyToManyLink.js";
+import RelatedLinks from "./RelatedLinks.js";
import Form from "./Form.js";
import FormRoot from "./FormRoot.js";
import FormError from "./FormError.js";
@@ -74,6 +78,7 @@ export {
Container,
Header,
Logo,
+ SiteTitle,
NavMenuPopup,
NavMenuFixed,
Main,
@@ -137,6 +142,9 @@ export {
PropertyTable,
ImagePreview,
FileLink,
+ ForeignKeyLink,
+ ManyToManyLink,
+ RelatedLinks,
Form,
FormRoot,
FormError,
diff --git a/packages/react/src/react.js b/packages/react/src/react.js
index 48801f21..6079d76c 100644
--- a/packages/react/src/react.js
+++ b/packages/react/src/react.js
@@ -138,6 +138,9 @@ const {
PropertyTable,
ImagePreview,
FileLink,
+ ForeignKeyLink,
+ ManyToManyLink,
+ RelatedLinks,
DebugContext,
} = components;
@@ -155,6 +158,9 @@ export {
PropertyTable,
ImagePreview,
FileLink,
+ ForeignKeyLink,
+ ManyToManyLink,
+ RelatedLinks,
DebugContext,
autoFormData,
};
diff --git a/packages/react/src/validate.js b/packages/react/src/validate.js
index b3b570a6..27962f9f 100644
--- a/packages/react/src/validate.js
+++ b/packages/react/src/validate.js
@@ -19,10 +19,27 @@ function validateRequired(values, form, labels = null) {
const errors = {};
form.forEach((field) => {
- const name = field["wq:ForeignKey"] ? `${field.name}_id` : field.name,
+ let name, value, error;
+ if (field["wq:ForeignKey"]) {
+ const naturalKey = field.name.match(/^([^\]]+)\[([^\]]+)\]$/);
+ if (naturalKey) {
+ name = naturalKey[1];
+ value =
+ values[naturalKey[1]] &&
+ values[naturalKey[1]][naturalKey[2]];
+ error = { [naturalKey[2]]: "This field is required." };
+ } else {
+ name = field.name + "_id";
+ value = values[name];
+ error = "This field is required.";
+ }
+ } else {
+ name = field.name;
value = values[name];
+ error = "This field is required.";
+ }
if (isMissing(value, field)) {
- errors[name] = "This field is required.";
+ errors[name] = error;
if (labels) {
labels.push(field.label);
}
diff --git a/packages/react/src/views/DefaultDetail.js b/packages/react/src/views/DefaultDetail.js
index 8334b8d5..feac4239 100644
--- a/packages/react/src/views/DefaultDetail.js
+++ b/packages/react/src/views/DefaultDetail.js
@@ -10,13 +10,14 @@ export default function DefaultDetail() {
const reverse = useReverse(),
context = useRenderContext(),
{ page, item_id, page_config } = useRouteInfo(),
- { ScrollView, PropertyTable, Fab } = useComponents(),
+ { ScrollView, PropertyTable, RelatedLinks, Fab } = useComponents(),
form = page_config.form || [{ name: "label" }],
editUrl = reverse(`${page}_edit`, item_id);
return (
<>
+
{page_config.can_change !== false && (