Custom Component genel düzenlemeleri

This commit is contained in:
Sedat ÖZTÜRK 2026-08-20 11:50:16 +03:00
parent 4003dc1845
commit 870c98690c
12 changed files with 529 additions and 209 deletions

View file

@ -20556,6 +20556,12 @@
"en": "Static options", "en": "Static options",
"tr": "Statik seçenekler" "tr": "Statik seçenekler"
}, },
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.TabContentPlaceholder",
"en": "Tab content",
"tr": "Sekme içeriği"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.StaticRowsHint", "key": "App.DeveloperKitComponentDesigner.StaticRowsHint",
@ -24909,8 +24915,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogFlexRow.FlexRowDescription", "key": "App.CatalogFlexRow.FlexRowDescription",
"en": "Horizontal and responsive layout", "en": "Horizontal, responsive layout; a column count splits it into equal columns and a first column width makes it a sidebar",
"tr": "Yatay ve responsive yerleşim" "tr": "Yatay ve responsive yerleşim; kolon sayısı eşit kolonlara böler, ilk kolon genişliği verilirse sidebar olur"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -24942,18 +24948,18 @@
"en": "Form", "en": "Form",
"tr": "Form" "tr": "Form"
}, },
{
"resourceName": "Platform",
"key": "App.CatalogHeaderContent.HeaderContentDescription",
"en": "Vertical layout for a header area and content below",
"tr": "Üst alan ve alt içerik için dikey yerleşim"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogHoverable.HoverableDescription", "key": "App.CatalogHoverable.HoverableDescription",
"en": "Shows a highlight color when hovering over rows", "en": "Shows a highlight color when hovering over rows",
"tr": "Satırların üzerine gelindiğinde vurgu rengi gösterir" "tr": "Satırların üzerine gelindiğinde vurgu rengi gösterir"
}, },
{
"resourceName": "Platform",
"key": "App.CatalogImg.ImgPlaceholderAlt",
"en": "Sample image",
"tr": "Örnek görsel"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogItems.ItemsDescription", "key": "App.CatalogItems.ItemsDescription",
@ -25038,12 +25044,6 @@
"en": "Select only shows the Label; the columns added on the Data tab travel with the option.", "en": "Select only shows the Label; the columns added on the Data tab travel with the option.",
"tr": "Select yalnızca Label gösterir; Data sekmesinde eklenen sütunlar seçenekle birlikte taşınır." "tr": "Select yalnızca Label gösterir; Data sekmesinde eklenen sütunlar seçenekle birlikte taşınır."
}, },
{
"resourceName": "Platform",
"key": "App.CatalogSidebarContent.SidebarContentDescription",
"en": "Narrow sidebar and wide content area",
"tr": "Dar sidebar ve geniş içerik alanı"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSpacer.SpacerDescription", "key": "App.CatalogSpacer.SpacerDescription",
@ -25188,12 +25188,6 @@
"en": "Sözsoft dynamic tree view", "en": "Sözsoft dynamic tree view",
"tr": "Sözsoft dinamik ağaç görünümü" "tr": "Sözsoft dinamik ağaç görünümü"
}, },
{
"resourceName": "Platform",
"key": "App.CatalogTwoColumns.TwoColumnsDescription",
"en": "Splits the page into two equal columns",
"tr": "Sayfayı iki eşit kolona böler"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.VisualDesignerConditionKind.ComponentValue", "key": "App.VisualDesignerConditionKind.ComponentValue",
@ -25746,30 +25740,6 @@
"en": "Enables the Delete button.", "en": "Enables the Delete button.",
"tr": "Delete butonunu aktifleştirir." "tr": "Delete butonunu aktifleştirir."
}, },
{
"resourceName": "Platform",
"key": "App.VisualDesignerLayout.HeaderContent",
"en": "Header + content",
"tr": "Üst alan + içerik"
},
{
"resourceName": "Platform",
"key": "App.VisualDesignerLayout.PageContainer",
"en": "Page area",
"tr": "Sayfa alanı"
},
{
"resourceName": "Platform",
"key": "App.VisualDesignerLayout.SidebarContent",
"en": "Sidebar + content",
"tr": "Sidebar + içerik"
},
{
"resourceName": "Platform",
"key": "App.VisualDesignerLayout.TwoColumns",
"en": "Two columns",
"tr": "İki kolon"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.SnippetApiCall.ApiCallDescription2", "key": "App.SnippetApiCall.ApiCallDescription2",

View file

@ -0,0 +1,15 @@
{
"GeneratedAt": "2026-08-20T07:44:44.3775836Z",
"CustomComponents": [
{
"Name": "NewComponent",
"RoutePath": "/admin/new-component",
"Code": "/*__SOZSOFT_VISUAL_DESIGNER__%7B%22version%22%3A1%2C%22sourceMode%22%3A%22visual%22%2C%22nodes%22%3A%5B%5D%2C%22canvas%22%3A%7B%22width%22%3A%22responsive%22%7D%2C%22lifecycle%22%3A%7B%22onMount%22%3A%22%22%7D%2C%22dataSources%22%3A%5B%5D%7D__*/\nconst NewComponent = () => {\n\n\n return (\n <>\n\n </>\n )\n}\n\nexport default NewComponent",
"Props": "{\"visualDesigner\":{\"version\":1,\"sourceMode\":\"visual\",\"nodes\":[],\"canvas\":{\"width\":\"responsive\"},\"lifecycle\":{\"onMount\":\"\"},\"dataSources\":[]}}",
"Description": "",
"IsActive": true,
"Dependencies": [],
"DataSources": []
}
]
}

View file

@ -1336,19 +1336,19 @@ export const CUSTOM_COMPONENTS: ComponentDefinition[] = [
{ {
key: 'tab1', key: 'tab1',
label: 'Sekme 1', label: 'Sekme 1',
children: 'İlk sekme içeriği', children: '',
disabled: false, disabled: false,
}, },
{ {
key: 'tab2', key: 'tab2',
label: 'Sekme 2', label: 'Sekme 2',
children: 'İkinci sekme içeriği', children: '',
disabled: false, disabled: false,
}, },
{ {
key: 'tab3', key: 'tab3',
label: 'Sekme 3', label: 'Sekme 3',
children: 'Üçüncü sekme içeriği', children: '',
disabled: false, disabled: false,
}, },
], ],

