Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/features/loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,12 @@ Projects loop
```

The Properties panel offers collection fields from the current entry table:
multi-media, multi-relation, and multi-select fields. Primitive items are
available as `currentEntry.value`; object-array members are exposed by key.
multi-media, multi-relation, multi-select, and repeater fields. Inside a loop
over a repeater, it offers that repeater's own multi-value sub-fields instead.
Primitive items are available as `currentEntry.value`; object-array members are
exposed by key. Repeater items (`{ id, cells }`) expose their cells directly, so
a block reads as `currentEntry.layout` and a loop nested inside the repeater loop
can iterate the block's `images`.
Media ids are resolved through the publisher's batched media prefetch and
provide `currentEntry.src`, `url`, `path`, `altText`, `mimeType`, `width`, and
`height`.
Expand Down Expand Up @@ -373,6 +377,18 @@ Subscription granularity: the hook never subscribes to the whole `site` document
6. Publish the site and a Project entry. Each project route renders only that
project's gallery items, in the field's authored order.

### Build case-study blocks from a repeater

1. Add a Repeater field named `blocks` with a `layout` select and a multi-media
`images` sub-field.
2. On the entry template, insert a Loop with Source **Current entry field** and
Field **Blocks**. Give its child container an HTML attribute
`data-layout="{currentEntry.layout}"`.
3. Inside that container, insert a second Loop with Field **Images** and an
Image child bound to **Current entry field → Media source**.
4. Style the grid from `[data-layout]` and `:has()` in a site stylesheet. Each
block renders only its own images.

### Build a loop with the AI agent

The site-scope AI agent stays on the HTML-native edit surface. It calls `list_loop_sources` to get valid source ids, table ids, order options, and `{currentEntry.field}` tokens, then inserts an `<instatic-loop>` marker through `insertHtml` / `replaceNodeHtml`:
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/base-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,26 @@ describe('base.video — render() specifics', () => {
expect(VideoModule.canHaveChildren).toBe(false)
})

it('uses the resolved asset path when videoUrl holds a bound media id', () => {
const { html } = renderModule(VideoModule, {
videoUrl: 'asset-id-123',
_resolvedMediaByKey: {
videoUrl: {
publicPath: '/uploads/reel.mp4',
mimeType: 'video/mp4',
width: 1920,
height: 1080,
altText: '',
blurHash: null,
posterPath: null,
variants: [],
},
},
})
expect(html).toContain('src="/uploads/reel.mp4"')
expect(html).not.toContain('asset-id-123')
})

it('renders a YouTube iframe when videoUrl is a watch URL', () => {
const { html } = renderModule(VideoModule, {
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/loops/entryField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ describe('current-entry-field loop items', () => {
expect(result.items[0]?.fields.value).toEqual(object)
})

it('exposes repeater item cells as the item fields', () => {
const block = { id: 'block-1', cells: { layout: '2-col', images: ['m1', 'm2'] } }
const result = resolveEntryFieldItems([block])

expect(result.items[0]?.id).toBe('block-1')
expect(result.items[0]?.fields).toMatchObject({
id: 'block-1',
layout: '2-col',
images: ['m1', 'm2'],
index: 0,
})
expect(result.items[0]?.fields.value).toEqual(block)
})

it('enriches media ids with renderable media fields', () => {
const result = resolveEntryFieldItems(['media-1'], {
mediaByReference: new Map([
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/modules/imageResponsiveAttrs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,16 @@ describe('base.image sizes resolution', () => {
expect(m![1]).not.toContain('.png')
})
})

describe('base.image src', () => {
it('uses the resolved asset path when src holds a bound media id', () => {
const html = renderImage({ src: 'asset-id-123' })
expect(html).toContain('src="/uploads/hero.png"')
expect(html).not.toContain('asset-id-123')
})

it('keeps an authored URL when no asset resolved', () => {
const html = renderImage({ src: 'https://example.com/a.jpg', _resolvedMediaByKey: undefined })
expect(html).toContain('src="https://example.com/a.jpg"')
})
})
42 changes: 42 additions & 0 deletions src/__tests__/publisher/loopRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,48 @@ describe('publisher loop renderer', () => {
expect(html).not.toContain('Project 1/p2-a')
expect(html).not.toContain('Project 2/p1-a')
})

it('iterates a multi-value sub-field of each repeater item', () => {
const page = makePage({
root: { moduleId: 'base.body', children: ['blocks'] },
blocks: {
moduleId: 'base.loop',
children: ['images'],
props: { sourceId: 'entry.field', filters: { fieldId: 'blocks' }, limit: 10 },
},
images: {
moduleId: 'base.loop',
children: ['card'],
props: { sourceId: 'entry.field', filters: { fieldId: 'images' }, limit: 10 },
},
card: {
moduleId: 'test.pair',
props: { parent: '', current: '' },
dynamicBindings: {
parent: { source: 'parentEntry', field: 'layout' },
current: { source: 'currentEntry', field: 'value' },
},
},
})
const caseStudy: LoopItem = {
id: 'case-study',
fields: {
title: 'Case study',
blocks: [
{ id: 'b1', cells: { layout: 'Full-width', images: ['hero'] } },
{ id: 'b2', cells: { layout: '2-col', images: ['left', 'right'] } },
],
},
}
const html = publishPage(page, makeSite(), baseRegistry, {
templateContext: { entryStack: [caseStudy] },
}).html

expect(html).toContain('<p>Full-width/hero</p>')
expect(html).toContain('<p>2-col/left</p>')
expect(html).toContain('<p>2-col/right</p>')
expect(html).not.toContain('Full-width/left')
})
})

it('attaches data attrs and registers infinite mode', () => {
Expand Down
85 changes: 64 additions & 21 deletions src/admin/pages/site/panels/PropertiesPanel/LoopPropertiesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import {
withoutCellFilter,
} from '@core/loops/cellFilter'
import type { LoopEntitySource } from '@core/loops/types'
import type { DataRow, DataTableListItem } from '@core/data/schemas'
import type { DataField, DataRow, DataTableListItem, RepeaterItemField } from '@core/data/schemas'
import type { PropertyControl, PropertySchema } from '@core/module-engine'
import { listCmsDataRows, listCmsDataTables } from '@core/persistence/cmsData'
import { getAncestors, type Page } from '@core/page-tree'
import { getAncestors, type Page, type PageNode } from '@core/page-tree'
import { PropertyControlRenderer } from '@site/property-controls/PropertyControlRenderer'
import { CUSTOM_HTML_TAG_VALUE } from '@core/htmlAttributes'
import { customHtmlTagControl, htmlTagControl } from '@modules/base/utils/htmlTag'
Expand Down Expand Up @@ -348,34 +348,40 @@ function entryCollectionFieldOptions(
): Array<{ label: string; value: string }> {
if (!activePage) return []

const enclosingDataRowsLoop = [...getAncestors(activePage, nodeId)]
// Nearest loop first: the closest enclosing loop decides what "the current
// entry" is for this one.
const enclosingLoops = [...getAncestors(activePage, nodeId)]
.reverse()
.find((node) => node.moduleId === 'base.loop' && node.props.sourceId === 'data.rows')
const filters = enclosingDataRowsLoop?.props.filters
const enclosingTableId =
filters && typeof filters === 'object' && !Array.isArray(filters)
? (filters as Record<string, unknown>).tableId
: null
.filter((node) => node.moduleId === 'base.loop')
const nearestLoop = enclosingLoops[0]
const nearestFilters = loopFilters(nearestLoop)

let contextTables: DataTableListItem[]
if (typeof enclosingTableId === 'string' && enclosingTableId) {
contextTables = tables.filter((table) => table.id === enclosingTableId)
} else if (activePage.template?.target.kind === 'postTypes') {
const slugs = new Set(activePage.template.target.tableSlugs)
contextTables = tables.filter((table) => slugs.has(table.slug))
} else {
contextTables = []
// Inside a loop over a repeater, the current entry is one repeater item,
// so the choices are that repeater's own multi-value sub-fields.
if (nearestLoop?.props.sourceId === ENTRY_FIELD_SOURCE_ID) {
const repeaterFieldId = nearestFilters[ENTRY_FIELD_FILTER_KEY]
const outerTables = contextTablesFor(activePage, enclosingLoops.slice(1), tables)
const options: Array<{ label: string; value: string }> = []
const seen = new Set<string>()
for (const table of outerTables) {
const repeater = table.fields.find((field) => field.id === repeaterFieldId)
if (repeater?.type !== 'repeater') continue
for (const field of repeater.fields) {
if (!isCollectionField(field) || seen.has(field.id)) continue
seen.add(field.id)
options.push({ label: field.label, value: field.id })
}
}
return options
}

const contextTables = contextTablesFor(activePage, enclosingLoops, tables)
const showTableName = contextTables.length > 1
const seen = new Set<string>()
const options: Array<{ label: string; value: string }> = []
for (const table of contextTables) {
for (const field of table.fields) {
const isCollection =
field.type === 'multiSelect' ||
((field.type === 'media' || field.type === 'relation') && field.allowMultiple === true)
if (!isCollection || seen.has(field.id)) continue
if (!isCollectionField(field) || seen.has(field.id)) continue
seen.add(field.id)
options.push({
label: showTableName ? `${table.name} → ${field.label}` : field.label,
Expand All @@ -385,3 +391,40 @@ function entryCollectionFieldOptions(
}
return options
}

/** Fields an `entry.field` loop can iterate: multi-value fields and repeaters. */
function isCollectionField(field: DataField | RepeaterItemField): boolean {
return (
field.type === 'multiSelect' ||
field.type === 'repeater' ||
((field.type === 'media' || field.type === 'relation') && field.allowMultiple === true)
)
}

function loopFilters(loop: PageNode | undefined): Record<string, unknown> {
const filters = loop?.props.filters
return filters && typeof filters === 'object' && !Array.isArray(filters)
? (filters as Record<string, unknown>)
: {}
}

/**
* The tables whose rows can be the current entry: the nearest enclosing
* `data.rows` loop's table, else the post types the template targets.
*/
function contextTablesFor(
activePage: Page,
enclosingLoops: PageNode[],
tables: DataTableListItem[],
): DataTableListItem[] {
const dataRowsLoop = enclosingLoops.find((node) => node.props.sourceId === 'data.rows')
const enclosingTableId = loopFilters(dataRowsLoop).tableId
if (typeof enclosingTableId === 'string' && enclosingTableId) {
return tables.filter((table) => table.id === enclosingTableId)
}
if (activePage.template?.target.kind === 'postTypes') {
const slugs = new Set(activePage.template.target.tableSlugs)
return tables.filter((table) => slugs.has(table.slug))
}
return []
}
21 changes: 21 additions & 0 deletions src/core/loops/sources/entryField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* `data.rows` project listing resolves the gallery belonging to each project.
*/

import { RepeaterItemSchema } from '@core/data/schemas'
import { safeParseValue } from '@core/utils/typeboxHelpers'
import type {
ContextualLoopEntitySource,
LoopFetchResult,
Expand Down Expand Up @@ -69,6 +71,23 @@ function projectItem(
return { id, fields: { id, value, index } }
}

// A repeater item stores its sub-field values under `cells`. Exposing the
// cells directly makes the item read like any other entry: bindings say
// `currentEntry.heading`, and a nested `entry.field` loop can iterate the
// item's own multi-value fields (a block's `images`).
const repeaterItem = safeParseValue(RepeaterItemSchema, value)
if (repeaterItem.ok) {
return {
id,
fields: {
...repeaterItem.value.cells,
id,
value,
index,
},
}
}

if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
id,
Expand All @@ -90,6 +109,8 @@ function projectItem(
* Media ids are enriched when the caller supplies the publisher/canvas media
* map. Other primitives remain available as `currentEntry.value`; object
* members are exposed directly while the original object remains at `value`.
* Repeater items (`{ id, cells }`) expose their cells, so a loop nested in a
* repeater loop can iterate an item's own multi-value sub-field.
*/
export function resolveEntryFieldItems(
value: unknown,
Expand Down
8 changes: 6 additions & 2 deletions src/modules/base/image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,12 @@ export const ImageModule: ModuleDefinition<ImageProps> = {
htmlTag: 'img',

render: (props) => {
const src = safeUrl(props.src)
// A `src` bound to a media field holds the asset id, not a URL, so the
// resolved asset's `publicPath` wins whenever the publisher found one.
// Authored URLs resolve to an asset with the same path, so they render
// unchanged.
const media = props._resolvedMediaByKey?.src
const src = safeUrl(media?.publicPath ?? props.src)
if (!src) return { html: '' }
const htmlAttrs = htmlAttributesAttr(props.htmlAttributes)

Expand All @@ -183,7 +188,6 @@ export const ImageModule: ModuleDefinition<ImageProps> = {
//
// The resolved-media payload is raw (not run through the publisher's
// `escapeProps`), so we HTML-escape here at the boundary.
const media = props._resolvedMediaByKey?.src
const alt = escapeHtml(media?.altText?.trim() ?? '')

const loading = props.loading === 'eager' ? 'eager' : 'lazy'
Expand Down
9 changes: 5 additions & 4 deletions src/modules/base/video/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,15 @@ export const VideoModule: ModuleDefinition<VideoProps> = {
})
}

const videoSrc = safeUrl(rawUrl)
if (!videoSrc) return { html: '<video></video>' }

// Resolved video asset gives us intrinsic dimensions — emits
// `width` / `height` attrs so the browser reserves layout space
// before the metadata downloads. Same CLS-avoidance trick as the
// image module.
// image module. Its `publicPath` is also the URL when `videoUrl` is
// bound to a media field and so holds the asset id.
const videoMedia = props._resolvedMediaByKey?.videoUrl ?? null
const videoSrc = safeUrl(videoMedia?.publicPath ?? rawUrl)
if (!videoSrc) return { html: '<video></video>' }

const posterMedia = props._resolvedMediaByKey?.poster ?? null

// Poster picks the smallest variant that's still ≥ the video's
Expand Down