/** * Inspector - the Data tab (M6). * * Binding nodes to WordPress's dynamic data is what lets one design serve a blog, a portfolio * or a product library rather than a single fixed page. * * One principle: list the candidate fields for the context, and never make the user assemble an * expression. Asking someone to type `post.thumbnail.url` pushes implementation detail onto * them, and one wrong letter produces blankness with no hint whatsoever. */ import { Fragment } from 'react'; import { Section, Field, Seg, Select, Num, Str, Empty } from './controls.jsx'; import { useSelectedNode, useStoreChange } from './useStore.js'; import { availableFields, bindableProps, groupFieldsFor } from './bindings.js'; import { __, sprintf } from './i18n.js'; export default function DataPanel({ store, cfg }) { const node = useSelectedNode(store); useStoreChange(store, ['props', 'structure']); if (!node) return {__('Select an element on the canvas or in the layer tree.', 'aumframe')}; return ( <> {node.type === 'Repeater' && } ); } /* Bindings */ function BindingSection({ store, node, cfg }) { const props = bindableProps(node.type); if (!props.length) { return (

{__('This kind of element has no property to bind data to. To repeat a whole block over data, use “Loop”.', 'aumframe')}

); } const { scope, fields } = availableFields(store, node.id, cfg); return (
{scope} : null}> {!fields.length && (

{__('This template has no data context. Put the node inside a “Loop”, or change the template to a single/archive type.', 'aumframe')}

)} {!!fields.length && props.map((prop) => { const current = node.bindings?.[prop.key]?.expr || ''; const usable = fields.filter((f) => prop.accepts.includes(f.type)); if (!usable.length) return null; // The fallback value lives on the node's own props (`props.content` / `props.src` …) const propName = prop.key.split('.').pop(); const fallback = node.props?.[propName] ?? ''; return ( store.setBinding(node.id, prop.key, null)} > ({ value: f.path, label: sprintf(/* translators: 1: Human-readable label. 2: The underlying path, name or key it stands for. */ __('%1$s · %2$s', 'aumframe'), f.label, f.path) }))} onChange={(v) => store.setCondition(node.id, v ? { ...cond, expr: v, op: cond.op || 'notEmpty' } : null)} /> {on && ( ({ value: id, label: describeQuery(id, queries[id]) }))} onChange={(v) => store.patchProps(node.id, { queryId: v || undefined })} /> )} store.patchProps(node.id, { as: v || undefined })} />
); } function QueriesSection({ store, cfg }) { const queries = store.getQueries(); const ids = Object.keys(queries); const site = cfg?.siteData || {}; const postTypes = site.postTypes || []; const taxonomies = site.taxonomies || []; // Repeaters usable as a loop source (spec tables, galleries…) — determined by the content type // this template is bound to const groupFields = groupFieldsFor(site, cfg?.rules, cfg?.kind); return (
store.addQuery()}> {'+ '}{__('New', 'aumframe')} )} > {!ids.length && (

{__('A query defines which content to fetch. Create one, then point a “Loop” at it.', 'aumframe')}

)} {ids.map((id) => ( other !== id && queries[other]?.as) .map((other) => ({ value: queries[other].as, label: sprintf(/* translators: 1: Name the other query is exposed under. 2: Its query ID. */ __('%1$s · query %2$s', 'aumframe'), queries[other].as, other), }))} postTypes={postTypes} taxonomies={taxonomies} groupFields={groupFields} onPatch={(delta) => store.setQuery(id, delta)} onRemove={() => store.setQuery(id, null)} /> ))}
); } /** * The editor for one query. * * Content types and taxonomies always come from the list of what is **actually registered on the * site** (see SiteData on the PHP side), never typed: one wrong letter in `product_cat` produces an * empty list, and the front end reports nothing. But values outside the list still have to stay * visible (a deactivated plugin, a conditionally registered CPT), so there is a uniform "(no longer * available)" fallback entry rather than letting the select quietly fall back to its first option. */ function QueryEditor({ id, q, postTypes, taxonomies, groupFields, outerAliases = [], onPatch, onRemove }) { const postType = q.postType || 'post'; const knownPt = postTypes.some((p) => p.name === postType); // List only the taxonomies attached to this content type — offering `category` for products is // noise const usableTax = taxonomies.filter((t) => (t.postTypes || []).includes(postType)); const tax = q.taxonomy || ''; const taxDef = taxonomies.find((t) => t.name === tax); const knownTax = !tax || !!taxDef; /* * `terms` has two shapes (1.7.73): a list of slugs under `taxonomy` (what these chips edit), or a * map `{ product_cat: ['keycaps'], feel: ['linear'] }` — one clause per taxonomy, combined with AND, which a * kit writes for cross-taxonomy lists ("keycap sets in this layout"). The map is shown read-only here rather * than flattened into the chips: flattening would silently drop every taxonomy but one on the next edit. */ const termsIsMap = !!q.terms && !Array.isArray(q.terms) && typeof q.terms === 'object'; const selected = termsIsMap ? [] : (q.terms || []); const termList = taxDef?.terms || []; const unselected = termList.filter((t) => !selected.includes(t.slug)); const setTerms = (next) => onPatch({ terms: next.length ? next : null }); return (
{id}
{ /* The data source. A search results page **must** use "this page's query" — a custom query cannot express "what the user searched for". An archive page using it also paginates correctly (a custom query bypasses the main query, and the symptom is "page two is identical to page one"). */} onPatch({ source: v === 'custom' ? null : v })} /> { /* Lists the terms themselves (name, cover image, link), not the content within them. Sections like "browse by series" run on this — otherwise the series have to be hard-coded into the layout, and the homepage loses a block every time the client adds one. */} {q.source === 'terms' && (<> ({ value: t.slug, label: t.name }))} onChange={(v) => onPatch({ parent: v || null })} /> )} {q.parentOf && ( ({ value: f.path, label: sprintf(/* translators: 1: Human-readable label. 2: The underlying path, name or key it stands for. */ __('%1$s · %2$s', 'aumframe'), f.label, f.path) })), ...(q.field && !(groupFields || []).some((f) => f.path === q.field) ? [{ value: q.field, label: sprintf(/* translators: %s: Name of a post type, taxonomy, field or colour that the site no longer has. */ __('%s (gone)', 'aumframe'), q.field) }] : []), ]} onChange={(v) => onPatch({ field: v || null })} /> )} {!['main', 'field', 'terms', 'breadcrumb'].includes(q.source) && (<> onPatch({ orderby: v })} /> {q.orderby === 'meta' && (() => { const ptDef = postTypes.find((p) => p.name === postType); const fields = metaFilterFields(ptDef); const current = q.orderMeta || q.meta?.key || ''; return ( {fields.length ? ( onPatch({ inTermOf: v || null })} /> )} {!!q.inTermOf && ( onPatch({ includeChildren: v === 'yes' ? 1 : null })} /> )} {termsIsMap && (
{Object.entries(q.terms).map(([taxName, slugs]) => ( {`${taxName} = ${(Array.isArray(slugs) ? slugs : [slugs]).join(', ')}`} ))}
)} {!termsIsMap && ( ({ value: t.slug, label: `${t.name} (${t.count})` }))} onChange={(v) => v && setTerms([...selected, v])} /> )} {!selected.length && (

{__('Selecting none means no term filter at all — the taxonomy alone decides.', 'aumframe')}

)}
)} { /* Exclude the current post. A "related content" section at the bottom of a single template inevitably needs this — without it the first card is the post you are already reading, and clicking it lands on the same page. */} onPatch({ notCurrent: v === 'on' ? 1 : null })} /> { /* Filter by one custom field (1.7.77). "The lead piece", "featured", "show on the home page" — a toggle the site owner ticks on one post. Before this a query could not see post meta at all, so "which one is featured" had to be a sort-order convention nobody can find in the admin. The key list comes from the content type's own fields (toggle / select / text), so the user picks rather than types. */} {(() => { const ptDef = postTypes.find((p) => p.name === postType); const metaFields = metaFilterFields(ptDef); const meta = q.meta && typeof q.meta === 'object' ? q.meta : {}; const RANGE = ['<', '<=', '>', '>=']; const isRange = RANGE.includes(meta.compare || '='); const needsValue = ['=', '!='].includes(meta.compare || '=') || isRange; const setMeta = (patch) => { const next = { ...meta, ...patch }; if (!next.key) { onPatch({ meta: null }); return; } onPatch({ meta: next }); }; return ( {metaFields.length ? ( =', label: __('is on or after', 'aumframe') }, { value: '>', label: __('is after', 'aumframe') }, { value: '<=', label: __('is on or before', 'aumframe') }, { value: '<', label: __('is before', 'aumframe') }, { value: 'exists', label: __('is set', 'aumframe') }, { value: 'notExists', label: __('is not set', 'aumframe') }, ]} onChange={(v) => setMeta({ compare: v || '=' })} /> {needsValue ? ( setMeta({ value: v })} /> ) : null}
)} {!!meta.key && needsValue && (
({ value: t.name, label: sprintf(/* translators: 1: Human-readable label. 2: The underlying path, name or key it stands for. */ __('%1$s · %2$s', 'aumframe'), t.label, t.name) }))} onChange={(v) => onPatch({ followTaxonomy: v || null })} /> )} )}
); }