View file

@ -11,6 +11,8 @@ import {
} from '@/contexts/componentRuntime' } from '@/contexts/componentRuntime'
import { useComponents } from '../../contexts/ComponentContext' import { useComponents } from '../../contexts/ComponentContext'
import ErrorBoundary from './ErrorBoundary' import ErrorBoundary from './ErrorBoundary'
import PlatformIcon from './PlatformIcon'
import PlatformSelectComponent from './selectComponents'
import PlatformViewHost from './PlatformViewHost' import PlatformViewHost from './PlatformViewHost'
import { toast } from '../ui' import { toast } from '../ui'
import { Loading } from '../shared' import { Loading } from '../shared'
@ -49,6 +51,12 @@ const compileComponent = (
// code without an extra per-component entry here. // code without an extra per-component entry here.
const staticComponents: Record<string, any> = { const staticComponents: Record<string, any> = {
...UiKit, ...UiKit,
// Icon props travel as names, so generated code needs the same name resolver
// the designer canvas draws with.
PlatformIcon,
// Select.componentAs is stored as a name, so generated code needs the same
// resolver the designer canvas renders with.
PlatformSelectComponent,
PlatformViewHost, PlatformViewHost,
toast, toast,
apiService, apiService,

View file

@ -0,0 +1,24 @@
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
interface PlatformIconProps {
/** Icon name as stored in the designer, e.g. `FaUser`. */
name?: string
className?: string
}
/**
* Renders an icon from its name. Icon props are stored as names that is what a
* picker can offer and what a document can carry so both the designer canvas
* and the generated component need one place that turns a name into an element.
* An unknown or empty name renders nothing, so an unset icon leaves no gap.
*/
const PlatformIcon = ({ name, className }: PlatformIconProps) => {
const navigationIcon = useNavigationIcons()
if (!name) return null
const Icon = navigationIcon[name]
// The dictionary loads asynchronously; until it arrives there is nothing to
// draw, and the component re-renders on its own once it does.
return Icon ? <Icon className={className} /> : null
}
export default PlatformIcon

View file

@ -0,0 +1,23 @@
import ReactSelect from 'react-select'
import CreatableSelect from 'react-select/creatable'
import AsyncSelect from 'react-select/async'
import type { ComponentType } from 'react'
/**
* `Select.componentAs` wants a component, but a designer document can only carry
* a name. These are the react-select flavours the Select is built to accept, and
* resolving the name in one place is what lets the canvas and the generated
* component agree on what a stored name means.
*/
const SELECT_COMPONENTS: Record<string, ComponentType<never>> = {
ReactSelect: ReactSelect as unknown as ComponentType<never>,
CreatableSelect: CreatableSelect as unknown as ComponentType<never>,
AsyncSelect: AsyncSelect as unknown as ComponentType<never>,
}
export const DESIGNER_SELECT_COMPONENT_NAMES = Object.keys(SELECT_COMPONENTS)
/** Unknown or empty name resolves to undefined, so Select keeps its own default. */
const PlatformSelectComponent = (name?: string) => (name ? SELECT_COMPONENTS[name] : undefined)
export default PlatformSelectComponent

View file

@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import * as UiKit from '@/components/ui' import * as UiKit from '@/components/ui'
import PlatformIcon from '@/components/componentEditor/PlatformIcon'
import PlatformSelectComponent from '@/components/componentEditor/selectComponents'
import PlatformViewHost, { import PlatformViewHost, {
type PlatformViewName, type PlatformViewName,
} from '@/components/componentEditor/PlatformViewHost' } from '@/components/componentEditor/PlatformViewHost'
@ -28,6 +30,9 @@ import {
isDesignerDateComponent, isDesignerDateComponent,
isDesignerDateProperty, isDesignerDateProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle,
isSqlDataSourceNode, isSqlDataSourceNode,
LEGACY_SQL_DATA_SOURCE_TYPE, LEGACY_SQL_DATA_SOURCE_TYPE,
normalizeDesignerKeyList, normalizeDesignerKeyList,
@ -498,7 +503,21 @@ const getPreviewProps = (
// of being rendered. // of being rendered.
if (key === SQL_DEFAULT_VALUE_PROP) return if (key === SQL_DEFAULT_VALUE_PROP) return
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
// A zero width or height is the toolbox default, not a request for a zero
// sized element - emitting it would collapse the image to nothing.
if ((key === 'width' || key === 'height') && value === 0) return
if (key.startsWith('on') && typeof value === 'string') return if (key.startsWith('on') && typeof value === 'string') return
// Stored as a name; Select expects the component itself.
if (key === DESIGNER_SELECT_COMPONENT_PROPERTY && typeof value === 'string') {
const component = PlatformSelectComponent(value)
if (component) props[key] = component
return
}
// Icons are stored as names; the component expects an element.
if (DESIGNER_ICON_PROPERTIES.has(key) && typeof value === 'string') {
props[key] = <PlatformIcon name={value} />
return
}
props[key] = resolveStaticLanguageKeys(value, translate) props[key] = resolveStaticLanguageKeys(value, translate)
}) })
Object.entries(node.events).forEach(([name, script]) => { Object.entries(node.events).forEach(([name, script]) => {
@ -1176,15 +1195,7 @@ const renderElement = (
} }
if (node.type === 'FlexRow') { if (node.type === 'FlexRow') {
return ( return (
<div <div className={String(node.props.className || '')} style={flexRowStyle(node.props)}>
className={String(node.props.className || '')}
style={{
display: 'flex',
gap: Number(node.props.gap) || 0,
flexWrap: node.props.wrap ? 'wrap' : 'nowrap',
alignItems: String(node.props.align || 'stretch'),
}}
>
{children} {children}
</div> </div>
) )
@ -1207,33 +1218,6 @@ const renderElement = (
</div> </div>
) )
} }
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
return (
<div
className={String(node.props.className || '')}
style={{
display: 'grid',
gap: Number(node.props.gap) || 0,
gridTemplateColumns:
node.type === 'SidebarContent'
? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)`
: 'repeat(2, minmax(0, 1fr))',
}}
>
{children}
</div>
)
}
if (node.type === 'HeaderContent') {
return (
<div
className={String(node.props.className || '')}
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
>
{children}
</div>
)
}
if (node.type === 'Grid') { if (node.type === 'Grid') {
return ( return (
<div <div
@ -1299,6 +1283,13 @@ const renderElement = (
key={String(option.value ?? index)} key={String(option.value ?? index)}
eventKey={String(option.value ?? index)} eventKey={String(option.value ?? index)}
> >
{/* An item without an icon keeps its plain layout, so a menu that
never had icons is left exactly as it was. */}
{typeof option.icon === 'string' && option.icon ? (
<span className="mr-2 inline-flex items-center">
<PlatformIcon name={option.icon} />
</span>
) : null}
{String(option.label ?? option.value ?? `#${index + 1}`)} {String(option.label ?? option.value ?? `#${index + 1}`)}
</UiKit.Menu.MenuItem> </UiKit.Menu.MenuItem>
))} ))}
@ -1363,7 +1354,22 @@ const renderElement = (
if (node.type === 'checkbox') { if (node.type === 'checkbox') {
return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" /> return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" />
} }
if (node.kind === 'html') return React.createElement(node.type, props, content) if (node.kind === 'html') {
// An empty element paints nothing, so on the canvas it would look like the
// drop never happened. The tag name stands in for it until it is given text
// or a child, and it is a designer only hint: the generated page never sees it.
const isEmpty = !hasOwnContent && React.Children.count(children) === 0
if (interactive && isEmpty && HTML_PLACEHOLDER_ELEMENTS.has(node.type)) {
return React.createElement(
node.type,
props,
// No size of its own: the placeholder inherits the element typography, so
// an h1 already looks like an h1 before it is given any text.
<span className="text-slate-400 dark:text-slate-500">{node.type}</span>,
)
}
return React.createElement(node.type, props, content)
}
const Component = resolveUiComponent(node.type) const Component = resolveUiComponent(node.type)
if (!Component) { if (!Component) {
@ -1376,6 +1382,20 @@ const renderElement = (
return React.createElement(Component, props, content) return React.createElement(Component, props, content)
} }
/** Elements that carry text, so an empty one can show its tag name on the canvas. */
const HTML_PLACEHOLDER_ELEMENTS = new Set([
'div',
'button',
'p',
'span',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
])
const NodeView = ({ const NodeView = ({
node, node,
index, index,
@ -1436,9 +1456,6 @@ const NodeView = ({
const canMoveDown = !isRoot || index < siblingCount - 1 const canMoveDown = !isRoot || index < siblingCount - 1
const acceptsDroppedChildren = [ const acceptsDroppedChildren = [
'PageContainer', 'PageContainer',
'TwoColumns',
'SidebarContent',
'HeaderContent',
'FlexRow', 'FlexRow',
'div', 'div',
'Card', 'Card',
@ -2317,17 +2334,14 @@ const VisualCanvas = ({
{translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')} {translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
</p> </p>
{interactive && ( {interactive && (
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
{[ {[
['PageContainer', translate('::App.VisualDesignerLayout.PageContainer'), '□'], // The component name itself is the label, so a layout is
['TwoColumns', translate('::App.VisualDesignerLayout.TwoColumns'), '▥'], // called the same here as it is in the toolbox.
[ ['PageContainer', '□'],
'SidebarContent', ['FlexRow', '▥'],
translate('::App.VisualDesignerLayout.SidebarContent'), ['Table', '▦'],
'◧', ].map(([name, icon]) => (
],
['HeaderContent', translate('::App.VisualDesignerLayout.HeaderContent'), '⊟'],
].map(([name, label, icon]) => (
<button <button
key={name} key={name}
className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300" className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
@ -2338,7 +2352,7 @@ const VisualCanvas = ({
}} }}
> >
<span className="mb-1 block text-xl text-sky-500">{icon}</span> <span className="mb-1 block text-xl text-sky-500">{icon}</span>
{label} {name}
</button> </button>
))} ))}
</div> </div>

View file

@ -70,6 +70,33 @@ const PROPERTY_OVERRIDES: Record<string, Record<string, PropertyOverride>> = {
children: { value: 'Galeri' }, children: { value: 'Galeri' },
}, },
Marquee: { gradient: { value: false }, speed: { value: 100 } }, Marquee: { gradient: { value: false }, speed: { value: 100 } },
// Stored as a name and resolved to the react-select flavour at render time; an
// empty value leaves Select on its own default.
Select: {
componentAs: {
type: 'select',
options: ['ReactSelect', 'CreatableSelect', 'AsyncSelect'],
value: '',
},
},
// Curated definition, so the DropdownPlacement union never reaches the catalog.
Dropdown: {
placement: {
type: 'select',
options: [
'top-start',
'top-center',
'top-end',
'bottom-start',
'bottom-center',
'bottom-end',
'middle-start-top',
'middle-start-bottom',
'middle-end-top',
'middle-end-bottom',
],
},
},
} }
const HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox']) const HTML_UI_DUPLICATES = new Set(['input', 'button', 'textarea', 'select', 'checkbox'])
const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = { const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
@ -499,9 +526,11 @@ const MENU_ITEMS_PROPERTY = {
const TABS_ITEMS_PROPERTY = { const TABS_ITEMS_PROPERTY = {
...SELECT_OPTIONS_PROPERTY, ...SELECT_OPTIONS_PROPERTY,
name: 'items', name: 'items',
// No content: a fresh tab is an empty container waiting for components, and
// placeholder text there would have to be deleted before anything is built.
value: [ value: [
{ label: 'Sekme 1', value: '1', content: 'Sekme 1 içeriği' }, { label: 'Sekme 1', value: '1' },
{ label: 'Sekme 2', value: '2', content: 'Sekme 2 içeriği' }, { label: 'Sekme 2', value: '2' },
], ],
description: 'App.CatalogTabs.TabsDescription', description: 'App.CatalogTabs.TabsDescription',
tsType: 'Array<{ label: string; value: unknown; content?: string }>', tsType: 'Array<{ label: string; value: unknown; content?: string }>',
@ -514,6 +543,50 @@ const getCollectionPropertyDefinition = (name: string, collectionProperty: strin
return collectionProperty === 'items' ? MENU_ITEMS_PROPERTY : SELECT_OPTIONS_PROPERTY return collectionProperty === 'items' ? MENU_ITEMS_PROPERTY : SELECT_OPTIONS_PROPERTY
} }
/**
* Inline placeholder artwork for a fresh `img`. Base64 rather than a raw SVG data
* URI, because the unescaped markup of the latter is rejected by strict URL
* parsing, and a data URI rather than a hosted file so nothing has to be fetched.
* The prop stays a plain string: any URL or data URI can replace it.
*/
const IMAGE_PLACEHOLDER_SRC =
'data:image/svg+xml;base64,' +
'PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NDAnIGhlaWdodD0nNDAwJyB2aWV3Qm94PScwIDAgNjQwIDQwMCc+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSdzJyB4MT0nMCcgeTE9JzAnIHgyPScwJyB5Mj0nMSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZGJlYWZlJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjZjhmYWZjJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9J2EnIHgxPScwJyB5MT0nMCcgeDI9JzEnIHkyPScxJz48c3RvcCBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyM5M2M1ZmQnLz48c3RvcCBvZmZzZXQ9JzEnIHN0b3AtY29sb3I9JyMzOGJkZjgnLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0nYicgeDE9JzAnIHkxPScwJyB4Mj0nMScgeTI9JzEnPjxzdG9wIG9mZnNldD0nMCcgc3RvcC1jb2xvcj0nIzBlYTVlOScvPjxzdG9wIG9mZnNldD0nMScgc3RvcC1jb2xvcj0nIzBjNGE2ZScvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSc2NDAnIGhlaWdodD0nNDAwJyBmaWxsPSd1cmwoI3MpJy8+PGNpcmNsZSBjeD0nNDk4JyBjeT0nMTA0JyByPSc0NicgZmlsbD0nI2ZjZDM0ZCcvPjxwYXRoIGQ9J00wIDQwMCBMMTY1IDE2NSBMMzMwIDQwMCBaJyBmaWxsPSd1cmwoI2EpJy8+PHBhdGggZD0nTTI2MCA0MDAgTDQ0MCAxOTUgTDYyMCA0MDAgWicgZmlsbD0ndXJsKCNhKScvPjxwYXRoIGQ9J002MCA0MDAgTDI1MCAyMzAgTDQ0MCA0MDAgWicgZmlsbD0ndXJsKCNiKScvPjwvc3ZnPg=='
/**
* Values a freshly dropped element starts with. Tailwind resets the headings, so
* without these an h1 is the size of a paragraph here and in the generated page
* alike and an empty img has nothing to show. They stay ordinary editable props.
*/
const HTML_DEFAULT_PROPS: Record<string, Record<string, unknown>> = {
h1: { className: 'text-4xl font-bold' },
h2: { className: 'text-3xl font-bold' },
h3: { className: 'text-2xl font-semibold' },
h4: { className: 'text-xl font-semibold' },
h5: { className: 'text-lg font-semibold' },
h6: { className: 'text-base font-semibold' },
img: {
src: IMAGE_PLACEHOLDER_SRC,
alt: 'App.CatalogImg.ImgPlaceholderAlt',
className: 'max-w-full rounded-md',
},
}
/**
* A prop with a known value set is picked from a list, never typed. Curated
* definitions and generated metadata both reach the panel through here, so a
* component gets the same treatment whichever of the two describes it.
*/
const withPickableOptions = <T extends { name: string; type: string; options?: string[] }>(
componentName: string,
property: T,
): T => {
const override = PROPERTY_OVERRIDES[componentName]?.[property.name]
const options = override?.options || property.options
if (!options || property.type === 'select') return property
return { ...property, type: 'select', options } as T
}
const normalizeDefinition = ( const normalizeDefinition = (
definition: (typeof CUSTOM_COMPONENTS)[number], definition: (typeof CUSTOM_COMPONENTS)[number],
kind: DesignerNodeKind, kind: DesignerNodeKind,
@ -547,6 +620,19 @@ const normalizeDefinition = (
] ]
} }
properties = properties
.filter((property) => !HIDDEN_GENERATED_PROPERTIES.has(property.name))
.map((property) => withPickableOptions(definition.name, property))
const defaultProps = HTML_DEFAULT_PROPS[definition.name]
if (defaultProps) {
properties = properties.map((property) =>
defaultProps[property.name] !== undefined && !property.value
? { ...property, value: defaultProps[property.name] }
: property,
)
}
return { return {
...definition, ...definition,
properties, properties,
@ -591,15 +677,77 @@ const inferUiCategory = (name: string) => {
return 'display' return 'display'
} }
/**
* Unions that the generated metadata carries only as a type name, so the value
* arrives as free text instead of a list. Naming the members here is what turns
* such a prop into a picker; keyed by the tsType, so one entry covers every
* component using it.
*/
const NAMED_UNION_OPTIONS: Record<string, string[]> = {
TabsVariant: ['underline', 'pill'],
// `ElementType` is every tag React knows; these are the ones a designer would
// reasonably render a component as.
ElementType: ['div', 'span', 'p', 'a', 'button', 'label', 'section', 'article'],
StepStatus: ['complete', 'pending', 'in-progress', 'error'],
StrokeLinecap: ['round', 'square'],
GapPosition: ['top', 'right', 'bottom', 'left'],
ArrowPlacement: [
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'right',
'right-start',
'right-end',
'left',
'left-start',
'left-end',
],
HTMLInputTypeAttribute: [
'text',
'password',
'email',
'number',
'tel',
'url',
'search',
'date',
'time',
'datetime-local',
'month',
'week',
'color',
'file',
],
}
/**
* Props that exist for code, not for the panel: `field` and `form` are the Formik
* objects a component is handed at runtime, so no value typed here could work.
*/
const HIDDEN_GENERATED_PROPERTIES = new Set(['field', 'form'])
const generatedPropertiesToDesigner = ( const generatedPropertiesToDesigner = (
generated: GeneratedComponentMetadata, generated: GeneratedComponentMetadata,
existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(), existingProperties = new Map<string, (typeof CUSTOM_COMPONENTS)[number]['properties'][number]>(),
componentName = '', componentName = '',
) => ) =>
generated.properties.map((property) => { generated.properties
.filter((property) => !HIDDEN_GENERATED_PROPERTIES.has(property.name))
.map((property) => {
const existing = existingProperties.get(property.name) const existing = existingProperties.get(property.name)
const override = PROPERTY_OVERRIDES[componentName]?.[property.name] const override = PROPERTY_OVERRIDES[componentName]?.[property.name]
const type = override?.type || property.type const options = override?.type
? override.options
: override?.options ||
property.options ||
existing?.options ||
NAMED_UNION_OPTIONS[property.tsType]
// A prop with a known set of values is picked from a list, never typed: the
// generated metadata often keeps the union as a plain string.
const type = override?.type || (options && property.type === 'string' ? 'select' : property.type)
return { return {
name: property.name, name: property.name,
type, type,
@ -611,9 +759,7 @@ const generatedPropertiesToDesigner = (
: existing?.value !== undefined : existing?.value !== undefined
? existing.value ? existing.value
: fallbackValue(type), : fallbackValue(type),
options: override?.type options,
? override.options
: override?.options || property.options || existing?.options,
description: property.tsType, description: property.tsType,
category: category:
property.type === 'function' property.type === 'function'
@ -682,11 +828,18 @@ const normalizeUiDefinition = (
...generatedProperties, ...generatedProperties,
// Keep explicitly curated inherited React props such as Input.value and placeholder. // Keep explicitly curated inherited React props such as Input.value and placeholder.
...curatedProperties.filter((property) => !generatedPropertyNames.has(property.name)), ...curatedProperties.filter((property) => !generatedPropertyNames.has(property.name)),
].filter( ]
.filter(
(property) => (property) =>
!(definition.name === 'Checkbox' && property.name === 'defaultChecked') && !(definition.name === 'Checkbox' && property.name === 'defaultChecked') &&
!(definition.name === 'Menu' && property.name === 'variant'), !(definition.name === 'Menu' && property.name === 'variant') &&
// A curated prop can be code-only too, and it never reaches the generated
// filter above.
!HIDDEN_GENERATED_PROPERTIES.has(property.name),
) )
// A curated prop carries no tsType, so its value set arrives either as its own
// options or through an override; both become a picker here.
.map((property) => withPickableOptions(definition.name, property))
const primaryEvents = DESIGNER_PRIMARY_EVENTS[definition.name] const primaryEvents = DESIGNER_PRIMARY_EVENTS[definition.name]
return { return {
...definition, ...definition,
@ -898,49 +1051,6 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
], ],
hooks: [], hooks: [],
}, },
{
name: 'TwoColumns',
icon: 'Columns2',
category: 'layout',
kind: 'layout',
toolboxGroup: 'layout',
description: 'App.CatalogTwoColumns.TwoColumnsDescription',
acceptsChildren: true,
properties: [
{ name: 'gap', type: 'number', value: 16, category: 'styling' },
{ name: 'className', type: 'string', value: '', category: 'styling' },
],
hooks: [],
},
{
name: 'SidebarContent',
icon: 'PanelLeft',
category: 'layout',
kind: 'layout',
toolboxGroup: 'layout',
description: 'App.CatalogSidebarContent.SidebarContentDescription',
acceptsChildren: true,
properties: [
{ name: 'sidebarWidth', type: 'string', value: '280px', category: 'styling' },
{ name: 'gap', type: 'number', value: 16, category: 'styling' },
{ name: 'className', type: 'string', value: '', category: 'styling' },
],
hooks: [],
},
{
name: 'HeaderContent',
icon: 'PanelTop',
category: 'layout',
kind: 'layout',
toolboxGroup: 'layout',
description: 'App.CatalogHeaderContent.HeaderContentDescription',
acceptsChildren: true,
properties: [
{ name: 'gap', type: 'number', value: 16, category: 'styling' },
{ name: 'className', type: 'string', value: '', category: 'styling' },
],
hooks: [],
},
{ {
name: 'FlexRow', name: 'FlexRow',
icon: 'Columns3', icon: 'Columns3',
@ -950,6 +1060,12 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
description: 'App.CatalogFlexRow.FlexRowDescription', description: 'App.CatalogFlexRow.FlexRowDescription',
acceptsChildren: true, acceptsChildren: true,
properties: [ properties: [
// 1 keeps the free flowing flex row; 2 or more turns the row into that many
// equal grid columns, which is what a two column or sidebar page needs.
{ name: 'columns', type: 'number', value: 1, category: 'styling' },
// Only read in column mode: a width here pins the first column instead of
// sharing the row equally, which is what makes a sidebar layout.
{ name: 'firstColumnWidth', type: 'string', value: '', category: 'styling' },
{ name: 'gap', type: 'number', value: 16, category: 'styling' }, { name: 'gap', type: 'number', value: 16, category: 'styling' },
{ name: 'wrap', type: 'boolean', value: true, category: 'styling' }, { name: 'wrap', type: 'boolean', value: true, category: 'styling' },
{ {

View file

@ -19,6 +19,9 @@ import {
isDesignerDateOnlyComponent, isDesignerDateOnlyComponent,
isDesignerDateProperty, isDesignerDateProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle,
isSqlDataSourceNode, isSqlDataSourceNode,
isValuelessDesignerFilter, isValuelessDesignerFilter,
DESIGNER_FILTER_DX_OPERATORS, DESIGNER_FILTER_DX_OPERATORS,
@ -234,11 +237,21 @@ const propsToCode = (
// node happens to carry from the toolbox defaults. // node happens to carry from the toolbox defaults.
!(formField && (key === 'readOnly' || key === 'disabled')) && !(formField && (key === 'readOnly' || key === 'disabled')) &&
!node.bindings?.[key]?.sourceId && !node.bindings?.[key]?.sourceId &&
// A zero width or height is the toolbox default, not a request for a zero
// sized element - emitting it would collapse the image to nothing.
!((key === 'width' || key === 'height') && value === 0) &&
value !== '' && value !== '' &&
value !== undefined, value !== undefined,
) )
.map(([key, value]) => .map(([key, value]) =>
// Dates live in the document as ISO strings; pickers need Date instances. // An icon is stored as a name; PlatformIcon turns it back into an element at
// runtime and renders nothing for an unknown one.
key === DESIGNER_SELECT_COMPONENT_PROPERTY && typeof value === 'string'
? // The name is resolved at runtime, where the react-select flavours live.
`${key}={PlatformSelectComponent(${JSON.stringify(value)})}`
: DESIGNER_ICON_PROPERTIES.has(key) && typeof value === 'string'
? `${key}={<PlatformIcon name=${JSON.stringify(value)} />}`
: // Dates live in the document as ISO strings; pickers need Date instances.
isDesignerDateProperty(node.type, key) isDesignerDateProperty(node.type, key)
? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}` ? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}`
: `${key}=${serializeValue(value)}`, : `${key}=${serializeValue(value)}`,
@ -828,7 +841,7 @@ const renderNodeToCode = (
} }
if (node.type === 'FlexRow') { if (node.type === 'FlexRow') {
const style = `{ display: "flex", gap: ${Number(node.props.gap) || 0}, flexWrap: "${node.props.wrap ? 'wrap' : 'nowrap'}", alignItems: "${String(node.props.align || 'stretch')}" }` const style = JSON.stringify(flexRowStyle(node.props))
const className = node.props.className const className = node.props.className
? ` className=${JSON.stringify(node.props.className)}` ? ` className=${JSON.stringify(node.props.className)}`
: '' : ''
@ -838,20 +851,10 @@ const renderNodeToCode = (
return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}` return `${indent(`<div${className} style={${style}}>`, level)}\n${children}\n${indent('</div>', level)}`
} }
if ( if (node.type === 'PageContainer') {
node.type === 'PageContainer' ||
node.type === 'TwoColumns' ||
node.type === 'SidebarContent' ||
node.type === 'HeaderContent'
) {
const gap = Number(node.props.gap) || 0 const gap = Number(node.props.gap) || 0
const className = JSON.stringify(String(node.props.className || '')) const className = JSON.stringify(String(node.props.className || ''))
const style = const style = `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }`
node.type === 'PageContainer'
? `{ display: "flex", flexDirection: "column", gap: ${gap}, margin: "0 auto", maxWidth: ${JSON.stringify(String(node.props.maxWidth || '1280px'))}, padding: ${Number(node.props.padding) || 0}, width: "100%" }`
: node.type === 'HeaderContent'
? `{ display: "flex", flexDirection: "column", gap: ${gap} }`
: `{ display: "grid", gap: ${gap}, gridTemplateColumns: ${JSON.stringify(node.type === 'SidebarContent' ? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)` : 'repeat(2, minmax(0, 1fr))')} }`
const children = node.children const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .map((child) => nodeToCode(child, level + 1, itemVariable, formScope))
.join('\n') .join('\n')
@ -1048,7 +1051,7 @@ ${indent(')}', level)}`
menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys) menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope) const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable) const optionsExpression = optionCollectionExpression(node, itemVariable)
return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}` return `${indent(`<UiKit.Menu${menuPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Menu.MenuItem key={String(option.value ?? optionIndex)} eventKey={String(option.value ?? optionIndex)}>{option.icon ? <span className="mr-2 inline-flex items-center"><PlatformIcon name={String(option.icon)} /></span> : null}{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)}</UiKit.Menu.MenuItem>)}`, level + 1)}\n${indent('</UiKit.Menu>', level)}`
} }
if (node.type === 'Dropdown') { if (node.type === 'Dropdown') {

View file

@ -764,6 +764,40 @@ export const SQL_DATA_SOURCE_TYPE = 'Form'
*/ */
export const LEGACY_SQL_DATA_SOURCE_TYPE = 'SqlDataSource' export const LEGACY_SQL_DATA_SOURCE_TYPE = 'SqlDataSource'
/**
* Layout of a FlexRow. A single column is a plain flex row - items keep their own
* width and may wrap. Two or more switch to a grid so the columns really line up,
* and a `firstColumnWidth` pins the first one, which is how a sidebar is built.
*/
export const flexRowStyle = (props: Record<string, unknown>) => {
const gap = Number(props.gap) || 0
const alignItems = String(props.align || 'stretch')
const columns = Math.min(12, Math.max(1, Math.floor(Number(props.columns) || 1)))
if (columns < 2) {
return {
display: 'flex' as const,
gap,
flexWrap: (props.wrap ? 'wrap' : 'nowrap') as 'wrap' | 'nowrap',
alignItems,
}
}
const firstColumnWidth = String(props.firstColumnWidth || '').trim()
return {
display: 'grid' as const,
gap,
alignItems,
gridTemplateColumns: firstColumnWidth
? `${firstColumnWidth} repeat(${columns - 1}, minmax(0, 1fr))`
: `repeat(${columns}, minmax(0, 1fr))`,
}
}
/** `Select.componentAs` holds the name of a react-select flavour. */
export const DESIGNER_SELECT_COMPONENT_PROPERTY = 'componentAs'
/** Props whose stored value is an icon name rather than an element. */
export const DESIGNER_ICON_PROPERTIES = new Set(['icon', 'customIcon'])
export const isSqlDataSourceNode = (type?: string) => export const isSqlDataSourceNode = (type?: string) =>
type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE

View file

@ -78,6 +78,7 @@ import {
getSqlRecordProperty, getSqlRecordProperty,
isDesignerDateComponent, isDesignerDateComponent,
isDesignerDateOnlyComponent, isDesignerDateOnlyComponent,
DESIGNER_ICON_PROPERTIES,
isDesignerOptionComponent, isDesignerOptionComponent,
isDesignerTabularComponent, isDesignerTabularComponent,
isSqlDataSourceNode, isSqlDataSourceNode,
@ -115,6 +116,7 @@ import {
type DesignerPropertyInfo, type DesignerPropertyInfo,
type SqlFormValueProperty, type SqlFormValueProperty,
} from '@/components/visualDesigner/types' } from '@/components/visualDesigner/types'
import { IconPickerField } from '@/views/shared/MenuAddDialog'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript' type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
@ -122,7 +124,7 @@ type InspectorTab = 'properties' | 'data' | 'tree'
type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string } type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string }
type EndpointResultModal = { source: DesignerDataSource; result: unknown } type EndpointResultModal = { source: DesignerDataSource; result: unknown }
type DataField = { path: string; type: string; preview: string } type DataField = { path: string; type: string; preview: string }
type StaticSelectOption = { label: string; value: unknown } type StaticSelectOption = { label: string; value: unknown; content?: string; icon?: string }
type DataSourceCatalogItem = { type DataSourceCatalogItem = {
attached: boolean attached: boolean
key: string key: string
@ -357,6 +359,12 @@ const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [
'custom', 'custom',
] ]
/**
* The layout group reads outside in - page, row, table - instead of alphabetically,
* because that is the order a page is actually built in.
*/
const LAYOUT_TOOLBOX_ORDER = ['PageContainer', 'FlexRow', 'Table']
const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = { const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
responsive: '100%', responsive: '100%',
desktop: '1180px', desktop: '1180px',
@ -575,14 +583,39 @@ const reorderNodeTree = (
return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement) return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement)
} }
const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode => { /**
* Default values that ship as localization keys - option labels, menu captions -
* so a new component starts with readable text instead of `App.Catalog...`. The
* resolved text is what gets stored, because from that point on it is ordinary
* user content the designer edits like any other value.
*/
const localizeDesignerDefaults = (value: unknown, translate: TranslateFn): unknown => {
if (typeof value === 'string') {
return value.startsWith('App.') ? translate('::' + value) : value
}
if (Array.isArray(value)) return value.map((item) => localizeDesignerDefaults(item, translate))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
localizeDesignerDefaults(item, translate),
]),
)
}
return value
}
const definitionToNode = (
definition: DesignerComponentDefinition,
translate: TranslateFn,
): DesignerNode => {
const props: Record<string, unknown> = {} const props: Record<string, unknown> = {}
const events: Record<string, string> = {} const events: Record<string, string> = {}
definition.properties.forEach((property) => { definition.properties.forEach((property) => {
if (property.category === 'events' || property.type === 'function') { if (property.category === 'events' || property.type === 'function') {
events[property.name] = typeof property.value === 'string' ? property.value : '' events[property.name] = typeof property.value === 'string' ? property.value : ''
} else { } else {
props[property.name] = clone(property.value) props[property.name] = localizeDesignerDefaults(clone(property.value), translate)
} }
}) })
return { return {
@ -596,6 +629,18 @@ const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode
} }
} }
/**
* Node types that were renamed or merged away. Rewriting them on load is what
* makes an existing page show its current type everywhere it names itself - the
* toolbox, the layer tree and the inspector all read it.
*/
const LEGACY_NODE_TYPES: Record<string, string> = {
[LEGACY_SQL_DATA_SOURCE_TYPE]: SQL_DATA_SOURCE_TYPE,
TwoColumns: 'FlexRow',
SidebarContent: 'FlexRow',
HeaderContent: 'PageContainer',
}
const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => { const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => {
const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] => const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => { nodes.map((node) => {
@ -629,6 +674,20 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument
delete props.variant delete props.variant
delete bindings.options delete bindings.options
} }
// The two column, sidebar and header layouts were folded away: a column
// count and an optional pinned first column turn FlexRow into the first
// two, and the header stack is a PageContainer without the page framing.
if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
props.columns = 2
if (node.type === 'SidebarContent') {
props.firstColumnWidth = String(props.firstColumnWidth || props.sidebarWidth || '280px')
}
delete props.sidebarWidth
}
if (node.type === 'HeaderContent') {
if (props.maxWidth === undefined) props.maxWidth = '100%'
if (props.padding === undefined) props.padding = 0
}
if (node.type === 'Table') { if (node.type === 'Table') {
const legacyRows = Array.isArray(props.items) const legacyRows = Array.isArray(props.items)
? props.items ? props.items
@ -664,10 +723,7 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument
} }
return { return {
...node, ...node,
// The container used to be called SqlDataSource. Renaming it on load is type: LEGACY_NODE_TYPES[node.type] || node.type,
// what makes an existing page show `Form` everywhere it names its own
// type — the toolbox, the layer tree and the inspector all read it.
type: node.type === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : node.type,
props, props,
events, events,
bindings, bindings,
@ -1123,6 +1179,11 @@ const PropertyEditor = ({
</select> </select>
) )
} }
// An icon prop holds a name; the picker is what makes that name findable and
// shows what it looks like, exactly as the menu editor does.
if (DESIGNER_ICON_PROPERTIES.has(name) && type === 'string') {
return <IconPickerField value={String(value ?? '')} onChange={onChange} />
}
const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`] const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`]
if (objectListSchema) { if (objectListSchema) {
return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} /> return <ObjectListEditor schema={objectListSchema} value={value} onChange={onChange} />
@ -1428,7 +1489,7 @@ const VisualComponentDesigner = () => {
(definitionName: string, parentId: string | null = null, slot?: string) => { (definitionName: string, parentId: string | null = null, slot?: string) => {
const definition = catalogByName.get(definitionName) const definition = catalogByName.get(definitionName)
if (!definition) return if (!definition) return
const node = definitionToNode(definition) const node = definitionToNode(definition, translate)
if (slot) node.slot = slot if (slot) node.slot = slot
commitDocument((current) => ({ commitDocument((current) => ({
...current, ...current,
@ -1449,7 +1510,7 @@ const VisualComponentDesigner = () => {
(definitionName: string, targetId: string, placement: 'before' | 'after') => { (definitionName: string, targetId: string, placement: 'before' | 'after') => {
const definition = catalogByName.get(definitionName) const definition = catalogByName.get(definitionName)
if (!definition) return if (!definition) return
const node = definitionToNode(definition) const node = definitionToNode(definition, translate)
commitDocument((current) => { commitDocument((current) => {
// Table cells and tab panes address their children by slot, so the new // Table cells and tab panes address their children by slot, so the new
// node has to join the one it was dropped next to. // node has to join the one it was dropped next to.
@ -2687,9 +2748,18 @@ const VisualComponentDesigner = () => {
(group) => (group) =>
[ [
group, group,
[...(groups.get(group) || [])].sort((left, right) => [...(groups.get(group) || [])].sort((left, right) => {
left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' }), if (group === 'layout') {
), const leftIndex = LAYOUT_TOOLBOX_ORDER.indexOf(left.name)
const rightIndex = LAYOUT_TOOLBOX_ORDER.indexOf(right.name)
if (leftIndex !== rightIndex) {
if (leftIndex < 0) return 1
if (rightIndex < 0) return -1
return leftIndex - rightIndex
}
}
return left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' })
}),
] as const, ] as const,
).filter(([, definitions]) => definitions.length > 0) ).filter(([, definitions]) => definitions.length > 0)
}, [filteredCatalog]) }, [filteredCatalog])
@ -2779,6 +2849,12 @@ const VisualComponentDesigner = () => {
translate('::App.DeveloperKitComponentDesigner.OptionN', { index: index + 1 }), translate('::App.DeveloperKitComponentDesigner.OptionN', { index: index + 1 }),
), ),
value: record.value ?? record.eventKey ?? record.key ?? index + 1, value: record.value ?? record.eventKey ?? record.key ?? index + 1,
// Only a tab carries its own text; keeping it here is what lets the
// row editor change or clear it instead of silently dropping it.
content: typeof record.content === 'string' ? record.content : undefined,
// Same for a menu item icon: it is a name, and an empty one means
// the item simply has no icon.
icon: typeof record.icon === 'string' ? record.icon : undefined,
} }
} }
return { label: String(option ?? ''), value: option ?? index + 1 } return { label: String(option ?? ''), value: option ?? index + 1 }
@ -3316,7 +3392,8 @@ const VisualComponentDesigner = () => {
{translate('::App.DeveloperKitComponentDesigner.StaticOptions')} {translate('::App.DeveloperKitComponentDesigner.StaticOptions')}
</div> </div>
{staticSelectOptions.map((option, index) => ( {staticSelectOptions.map((option, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-1"> <div key={index} className="space-y-1">
<div className="grid grid-cols-[1fr_1fr_auto] gap-1">
<input <input
aria-label={translate('::App.DeveloperKitComponentDesigner.OptionLabelAria', { aria-label={translate('::App.DeveloperKitComponentDesigner.OptionLabelAria', {
index: index + 1, index: index + 1,
@ -3362,6 +3439,37 @@ const VisualComponentDesigner = () => {
<FaTrash /> <FaTrash />
</button> </button>
</div> </div>
{selectedNode?.type === 'Menu' && (
<IconPickerField
value={option.icon ?? ''}
onChange={(iconKey) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, icon: iconKey } : current,
),
)
}
/>
)}
{selectedNode?.type === 'Tabs' && (
<input
className="w-full min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.TabContentPlaceholder',
)}
value={option.content ?? ''}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index
? { ...current, content: event.target.value }
: current,
),
)
}
/>
)}
</div>
))} ))}
<button <button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700" className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
@ -5725,7 +5833,7 @@ const VisualComponentDesigner = () => {
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800"> <div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-white"> <h2 className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Script API yapabilecekleriniz <FaBolt className="text-amber-500" /> Script API
<span className="shrink-0 rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300"> <span className="shrink-0 rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300">
{translate('::App.DeveloperKitComponentDesigner.ExampleCount', { {translate('::App.DeveloperKitComponentDesigner.ExampleCount', {
count: DESIGNER_SCRIPT_RECIPES.length, count: DESIGNER_SCRIPT_RECIPES.length,

View file

@ -90,8 +90,13 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
/> />
</Button> </Button>
{/*
Panel genişliği tetikleyiciyi takip eder: sabit 360px, Visual Designer'ın dar
property kolonunda yatay taşmaya ve ikonların sıkışmasına yol ıyordu. Alt
sınır okunabilirliği, üst sınır geniş dialogdaki eski görünümü korur.
*/}
{open && ( {open && (
<div className="absolute z-50 mt-1 left-0 w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl"> <div className="absolute z-50 mt-1 left-0 w-full min-w-[220px] max-w-[min(360px,calc(100vw-2rem))] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl">
<div className="p-2 border-b border-gray-100 dark:border-gray-700 flex items-center gap-2"> <div className="p-2 border-b border-gray-100 dark:border-gray-700 flex items-center gap-2">
<input <input
autoFocus autoFocus
@ -102,7 +107,7 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
/> />
<span className="text-xs text-gray-400 shrink-0">{filtered.length} icons</span> <span className="text-xs text-gray-400 shrink-0">{filtered.length} icons</span>
</div> </div>
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[208px] overflow-y-auto"> <div className="grid grid-cols-[repeat(auto-fill,minmax(2rem,1fr))] gap-0.5 p-2 max-h-[208px] overflow-y-auto">
{displayed.map(([key, Icon]) => ( {displayed.map(([key, Icon]) => (
<Button <Button
key={key} key={key}