Skip to content
Merged
12 changes: 9 additions & 3 deletions app/ycode/components/CenterCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,10 @@ const CenterCanvas = React.memo(function CenterCanvas({
layerList.forEach((layer) => {
const collectionVariable = getCollectionVariable(layer);
if (collectionVariable?.id) {
settings.push(`${layer.id}:${collectionVariable.id}:${collectionVariable.sort_by ?? ''}:${collectionVariable.sort_order ?? ''}:${collectionVariable.limit ?? ''}:${collectionVariable.offset ?? ''}`);
const opts = layer.settings?.optionsSource;
const sortBy = opts?.sortFieldId || collectionVariable.sort_by;
const sortOrder = opts?.sortOrder || collectionVariable.sort_order;
settings.push(`${layer.id}:${collectionVariable.id}:${sortBy ?? ''}:${sortOrder ?? ''}:${collectionVariable.limit ?? ''}:${collectionVariable.offset ?? ''}`);
}
if (layer.children && layer.children.length > 0) {
settings.push(...extractCollectionSettings(layer.children));
Expand All @@ -886,11 +889,14 @@ const CenterCanvas = React.memo(function CenterCanvas({
layerList.forEach((layer) => {
const collectionVariable = getCollectionVariable(layer);
if (collectionVariable?.id) {
const opts = layer.settings?.optionsSource;
const sortBy = opts?.sortFieldId || collectionVariable.sort_by;
const sortOrder = opts?.sortOrder || collectionVariable.sort_order;
fetchLayerData(
layer.id,
collectionVariable.id,
collectionVariable.sort_by,
collectionVariable.sort_order,
sortBy,
sortOrder,
collectionVariable.limit,
collectionVariable.offset
);
Expand Down
4 changes: 2 additions & 2 deletions app/ycode/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2121,8 +2121,8 @@ const RightSidebar = React.memo(function RightSidebar({
</SettingsPanel>
)}

{/* Collection Binding Panel - only show for collection layers */}
{selectedLayer && getCollectionVariable(selectedLayer) && (
{/* Collection Binding Panel - only show for collection layers (hide when optionsSource manages it) */}
{selectedLayer && getCollectionVariable(selectedLayer) && !selectedLayer.settings?.optionsSource && (
<SettingsPanel
title="CMS"
isOpen={collectionBindingOpen}
Expand Down
259 changes: 203 additions & 56 deletions app/ycode/components/SelectOptionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* (each item becomes an option with value=itemId, label=displayField).
*/

import React, { useState, useCallback, useEffect, useMemo } from 'react';
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';

import {
DndContext,
Expand Down Expand Up @@ -59,6 +59,20 @@ import { toast } from 'sonner';

import type { Layer, CollectionItemWithValues } from '@/types';

function isCheckboxWrapperLayer(layer: Layer | null): boolean {
if (!layer || layer.name !== 'div') return false;
return !!layer.children?.some(
(c) => c.name === 'input' && c.attributes?.type === 'checkbox'
);
}

function isRadioWrapperLayer(layer: Layer | null): boolean {
if (!layer || layer.name !== 'div') return false;
return !!layer.children?.some(
(c) => c.name === 'input' && c.attributes?.type === 'radio'
);
}

interface SelectOptionsSettingsProps {
layer: Layer | null;
onLayerUpdate: (layerId: string, updates: Partial<Layer>) => void;
Expand Down Expand Up @@ -333,6 +347,8 @@ export default function SelectOptionsSettings({
const { collections, fields, loadFields } = useCollectionsStore();

const isSelectLayer = layer?.name === 'select';
const isCheckboxWrapper = isCheckboxWrapperLayer(layer);
const isRadioWrapper = isRadioWrapperLayer(layer);
const optionsSource = layer?.settings?.optionsSource;
const isCollectionSource = !!optionsSource?.collectionId;
const explicitOptionsMode = layer?.settings?.selectOptionsMode;
Expand Down Expand Up @@ -372,6 +388,22 @@ export default function SelectOptionsSettings({
const handleSourceChange = useCallback((value: string) => {
if (!layer) return;

if (value === 'none' && (isCheckboxWrapper || isRadioWrapper)) {
autoBindAppliedForRef.current = null;
const { optionsSource: _, sortByCollectionId: _sb, sortByFieldIds: _sf, selectOptionsMode: _sm, ...restSettings } = layer.settings || {};
const textChild = layer.children?.find(c => c.name === 'text');
if (textChild) {
onLayerUpdate(textChild.id, {
variables: { ...textChild.variables, text: { type: 'dynamic_text' as const, data: { content: isRadioWrapper ? 'Radio label' : 'Checkbox label' } } },
});
}
onLayerUpdate(layer.id, {
settings: { ...restSettings, tag: 'label' },
variables: { ...layer.variables, collection: undefined },
});
return;
}

if (value === 'none' || value === 'list') {
const { optionsSource: _, sortByCollectionId: _sb, sortByFieldIds: _sf, ...restSettings } = layer.settings || {};
onLayerUpdate(layer.id, {
Expand All @@ -398,6 +430,16 @@ export default function SelectOptionsSettings({
},
children: SORT_ORDER_PRESET_OPTIONS.map((opt, idx) => buildOptionLayer(`${layer.id}-preset-${idx}-${generateId('opt')}`, opt.label, opt.value)),
});
} else if (isCheckboxWrapper || isRadioWrapper) {
const { sortByCollectionId: _sb, sortByFieldIds: _sf, ...restSettings } = layer.settings || {};
onLayerUpdate(layer.id, {
settings: {
...restSettings,
selectOptionsMode: undefined,
optionsSource: { collectionId: value },
},
variables: { ...layer.variables, collection: { id: value } },
});
} else {
const { sortByCollectionId: _sb, sortByFieldIds: _sf, ...restSettings } = layer.settings || {};
onLayerUpdate(layer.id, {
Expand All @@ -408,7 +450,7 @@ export default function SelectOptionsSettings({
},
});
}
}, [layer, onLayerUpdate]);
}, [layer, isCheckboxWrapper, isRadioWrapper, onLayerUpdate]);

const handleSortByCollectionChange = useCallback((collectionId: string) => {
if (!layer) return;
Expand Down Expand Up @@ -467,6 +509,14 @@ export default function SelectOptionsSettings({
patchOptionsSource({ defaultItemId: value === 'none' ? undefined : value });
}, [patchOptionsSource]);

const handleToggleDefaultCheckboxItem = useCallback((itemId: string) => {
const current = optionsSource?.defaultItemIds || [];
const updated = current.includes(itemId)
? current.filter(id => id !== itemId)
: [...current, itemId];
patchOptionsSource({ defaultItemIds: updated.length > 0 ? updated : undefined });
}, [patchOptionsSource, optionsSource?.defaultItemIds]);

const handleSortFieldChange = useCallback((value: string) => {
patchOptionsSource({ sortFieldId: value === 'none' ? undefined : value, sortOrder: value === 'none' ? undefined : (optionsSource?.sortOrder || 'asc') });
}, [patchOptionsSource, optionsSource?.sortOrder]);
Expand Down Expand Up @@ -712,8 +762,45 @@ export default function SelectOptionsSettings({
onLayerUpdate(layer.id, { children: [noneChild, ...fieldChildren] });
}, [layer?.id, isSortByMode, sortByCollectionId, sortByFieldIds, sortByCollectionFields, currentOptionSignature, onLayerUpdate]);

// Only show for select elements
if (!layer || !isSelectLayer) {
// Track which collection has already had its text child auto-bound to prevent re-binding
const autoBindAppliedForRef = useRef<string | null>(null);

// Checkbox/radio wrapper: auto-bind text child to the display field once when a collection is first assigned
const isInputGroupWrapper = isCheckboxWrapper || isRadioWrapper;
useEffect(() => {
if (!layer || !isInputGroupWrapper || !isCollectionSource || !displayField) return;
const collectionId = layer.settings?.optionsSource?.collectionId;
if (!collectionId || autoBindAppliedForRef.current === collectionId) return;
const textChild = layer.children?.find(c => c.name === 'text');
if (!textChild) return;
if (textChild.variables?.text?.type === 'dynamic_rich_text') {
autoBindAppliedForRef.current = collectionId;
return;
}
onLayerUpdate(textChild.id, {
variables: {
...textChild.variables,
text: {
type: 'dynamic_rich_text' as const,
data: {
content: {
type: 'doc',
content: [{ type: 'paragraph', content: [{
type: 'dynamicVariable',
attrs: {
variable: { type: 'field', data: { field_id: displayField.id, field_type: displayField.type, relationships: [], source: 'collection', collection_layer_id: layer.id } },
label: displayField.name || 'Name',
},
}] }],
},
},
},
},
});
autoBindAppliedForRef.current = collectionId;
}, [layer?.id, isInputGroupWrapper, isCollectionSource, displayField, layer?.children, onLayerUpdate]);

if (!layer || (!isSelectLayer && !isCheckboxWrapper && !isRadioWrapper)) {
return null;
}

Expand All @@ -724,7 +811,7 @@ export default function SelectOptionsSettings({
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
action={
!isCollectionSource && !isSortOrderMode && !isSortByMode ? (
isSelectLayer && !isCollectionSource && !isSortOrderMode && !isSortByMode ? (
<Popover open={showAddPopover} onOpenChange={setShowAddPopover}>
<PopoverTrigger asChild>
<Button
Expand Down Expand Up @@ -796,17 +883,21 @@ export default function SelectOptionsSettings({
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="list">
<span className="flex items-center gap-2">
<Icon name="listUnordered" className="size-3 opacity-60" />
List
</span>
</SelectItem>
<SelectGroup>
<SelectLabel>Sorting options</SelectLabel>
<SelectItem value={SOURCE_SORT_BY}>Sort by</SelectItem>
<SelectItem value={SOURCE_SORT_ORDER}>Sort order</SelectItem>
</SelectGroup>
{isSelectLayer && (
<>
<SelectItem value="list">
<span className="flex items-center gap-2">
<Icon name="listUnordered" className="size-3 opacity-60" />
List
</span>
</SelectItem>
<SelectGroup>
<SelectLabel>Sorting options</SelectLabel>
<SelectItem value={SOURCE_SORT_BY}>Sort by</SelectItem>
<SelectItem value={SOURCE_SORT_ORDER}>Sort order</SelectItem>
</SelectGroup>
</>
)}
{collections.length > 0 && (
<SelectGroup>
<SelectLabel>Collections</SelectLabel>
Expand All @@ -832,45 +923,101 @@ export default function SelectOptionsSettings({
<div className="grid grid-cols-3 items-center">
<Label variant="muted">Default</Label>
<div className="col-span-2">
<Select
value={optionsSource?.defaultItemId || 'none'}
onValueChange={handleDefaultItemChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{sourceItems.map((item) => (
<SelectItem key={item.id} value={item.id}>
{getItemDisplayName(item, displayField)}
</SelectItem>
))}
{sourceItemsLoading && (
<div className="px-2 py-2 text-xs text-muted-foreground">Loading options...</div>
)}
{!sourceItemsLoading && sourceItems.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground">No items found</div>
)}
{sourceItemsHasMore && (
<div className="px-1 py-1">
<Button
size="sm"
variant="ghost"
className="w-full justify-center text-xs"
disabled={sourceItemsLoadingMore}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
fetchSourceItems({ reset: false, offset: sourceItemsOffset });
}}
{isCheckboxWrapper ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline" size="sm"
className="w-full justify-between font-normal"
>
<span className="truncate">
{(optionsSource?.defaultItemIds?.length || 0) > 0
? `${optionsSource!.defaultItemIds!.length} selected`
: 'None'}
</span>
<Icon
name="chevronDown"
className="size-3.5 shrink-0 opacity-50"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[var(--radix-dropdown-menu-trigger-width)]">
{sourceItems.map((item) => (
<DropdownMenuCheckboxItem
key={item.id}
checked={(optionsSource?.defaultItemIds || []).includes(item.id)}
onCheckedChange={() => handleToggleDefaultCheckboxItem(item.id)}
onSelect={(e) => e.preventDefault()}
>
{sourceItemsLoadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</SelectContent>
</Select>
{getItemDisplayName(item, displayField)}
</DropdownMenuCheckboxItem>
))}
{sourceItemsLoading && (
<div className="px-2 py-2 text-xs text-muted-foreground">Loading options...</div>
)}
{!sourceItemsLoading && sourceItems.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground">No items found</div>
)}
{sourceItemsHasMore && (
<div className="px-1 py-1">
<Button
size="sm"
variant="ghost"
className="w-full justify-center text-xs"
disabled={sourceItemsLoadingMore}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
fetchSourceItems({ reset: false, offset: sourceItemsOffset });
}}
>
{sourceItemsLoadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Select
value={optionsSource?.defaultItemId || 'none'}
onValueChange={handleDefaultItemChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="none">None</SelectItem>
{sourceItems.map((item) => (
<SelectItem key={item.id} value={item.id}>
{getItemDisplayName(item, displayField)}
</SelectItem>
))}
{sourceItemsLoading && (
<div className="px-2 py-2 text-xs text-muted-foreground">Loading options...</div>
)}
{!sourceItemsLoading && sourceItems.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground">No items found</div>
)}
{sourceItemsHasMore && (
<div className="px-1 py-1">
<Button
size="sm"
variant="ghost"
className="w-full justify-center text-xs"
disabled={sourceItemsLoadingMore}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
fetchSourceItems({ reset: false, offset: sourceItemsOffset });
}}
>
{sourceItemsLoadingMore ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</SelectContent>
</Select>
)}
</div>
</div>

Expand Down Expand Up @@ -1002,8 +1149,8 @@ export default function SelectOptionsSettings({
</>
)}

{/* Static options editor (only when not using collection source) */}
{!isCollectionSource && !isSortByMode && (
{/* Static options editor (select only, not when using collection source) */}
{isSelectLayer && !isCollectionSource && !isSortByMode && (
<>
{isSortOrderMode ? (
<div className="flex flex-col gap-2">
Expand Down
Loading