diff --git a/frontend/components.d.ts b/frontend/components.d.ts index f6d3c2b80..2cfb1f020 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -74,7 +74,9 @@ declare module 'vue' { LucideChevronUp: typeof import('~icons/lucide/chevron-up')['default'] LucideFlaskConical: typeof import('~icons/lucide/flask-conical')['default'] LucidePaperclip: typeof import('~icons/lucide/paperclip')['default'] + LucidePlus: typeof import('~icons/lucide/plus')['default'] LucideRepeat: typeof import('~icons/lucide/repeat')['default'] + LucideX: typeof import('~icons/lucide/x')['default'] MarginHandler: typeof import('./src/components/MarginHandler.vue')['default'] MarkdownEditor: typeof import('./src/components/AppLayout/MarkdownEditor.vue')['default'] MissingComponent: typeof import('./src/components/MissingComponent.vue')['default'] @@ -92,7 +94,6 @@ declare module 'vue' { ProxyDialog: typeof import('./src/components/ProxyComponents/ProxyDialog.vue')['default'] PublishButton: typeof import('./src/components/PublishButton.vue')['default'] Repeater: typeof import('./src/components/AppLayout/Repeater.vue')['default'] - RepeaterContextProvider: typeof import('./src/components/AppLayout/RepeaterContextProvider.vue')['default'] ResourceDialog: typeof import('./src/components/ResourceDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] @@ -101,6 +102,7 @@ declare module 'vue' { SectionContainer: typeof import('./src/components/SectionContainer.vue')['default'] SettingItem: typeof import('./src/components/SettingItem.vue')['default'] SlotIcon: typeof import('./src/components/Icons/SlotIcon.vue')['default'] + SlotScopeProvider: typeof import('./src/components/SlotScopeProvider.vue')['default'] SplitView: typeof import('./src/components/AppLayout/SplitView.vue')['default'] StudioCanvas: typeof import('./src/components/StudioCanvas.vue')['default'] StudioComponent: typeof import('./src/components/StudioComponent.vue')['default'] diff --git a/frontend/src/components/AppComponent.vue b/frontend/src/components/AppComponent.vue index 01b4da194..d766481c8 100644 --- a/frontend/src/components/AppComponent.vue +++ b/frontend/src/components/AppComponent.vue @@ -21,20 +21,13 @@ :key="slotName" v-slot:[slotName]="slotProps" > - - - + @@ -61,7 +54,7 @@ import Block from "@/utils/block" import { computed, onMounted, ref, useAttrs, inject, type ComputedRef, h } from "vue" import type { ComponentPublicInstance } from "vue" import { createResource } from "frappe-ui" -import { getComponentRoot, isHTML, isObjectEmpty } from "@/utils/helpers" +import { getComponentRoot, isObjectEmpty } from "@/utils/helpers" import { useScreenSize } from "@/utils/useScreenSize" import { isDynamicValue } from "@/utils/code" import { resolveEventListener } from "@/utils/eventModifiers" @@ -69,11 +62,12 @@ import useComponentInstance from "@/utils/useComponentInstance" import useCodeStore from "@/stores/codeStore" import { toast } from "frappe-ui" -import type { RepeaterContext } from "@/types" +import type { SlotScope } from "@/types" import type { Field } from "@/types/ComponentEvent" import type { DataResult } from "@/types/Studio/StudioResource" import StudioComponentRenderer from "@/components/StudioComponentRenderer.vue" +import SlotScopeProvider from "@/components/SlotScopeProvider.vue" import { customVueComponentsRegistry } from "@/globals" import MissingComponent from "@/components/MissingComponent.vue" @@ -110,12 +104,12 @@ const classes = computed(() => { }) const codeStore = useCodeStore() -const repeaterContext = inject | null>("repeaterContext", null) +const slotScope = inject | null>("slotScope", null) const componentContext = inject("componentContext", null) const evaluationContext = computed(() => { return { - ...repeaterContext?.value, + ...slotScope?.value, ...componentContext?.value, } }) @@ -204,7 +198,7 @@ function getEventHandler(event: any): Listener | undefined { } } else if (event.action === "Run Script") { return (...eventArgs: any[]) => { - codeStore.executeUserScript(event.script, repeaterContext?.value, componentContext?.value, eventArgs) + codeStore.executeUserScript(event.script, slotScope?.value, componentContext?.value, eventArgs) } } } @@ -237,7 +231,7 @@ const handleSuccess = (event: any) => (data: DataResult) => { return codeStore.handleSuccess( event.on_success_script, data, - repeaterContext?.value, + slotScope?.value, componentContext?.value, event.eventArgs, ) @@ -253,7 +247,7 @@ const handleError = (event: any) => (error: any) => { return codeStore.handleError( event.on_error_script, error, - repeaterContext?.value, + slotScope?.value, componentContext?.value, event.eventArgs, ) diff --git a/frontend/src/components/AppLayout/Repeater.vue b/frontend/src/components/AppLayout/Repeater.vue index 81adbf497..54b7867f0 100644 --- a/frontend/src/components/AppLayout/Repeater.vue +++ b/frontend/src/components/AppLayout/Repeater.vue @@ -7,15 +7,15 @@ {{ emptyStateMessage || "No data" }} diff --git a/frontend/src/components/ComponentContextMenu.vue b/frontend/src/components/ComponentContextMenu.vue index ff73cfad4..dd698a27b 100644 --- a/frontend/src/components/ComponentContextMenu.vue +++ b/frontend/src/components/ComponentContextMenu.vue @@ -17,17 +17,14 @@ import { ref, Ref } from "vue" import { vOnClickOutside } from "@vueuse/components" import ContextMenu from "@/components/ContextMenu.vue" import Block from "@/utils/block" -import useStudioStore from "@/stores/studioStore" import useCanvasStore from "@/stores/canvasStore" import useComponentEditorStore from "@/stores/componentEditorStore" import type { ContextMenuOption } from "@/types" -import { isObjectEmpty } from "@/utils/helpers" import { getBlockCopy, getComponentBlock } from "@/utils/serializer" import getBlockTemplate from "@/utils/blockTemplate" import FormDialog from "@/components/FormDialog.vue" import { toast } from "frappe-ui" -const store = useStudioStore() const canvasStore = useCanvasStore() const contextMenuVisible = ref(false) @@ -129,15 +126,6 @@ const contextMenuOptions: ContextMenuOption[] = [ return !block.value.isRoot() && Boolean(block.value.getParentBlock()) }, }, - { - label: "Edit Slot", - action: () => { - store.showSlotEditorDialog = true - }, - condition: () => - !isObjectEmpty(block.value.componentSlots) && - block.value.isSlotEditable(canvasStore.activeCanvas?.selectedSlot), - }, { label: "Save as Component", action: () => { diff --git a/frontend/src/components/ComponentEditor.vue b/frontend/src/components/ComponentEditor.vue index 91fc94b75..82674f72d 100644 --- a/frontend/src/components/ComponentEditor.vue +++ b/frontend/src/components/ComponentEditor.vue @@ -46,9 +46,8 @@ class="pointer-events-none fixed ring-2 ring-inset ring-outline-purple-5" :class="isSlotSelected(slot.slotId) ? 'opacity-100' : 'opacity-65'" :style="{ - // set min height and width so that slots without content are visible - minWidth: `calc(${12}px * ${canvasProps.scale})`, - minHeight: `calc(${12}px * ${canvasProps.scale})`, + minWidth: `calc(${20}px * ${canvasProps.scale})`, + minHeight: `calc(${20}px * ${canvasProps.scale})`, }" > - - - - diff --git a/frontend/src/components/SearchBlock.vue b/frontend/src/components/SearchBlock.vue index e6c80b396..3bff2e96e 100644 --- a/frontend/src/components/SearchBlock.vue +++ b/frontend/src/components/SearchBlock.vue @@ -435,9 +435,7 @@ const searchWithFilters = (searchTerm: string): Block[] => { if (block.componentSlots) { Object.values(block.componentSlots).forEach((slot) => { - if (Array.isArray(slot.slotContent)) { - slot.slotContent.forEach((child) => searchInBlock(child)) - } + slot.slotContent.forEach((child) => searchInBlock(child)) }) } } diff --git a/frontend/src/components/SlotScopeProvider.vue b/frontend/src/components/SlotScopeProvider.vue new file mode 100644 index 000000000..1e394c4a2 --- /dev/null +++ b/frontend/src/components/SlotScopeProvider.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/StudioComponent.vue b/frontend/src/components/StudioComponent.vue index da7ce2b9b..cfd5db463 100644 --- a/frontend/src/components/StudioComponent.vue +++ b/frontend/src/components/StudioComponent.vue @@ -37,40 +37,26 @@ :key="slotName" v-slot:[slotName]="slotProps" > - - - + + +
{ return name }) -const repeaterContext = inject | null>("repeaterContext", null) +const slotScope = inject | null>("slotScope", null) const componentContext = inject("componentContext", null) const evaluationContext = computed(() => { return { - ...repeaterContext?.value, + ...slotScope?.value, ...componentContext?.value, } }) @@ -304,8 +291,8 @@ const handleClick = (e: MouseEvent) => { const domEvent = e instanceof Event ? e : null const block = (domEvent && getClickedComponent(domEvent)) || props.block canvasStore.activeCanvas?.selectBlock(block, domEvent) - if (repeaterContext?.value) { - block.setRepeaterDataItem((repeaterContext.value as RepeaterContext).dataItem) + if (slotScope?.value) { + block.setSlotScope(slotScope.value) } if (!domEvent) return diff --git a/frontend/src/components/ai/toolDispatch.ts b/frontend/src/components/ai/toolDispatch.ts index 1c09356f4..50027c23b 100644 --- a/frontend/src/components/ai/toolDispatch.ts +++ b/frontend/src/components/ai/toolDispatch.ts @@ -131,15 +131,11 @@ export class ToolDispatcher { newParent.addChild(options, typeof args.index === "number" ? args.index : null) } - /** Fill a named slot of an existing block. `html` sets a raw-string slot; `blocks` - * replaces the slot with expanded child blocks (their ids are assigned on the canvas). */ + /** Fill a named slot of an existing block, replacing it with expanded child blocks + * (their ids are assigned on the canvas). */ private setSlot(args: Record) { const block = this.ctx.getCanvas()?.findBlock(args.component_id) if (!block || !args.slot_name) return - if (typeof args.html === "string") { - block.updateSlot(args.slot_name, args.html) - return - } if (Array.isArray(args.blocks)) { if (!block.getSlot(args.slot_name)) block.addSlot(args.slot_name) block.getSlot(args.slot_name).slotContent = [] diff --git a/frontend/src/stores/codeStore.ts b/frontend/src/stores/codeStore.ts index ba7e836ce..0ab36b141 100644 --- a/frontend/src/stores/codeStore.ts +++ b/frontend/src/stores/codeStore.ts @@ -360,12 +360,12 @@ const useCodeStore = defineStore("codeStore", () => { function executeUserScript( script: string, - repeaterContext?: Record, + slotScope?: Record, componentContext?: Record, eventArgs?: any[], ) { try { - const context = { ...scriptContext.value, ...repeaterContext, ...componentContext, eventArgs } + const context = { ...scriptContext.value, ...slotScope, ...componentContext, eventArgs } const scriptToExecute = ` with (context) { @@ -385,14 +385,14 @@ const useCodeStore = defineStore("codeStore", () => { function handleSuccess( script: string, data: DataResult, - repeaterContext?: Record, + slotScope?: Record, componentContext?: Record, eventArgs?: any[], ) { try { const context = { ...scriptContext.value, - ...repeaterContext, + ...slotScope, ...componentContext, eventArgs, data, @@ -414,14 +414,14 @@ const useCodeStore = defineStore("codeStore", () => { function handleError( script: string, error: any, - repeaterContext?: Record, + slotScope?: Record, componentContext?: Record, eventArgs?: any[], ) { try { const context = { ...scriptContext.value, - ...repeaterContext, + ...slotScope, ...componentContext, eventArgs, error, diff --git a/frontend/src/stores/studioStore.ts b/frontend/src/stores/studioStore.ts index 4f29001dd..511f23919 100644 --- a/frontend/src/stores/studioStore.ts +++ b/frontend/src/stores/studioStore.ts @@ -53,7 +53,6 @@ const useStudioStore = defineStore("store", () => { const componentContextMenu = ref | null>(null) // dialogs - const showSlotEditorDialog = ref(false) const showSearchBlock = ref(false) const showStudioSettingsDialog = ref(false) const showPageOptions = ref(false) @@ -609,7 +608,6 @@ const useStudioStore = defineStore("store", () => { mode, componentContextMenu, // dialogs - showSlotEditorDialog, showSearchBlock, showStudioSettingsDialog, showPageOptions, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dd1f511bd..58e016a0c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -42,7 +42,7 @@ export type StudioMode = "select" | "container" export interface Slot { slotId: string slotName: string - slotContent: string | Block[] + slotContent: Block[] parentBlockId: string } @@ -158,12 +158,8 @@ export type HashString = `#${string}` export type RGBString = `rgb(${number}, ${number}, ${number})` -// repeater -export type RepeaterContext = { - dataItem: Record - dataIndex: number - dataKey?: string -} +// scoped slots +export type SlotScope = Record // completions export type CompletionSource = { diff --git a/frontend/src/utils/block.ts b/frontend/src/utils/block.ts index 3e2d098b5..60899c26e 100644 --- a/frontend/src/utils/block.ts +++ b/frontend/src/utils/block.ts @@ -1,4 +1,4 @@ -import type { BlockOptions, BlockStyleMap, CompletionSource, Slot } from "@/types" +import type { BlockOptions, BlockStyleMap, CompletionSource, Slot, SlotScope } from "@/types" import { clamp } from "@vueuse/core" import { reactive, CSSProperties, nextTick } from 'vue' @@ -42,7 +42,7 @@ class Block implements BlockOptions { extendedFromComponent?: Block // for the component root isCustomVueComponent?: boolean // custom vue component from frappe app // temporary properties - repeaterDataItem?: Record | null + slotScope?: SlotScope | null componentContext?: Record | null // @editor-only @@ -97,8 +97,8 @@ class Block implements BlockOptions { this.initializeSlots() // Define as non-reactive property - Object.defineProperty(this, "repeaterDataItem", { - value: options.repeaterDataItem || null, + Object.defineProperty(this, "slotScope", { + value: options.slotScope || null, writable: true, enumerable: false, configurable: true @@ -159,14 +159,7 @@ class Block implements BlockOptions { if (index === -1) return if (child.isSlotBlock()) { - let slotContent = this.getSlotContent(child.parentSlotName!) - if (!Array.isArray(slotContent)) return - - if (slotContent.length === 1) { - this.updateSlot(child.parentSlotName!, "") - } else { - slotContent.splice(index, 1) - } + this.getSlotContent(child.parentSlotName!)?.splice(index, 1) } else { this.children.splice(index, 1) } @@ -185,9 +178,8 @@ class Block implements BlockOptions { getChildIndex(child: Block) { if (child.parentSlotName) { - return ( - this.getSlotContent(child.parentSlotName) as Block[] - )?.findIndex((block) => block.componentId === child.componentId) + return this.getSlotContent(child.parentSlotName) + ?.findIndex((block) => block.componentId === child.componentId) } return this.children.findIndex((block) => block.componentId === child.componentId) } @@ -197,10 +189,8 @@ class Block implements BlockOptions { const child = this.children.find((block) => block.componentId === componentId) if (child) return child for (const slot of Object.values(this.componentSlots)) { - if (Array.isArray(slot.slotContent)) { - const found = slot.slotContent.find((block) => block.componentId === componentId) - if (found) return found - } + const found = slot.slotContent.find((block) => block.componentId === componentId) + if (found) return found } return null } @@ -646,12 +636,11 @@ class Block implements BlockOptions { } slot.parentBlockId = this.componentId - if (Array.isArray(slot.slotContent)) { - slot.slotContent = slot.slotContent.map((block) => { - block.parentBlock = this - return reactive(new Block(block)) - }) - } + // legacy pages may carry string slot content; those are migrated, ignore any leftovers + slot.slotContent = (Array.isArray(slot.slotContent) ? slot.slotContent : []).map((block) => { + block.parentBlock = this + return reactive(new Block(block)) + }) }) } @@ -659,7 +648,7 @@ class Block implements BlockOptions { this.componentSlots[slotName] = { slotName: slotName, slotId: this.generateSlotId(slotName), - slotContent: "", + slotContent: [], parentBlockId: this.componentId } nextTick(() => { @@ -668,24 +657,16 @@ class Block implements BlockOptions { }) } - updateSlot(slotName: string, content: string | Block | BlockOptions, index?: number | null) { - if (typeof content === "string") { - this.componentSlots[slotName].slotContent = content - } else { - if (!Array.isArray(this.componentSlots[slotName].slotContent)) { - this.componentSlots[slotName].slotContent = [] - } - - // for top-level blocks inside a slot - content.parentSlotName = slotName - content.parentBlock = this - const slotContent = this.componentSlots[slotName].slotContent as Block[] - index = this.getValidIndex(index, slotContent.length) - const childBlock = reactive(new Block(content)) - slotContent.splice(index, 0, childBlock) - childBlock.selectBlock() - return childBlock - } + updateSlot(slotName: string, content: Block | BlockOptions, index?: number | null) { + // for top-level blocks inside a slot + content.parentSlotName = slotName + content.parentBlock = this + const slotContent = this.componentSlots[slotName].slotContent + index = this.getValidIndex(index, slotContent.length) + const childBlock = reactive(new Block(content)) + slotContent.splice(index, 0, childBlock) + childBlock.selectBlock() + return childBlock } removeSlot(slotName: string) { @@ -708,16 +689,6 @@ class Block implements BlockOptions { return `${this.componentId}:${slotName}` } - isSlotEditable(slot: Slot | undefined | null) { - if (!slot) return false - - return Boolean( - !this.isRoot() - && slot.slotId - && typeof slot.slotContent === "string" - ) - } - isSlotBlock() { return Boolean(this.parentSlotName) } @@ -727,39 +698,23 @@ class Block implements BlockOptions { return this.componentName === "Repeater" } - isRepeated() { - return Boolean(this.getParentBlock()?.isRepeater()) + isRepeated(): boolean { + let current = this.getParentBlock() + while (current) { + if (current.isRepeater()) return true + current = current.getParentBlock() + } + return false } - setRepeaterDataItem(repeaterDataItem: Record) { - // temporarily set repeater data item on selected block for autocompletions - this.repeaterDataItem = repeaterDataItem + // scoped slots + setSlotScope(slotScope: SlotScope) { + // temporarily set the enclosing scoped slot props on selected block for autocompletions + this.slotScope = slotScope } getCompletions(): CompletionSource[] { - const completions = [] - if (this.repeaterDataItem) { - completions.push( - { - item: this.repeaterDataItem, - completion: { - label: "dataItem", - type: "data", - detail: "Repeater Data Item", - } - } - ) - completions.push( - { - item: "dataIndex", - completion: { - label: "dataIndex", - type: "data", - detail: "Repeater Data Index", - } - } - ) - } + const completions = this.getSlotScopeCompletions() if (this.componentContext) { completions.push( { @@ -776,6 +731,18 @@ class Block implements BlockOptions { return completions } + getSlotScopeCompletions(): CompletionSource[] { + const detail = this.isRepeated() ? "Repeater Scope" : "Slot Scope" + return Object.entries(this.slotScope || {}).map(([name, value]) => ({ + item: value, + completion: { + label: name, + type: "data", + detail, + }, + })) + } + // events addEvent(event: ComponentEvent) { this.componentEvents[event.event] = event diff --git a/frontend/src/utils/blockCodec.ts b/frontend/src/utils/blockCodec.ts index cf2ad1938..138b4b6c7 100644 --- a/frontend/src/utils/blockCodec.ts +++ b/frontend/src/utils/blockCodec.ts @@ -26,8 +26,8 @@ export function expandBlock(node: Record): BlockOptions { return block } -/** Expand the compact `slots` map ({slotName: [compact blocks] | "html string"}) into Studio - * componentSlots. slotId/parentBlockId are backfilled by the Block constructor (initializeSlots). +/** Expand the compact `slots` map ({slotName: [compact blocks]}) into Studio componentSlots. + * slotId/parentBlockId are backfilled by the Block constructor (initializeSlots). * Mirrors BlockCodec._expand_slots. */ function expandSlots(slots: Record | undefined): Record { const out: Record = {} @@ -35,8 +35,6 @@ function expandSlots(slots: Record | undefined): Record getBlockCopyWithoutParent(child)) // remove parentBlock reference for slot children for (const slot of Object.values(blockCopy.componentSlots || {})) { - if (Array.isArray(slot.slotContent)) { - slot.slotContent = slot.slotContent.map((child) => getBlockCopyWithoutParent(child)) - } + slot.slotContent = slot.slotContent.map(getBlockCopyWithoutParent) as Block[] } return blockCopy diff --git a/frontend/src/utils/useCanvasUtils.ts b/frontend/src/utils/useCanvasUtils.ts index f031f89af..6a39e3321 100644 --- a/frontend/src/utils/useCanvasUtils.ts +++ b/frontend/src/utils/useCanvasUtils.ts @@ -85,10 +85,8 @@ export function useCanvasUtils( if (block.componentSlots) { for (const slot of Object.values(block.componentSlots)) { - if (Array.isArray(slot.slotContent)) { - const found = findBlock(componentId, slot.slotContent) - if (found) return found - } + const found = findBlock(componentId, slot.slotContent) + if (found) return found } } } diff --git a/studio/ai/agent/selectors.py b/studio/ai/agent/selectors.py index 98104d168..932638db4 100644 --- a/studio/ai/agent/selectors.py +++ b/studio/ai/agent/selectors.py @@ -11,7 +11,7 @@ def child_blocks(block: dict) -> Iterator[dict]: """A block's direct child blocks: its `children` PLUS blocks nested in named slots - (componentSlots[].slotContent when that content is a block list). Slotted blocks + (componentSlots[].slotContent). Slotted blocks are real children of the block — walking only `children` makes them unreachable for selection and ref-validation.""" for child in block.get("children") or []: diff --git a/studio/ai/agent/tools/blocks.py b/studio/ai/agent/tools/blocks.py index 9bbce8697..c1b70b35b 100644 --- a/studio/ai/agent/tools/blocks.py +++ b/studio/ai/agent/tools/blocks.py @@ -151,7 +151,7 @@ }, "slots": { "type": "object", - "description": 'NAMED slots (only for components that expose them): {"": [ child block objects ]} — each value is a list of blocks in this same schema, or a raw HTML string. Use "c" for default-slot content; use "slots" only for named slots.', + "description": 'NAMED slots (only for components that expose them): {"": [ child block objects ]} — each value is a list of blocks in this same schema. Use "c" for default-slot content; use "slots" only for named slots.', }, }, "required": ["name"], @@ -210,11 +210,11 @@ description=( "Fill a NAMED slot of a block that ALREADY EXISTS on the page — the component-specific slots " "a frappe-ui component exposes beyond its default slot (e.g. a card's header/footer, a custom " - "prefix/suffix). Replaces that slot's content. Provide EITHER 'blocks' (a list of block " - "definitions in the add_block schema — they get their ids on the canvas, so bake any bindings/" - "events into their props) OR 'html' (a raw HTML string). For default-slot content, add children " - "with add_block instead; for a block you are CREATING this turn, put named slots in its add_block " - "'slots' field rather than calling this." + "prefix/suffix). Replaces that slot's content with 'blocks' (a list of block definitions in the " + "add_block schema — they get their ids on the canvas, so bake any bindings/events into their " + "props). A slot only holds blocks: for a text label use a TextBlock. For default-slot content, " + "add children with add_block instead; for a block you are CREATING this turn, put named slots in " + "its add_block 'slots' field rather than calling this." ), parameters={ "type": "object", @@ -229,12 +229,8 @@ "description": "Block definitions to place in the slot (add_block schema, recursively). Do NOT include ids.", "items": {"type": "object"}, }, - "html": { - "type": "string", - "description": "Raw HTML for the slot, as an alternative to 'blocks'.", - }, }, - "required": ["component_id", "slot_name"], + "required": ["component_id", "slot_name", "blocks"], }, ) diff --git a/studio/ai/block_codec.py b/studio/ai/block_codec.py index 012c105ea..136686469 100644 --- a/studio/ai/block_codec.py +++ b/studio/ai/block_codec.py @@ -62,9 +62,9 @@ def compress(block: dict, depth: int = 0) -> dict: @staticmethod def _compress_slots(slots: dict, depth: int) -> dict: - """Compact componentSlots to {slotName: [compact blocks] | "html string"}. Slot - children use the SAME compact shape as `c` (recurse), and the derived slotId/ - parentBlockId are dropped (the client regenerates them). Empty slots are omitted.""" + """Compact componentSlots to {slotName: [compact blocks]}. Slot children use the SAME + compact shape as `c` (recurse), and the derived slotId/parentBlockId are dropped (the + client regenerates them). Empty slots are omitted.""" out = {} for name, slot in slots.items(): if not isinstance(slot, dict): @@ -74,24 +74,19 @@ def _compress_slots(slots: dict, depth: int) -> dict: blocks = [BlockCodec.compress(b, depth + 1) for b in content if isinstance(b, dict)] if blocks: out[name] = blocks - elif isinstance(content, str) and content.strip(): - out[name] = content return out @staticmethod def _expand_slots(slots: dict) -> dict: - """Expand compact slots ({slotName: [blocks] | "html"}) into componentSlots. slotId/ + """Expand compact slots ({slotName: [blocks]}) into componentSlots. slotId/ parentBlockId are backfilled by the frontend Block constructor (initializeSlots).""" out = {} if not isinstance(slots, dict): return out for name, content in slots.items(): - if isinstance(content, list): - slot_content = [BlockCodec.expand(b) for b in content if isinstance(b, dict)] - elif isinstance(content, str): - slot_content = content - else: + if not isinstance(content, list): continue + slot_content = [BlockCodec.expand(b) for b in content if isinstance(b, dict)] out[name] = {"slotName": name, "slotContent": slot_content} return out diff --git a/studio/ai/prompts.py b/studio/ai/prompts.py index 85512b211..05441895c 100644 --- a/studio/ai/prompts.py +++ b/studio/ai/prompts.py @@ -123,7 +123,7 @@ - "events": { } — event handlers, eventName → JS script, e.g. {"click":"counter.value++"}. Variables are refs (write with .value); the script also sees data sources and route/router. - "visibility": "expr" — render the block only when a {{ }} expression is truthy, e.g. "{{ todos.data.length > 0 }}" - "c": [ ] — children list (array of block objects). These are the block's DEFAULT-slot content (e.g. a Dialog's body, a ContextMenu's target surface). -- "slots": { } — NAMED slots only, for components that expose them: {"": [ ...child block objects... ]} (each value is a block list in THIS same schema, or a raw HTML string). Default content goes in "c" — use "slots" only for a component's named slots. On an EXISTING block, fill a named slot with set_slot(component_id, slot_name, blocks) and drop a wrong one with remove_slot(component_id, slot_name). +- "slots": { } — NAMED slots only, for components that expose them: {"": [ ...child block objects... ]} (each value is a block list in THIS same schema — a slot holds blocks only, so use a TextBlock for a plain label). Default content goes in "c" — use "slots" only for a component's named slots. On an EXISTING block, fill a named slot with set_slot(component_id, slot_name, blocks) and drop a wrong one with remove_slot(component_id, slot_name). NAMED SLOTS RULE: use ONLY a component's real slot names — the ones listed after `# slots:` in its catalog entry below. NEVER invent a slot name (e.g. Sidebar's footer is called "footer-items", not "footer"); content placed in a slot the component doesn't declare silently does not render. If a component shows no `# slots:`, treat it as having none — use its props or default-slot "c". ROOT BLOCK — the page root is: diff --git a/studio/build.py b/studio/build.py index 83afcbc11..9a219dadd 100644 --- a/studio/build.py +++ b/studio/build.py @@ -180,9 +180,10 @@ def _add_block_components(self, block: dict) -> None: if slots := block.get("componentSlots"): for slot in slots.values(): - if isinstance(slot.get("slotContent"), str): + content = slot.get("slotContent") + if not isinstance(content, list): continue - for slot_child in slot.get("slotContent"): + for slot_child in content: self._add_block_components(slot_child) def _add_studio_components(self, block: dict): diff --git a/studio/patches.txt b/studio/patches.txt index 5c4b63bea..cf53f16c9 100644 --- a/studio/patches.txt +++ b/studio/patches.txt @@ -15,4 +15,5 @@ studio.studio.doctype.studio_page.patches.migrate_watchers_to_page_script studio.studio.doctype.studio_page.patches.migrate_espresso_color_tokens_v2 studio.studio.doctype.studio_page.patches.migrate_text_size_tokens_v2 studio.studio.doctype.studio_page.patches.migrate_event_actions_to_script -studio.studio.doctype.studio_ai_session.patches.migrate_ai_session_messages_to_doctype \ No newline at end of file +studio.studio.doctype.studio_ai_session.patches.migrate_ai_session_messages_to_doctype +studio.studio.doctype.studio_page.patches.migrate_slot_content_to_blocks \ No newline at end of file diff --git a/studio/studio/doctype/studio_app/test_studio_app.py b/studio/studio/doctype/studio_app/test_studio_app.py index 9caeac22d..9c5f78b42 100644 --- a/studio/studio/doctype/studio_app/test_studio_app.py +++ b/studio/studio/doctype/studio_app/test_studio_app.py @@ -74,8 +74,8 @@ def test_extracts_components_from_slots(self): self.assertIn("Dialog", builder.components) self.assertIn("TextInput", builder.components) - def test_handles_string_slot_content(self): - """String slot content should be gracefully skipped without errors.""" + def test_handles_non_block_slot_content(self): + """Legacy string slot content should be gracefully skipped without errors.""" app = make_studio_app(app_title="Str Slot App", app_name="str-slot-app") blocks = json.dumps( [ diff --git a/studio/studio/doctype/studio_page/patches/migrate_slot_content_to_blocks.py b/studio/studio/doctype/studio_page/patches/migrate_slot_content_to_blocks.py new file mode 100644 index 000000000..d559d563c --- /dev/null +++ b/studio/studio/doctype/studio_page/patches/migrate_slot_content_to_blocks.py @@ -0,0 +1,69 @@ +"""Slots now hold blocks only — string/HTML slot content is no longer rendered. + +Convert every leftover string into an equivalent block so existing pages keep their +content: markup becomes an HTML block, plain text a TextBlock. +""" + +import re + +import frappe + +HTML_PATTERN = re.compile(r"<[a-z][\s\S]*>", re.IGNORECASE) + + +def execute(): + pages = frappe.get_all("Studio Page", fields=["name", "blocks", "draft_blocks"]) + for page in pages: + updates = {} + for field in ("blocks", "draft_blocks"): + blocks = frappe.parse_json(page.get(field) or "[]") + if blocks and migrate_blocks(blocks): + updates[field] = frappe.as_json(blocks) + if updates: + frappe.get_doc("Studio Page", page.name).update(updates).save() + + components = frappe.get_all("Studio Component", fields=["name", "block"]) + for component in components: + block = frappe.parse_json(component.get("block") or "{}") + if block and migrate_blocks([block]): + frappe.get_doc("Studio Component", component.name).update({"block": frappe.as_json(block)}).save() + + +def migrate_blocks(blocks) -> bool: + """Rewrite string slot content in place. Returns True if anything changed.""" + migrated = False + for block in blocks: + if not isinstance(block, dict): + continue + + for slot_name, slot in (block.get("componentSlots") or {}).items(): + content = slot.get("slotContent") if isinstance(slot, dict) else None + if isinstance(content, list): + migrated = migrate_blocks(content) or migrated + elif isinstance(content, str): + slot["slotContent"] = as_blocks(content, slot_name) + migrated = True + + migrated = migrate_blocks(block.get("children") or []) or migrated + + return migrated + + +def as_blocks(content: str, slot_name: str) -> list[dict]: + if not content.strip(): + return [] + + if HTML_PATTERN.search(content): + component_name, props = "HTML", {"html": content} + else: + component_name, props = "TextBlock", {"text": content, "tag": "p"} + + return [ + { + "componentId": f"{component_name}-{frappe.generate_hash(length=9)}", + "componentName": component_name, + "componentProps": props, + "children": [], + "parentSlotName": slot_name, + } + ] diff --git a/studio/studio/doctype/studio_page/studio_page.py b/studio/studio/doctype/studio_page/studio_page.py index 8979c24d4..b65a076fc 100644 --- a/studio/studio/doctype/studio_page/studio_page.py +++ b/studio/studio/doctype/studio_page/studio_page.py @@ -164,9 +164,10 @@ def add_component(block): if slots := block.get("componentSlots"): for slot in slots.values(): - if isinstance(slot.get("slotContent"), str): + content = slot.get("slotContent") + if not isinstance(content, list): continue - for slot_child in slot.get("slotContent"): + for slot_child in content: add_component(slot_child) def get_root_block(blocks):