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 && (