diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index d41330a1..5f3698fe 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -20556,6 +20556,12 @@ "en": "Static options", "tr": "Statik seçenekler" }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitComponentDesigner.TabContentPlaceholder", + "en": "Tab content", + "tr": "Sekme içeriği" + }, { "resourceName": "Platform", "key": "App.DeveloperKitComponentDesigner.StaticRowsHint", @@ -24909,8 +24915,8 @@ { "resourceName": "Platform", "key": "App.CatalogFlexRow.FlexRowDescription", - "en": "Horizontal and responsive layout", - "tr": "Yatay ve responsive yerleşim" + "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; kolon sayısı eşit kolonlara böler, ilk kolon genişliği verilirse sidebar olur" }, { "resourceName": "Platform", @@ -24942,18 +24948,18 @@ "en": "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", "key": "App.CatalogHoverable.HoverableDescription", "en": "Shows a highlight color when hovering over rows", "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", "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.", "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", "key": "App.CatalogSpacer.SpacerDescription", @@ -25188,12 +25188,6 @@ "en": "Sözsoft dynamic tree view", "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", "key": "App.VisualDesignerConditionKind.ComponentValue", @@ -25746,30 +25740,6 @@ "en": "Enables the Delete button.", "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", "key": "App.SnippetApiCall.ApiCallDescription2", diff --git a/configs/seeds/host/custom/NewComponent.json b/configs/seeds/host/custom/NewComponent.json new file mode 100644 index 00000000..c48c9718 --- /dev/null +++ b/configs/seeds/host/custom/NewComponent.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/ui/src/components/codeLayout/data/componentDefinitions.ts b/ui/src/components/codeLayout/data/componentDefinitions.ts index 3f2fbe5c..7780038c 100644 --- a/ui/src/components/codeLayout/data/componentDefinitions.ts +++ b/ui/src/components/codeLayout/data/componentDefinitions.ts @@ -1336,19 +1336,19 @@ export const CUSTOM_COMPONENTS: ComponentDefinition[] = [ { key: 'tab1', label: 'Sekme 1', - children: 'İlk sekme içeriği', + children: '', disabled: false, }, { key: 'tab2', label: 'Sekme 2', - children: 'İkinci sekme içeriği', + children: '', disabled: false, }, { key: 'tab3', label: 'Sekme 3', - children: 'Üçüncü sekme içeriği', + children: '', disabled: false, }, ], diff --git a/ui/src/components/componentEditor/DynamicRenderer.tsx b/ui/src/components/componentEditor/DynamicRenderer.tsx index 30619345..1bc82db9 100644 --- a/ui/src/components/componentEditor/DynamicRenderer.tsx +++ b/ui/src/components/componentEditor/DynamicRenderer.tsx @@ -11,6 +11,8 @@ import { } from '@/contexts/componentRuntime' import { useComponents } from '../../contexts/ComponentContext' import ErrorBoundary from './ErrorBoundary' +import PlatformIcon from './PlatformIcon' +import PlatformSelectComponent from './selectComponents' import PlatformViewHost from './PlatformViewHost' import { toast } from '../ui' import { Loading } from '../shared' @@ -49,6 +51,12 @@ const compileComponent = ( // code without an extra per-component entry here. const staticComponents: Record = { ...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, toast, apiService, diff --git a/ui/src/components/componentEditor/PlatformIcon.tsx b/ui/src/components/componentEditor/PlatformIcon.tsx new file mode 100644 index 00000000..1939305a --- /dev/null +++ b/ui/src/components/componentEditor/PlatformIcon.tsx @@ -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 ? : null +} + +export default PlatformIcon diff --git a/ui/src/components/componentEditor/selectComponents.ts b/ui/src/components/componentEditor/selectComponents.ts new file mode 100644 index 00000000..47fa26f9 --- /dev/null +++ b/ui/src/components/componentEditor/selectComponents.ts @@ -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> = { + ReactSelect: ReactSelect as unknown as ComponentType, + CreatableSelect: CreatableSelect as unknown as ComponentType, + AsyncSelect: AsyncSelect as unknown as ComponentType, +} + +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 diff --git a/ui/src/components/visualDesigner/VisualCanvas.tsx b/ui/src/components/visualDesigner/VisualCanvas.tsx index 4e503167..66b5c8da 100644 --- a/ui/src/components/visualDesigner/VisualCanvas.tsx +++ b/ui/src/components/visualDesigner/VisualCanvas.tsx @@ -1,5 +1,7 @@ import React from 'react' import * as UiKit from '@/components/ui' +import PlatformIcon from '@/components/componentEditor/PlatformIcon' +import PlatformSelectComponent from '@/components/componentEditor/selectComponents' import PlatformViewHost, { type PlatformViewName, } from '@/components/componentEditor/PlatformViewHost' @@ -28,6 +30,9 @@ import { isDesignerDateComponent, isDesignerDateProperty, isDesignerOptionComponent, + DESIGNER_ICON_PROPERTIES, + DESIGNER_SELECT_COMPONENT_PROPERTY, + flexRowStyle, isSqlDataSourceNode, LEGACY_SQL_DATA_SOURCE_TYPE, normalizeDesignerKeyList, @@ -498,7 +503,21 @@ const getPreviewProps = ( // of being rendered. if (key === SQL_DEFAULT_VALUE_PROP) 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 + // 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] = + return + } props[key] = resolveStaticLanguageKeys(value, translate) }) Object.entries(node.events).forEach(([name, script]) => { @@ -1176,15 +1195,7 @@ const renderElement = ( } if (node.type === 'FlexRow') { return ( -
+
{children}
) @@ -1207,33 +1218,6 @@ const renderElement = (
) } - if (node.type === 'TwoColumns' || node.type === 'SidebarContent') { - return ( -
- {children} -
- ) - } - if (node.type === 'HeaderContent') { - return ( -
- {children} -
- ) - } if (node.type === 'Grid') { return (
+ {/* 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 ? ( + + + + ) : null} {String(option.label ?? option.value ?? `#${index + 1}`)} ))} @@ -1363,7 +1354,22 @@ const renderElement = ( if (node.type === 'checkbox') { return )} 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. + {node.type}, + ) + } + return React.createElement(node.type, props, content) + } const Component = resolveUiComponent(node.type) if (!Component) { @@ -1376,6 +1382,20 @@ const renderElement = ( 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 = ({ node, index, @@ -1436,9 +1456,6 @@ const NodeView = ({ const canMoveDown = !isRoot || index < siblingCount - 1 const acceptsDroppedChildren = [ 'PageContainer', - 'TwoColumns', - 'SidebarContent', - 'HeaderContent', 'FlexRow', 'div', 'Card', @@ -2317,17 +2334,14 @@ const VisualCanvas = ({ {translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}

{interactive && ( -
+
{[ - ['PageContainer', translate('::App.VisualDesignerLayout.PageContainer'), '□'], - ['TwoColumns', translate('::App.VisualDesignerLayout.TwoColumns'), '▥'], - [ - 'SidebarContent', - translate('::App.VisualDesignerLayout.SidebarContent'), - '◧', - ], - ['HeaderContent', translate('::App.VisualDesignerLayout.HeaderContent'), '⊟'], - ].map(([name, label, icon]) => ( + // The component name itself is the label, so a layout is + // called the same here as it is in the toolbox. + ['PageContainer', '□'], + ['FlexRow', '▥'], + ['Table', '▦'], + ].map(([name, icon]) => ( ))}
diff --git a/ui/src/components/visualDesigner/catalog.ts b/ui/src/components/visualDesigner/catalog.ts index 070282ae..2ff4670c 100644 --- a/ui/src/components/visualDesigner/catalog.ts +++ b/ui/src/components/visualDesigner/catalog.ts @@ -70,6 +70,33 @@ const PROPERTY_OVERRIDES: Record> = { children: { value: 'Galeri' }, }, 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 UI_PROPERTY_ALIASES: Record> = { @@ -499,9 +526,11 @@ const MENU_ITEMS_PROPERTY = { const TABS_ITEMS_PROPERTY = { ...SELECT_OPTIONS_PROPERTY, 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: [ - { label: 'Sekme 1', value: '1', content: 'Sekme 1 içeriği' }, - { label: 'Sekme 2', value: '2', content: 'Sekme 2 içeriği' }, + { label: 'Sekme 1', value: '1' }, + { label: 'Sekme 2', value: '2' }, ], description: 'App.CatalogTabs.TabsDescription', 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 } +/** + * 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> = { + 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 = ( + 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 = ( definition: (typeof CUSTOM_COMPONENTS)[number], 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 { ...definition, properties, @@ -591,41 +677,101 @@ const inferUiCategory = (name: string) => { 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 = { + 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 = ( generated: GeneratedComponentMetadata, existingProperties = new Map(), componentName = '', ) => - generated.properties.map((property) => { - const existing = existingProperties.get(property.name) - const override = PROPERTY_OVERRIDES[componentName]?.[property.name] - const type = override?.type || property.type - return { - name: property.name, - type, - value: - override && 'value' in override - ? override.value - : property.defaultValue !== undefined - ? property.defaultValue - : existing?.value !== undefined - ? existing.value - : fallbackValue(type), - options: override?.type + generated.properties + .filter((property) => !HIDDEN_GENERATED_PROPERTIES.has(property.name)) + .map((property) => { + const existing = existingProperties.get(property.name) + const override = PROPERTY_OVERRIDES[componentName]?.[property.name] + const options = override?.type ? override.options - : override?.options || property.options || existing?.options, - description: property.tsType, - category: - property.type === 'function' - ? ('events' as const) - : existing?.category || - (property.name === 'className' || property.name === 'style' - ? ('styling' as const) - : ('properties' as const)), - tsType: property.tsType, - required: property.required, - } - }) + : 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 { + name: property.name, + type, + value: + override && 'value' in override + ? override.value + : property.defaultValue !== undefined + ? property.defaultValue + : existing?.value !== undefined + ? existing.value + : fallbackValue(type), + options, + description: property.tsType, + category: + property.type === 'function' + ? ('events' as const) + : existing?.category || + (property.name === 'className' || property.name === 'style' + ? ('styling' as const) + : ('properties' as const)), + tsType: property.tsType, + required: property.required, + } + }) const normalizeUiDefinition = ( definition: (typeof CUSTOM_COMPONENTS)[number], @@ -682,11 +828,18 @@ const normalizeUiDefinition = ( ...generatedProperties, // Keep explicitly curated inherited React props such as Input.value and placeholder. ...curatedProperties.filter((property) => !generatedPropertyNames.has(property.name)), - ].filter( - (property) => - !(definition.name === 'Checkbox' && property.name === 'defaultChecked') && - !(definition.name === 'Menu' && property.name === 'variant'), - ) + ] + .filter( + (property) => + !(definition.name === 'Checkbox' && property.name === 'defaultChecked') && + !(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] return { ...definition, @@ -898,49 +1051,6 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [ ], 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', icon: 'Columns3', @@ -950,6 +1060,12 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [ description: 'App.CatalogFlexRow.FlexRowDescription', acceptsChildren: true, 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: 'wrap', type: 'boolean', value: true, category: 'styling' }, { diff --git a/ui/src/components/visualDesigner/codeGenerator.ts b/ui/src/components/visualDesigner/codeGenerator.ts index 23ae1356..b2f752a3 100644 --- a/ui/src/components/visualDesigner/codeGenerator.ts +++ b/ui/src/components/visualDesigner/codeGenerator.ts @@ -19,6 +19,9 @@ import { isDesignerDateOnlyComponent, isDesignerDateProperty, isDesignerOptionComponent, + DESIGNER_ICON_PROPERTIES, + DESIGNER_SELECT_COMPONENT_PROPERTY, + flexRowStyle, isSqlDataSourceNode, isValuelessDesignerFilter, DESIGNER_FILTER_DX_OPERATORS, @@ -234,12 +237,22 @@ const propsToCode = ( // node happens to carry from the toolbox defaults. !(formField && (key === 'readOnly' || key === 'disabled')) && !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 !== undefined, ) .map(([key, value]) => - // Dates live in the document as ISO strings; pickers need Date instances. - isDesignerDateProperty(node.type, key) + // 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}={}` + : // Dates live in the document as ISO strings; pickers need Date instances. + isDesignerDateProperty(node.type, key) ? `${key}={toDesignerDate(${staticValueExpression(value)}, ${isDesignerDateOnlyComponent(node.type)})}` : `${key}=${serializeValue(value)}`, ) @@ -828,7 +841,7 @@ const renderNodeToCode = ( } 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 ? ` className=${JSON.stringify(node.props.className)}` : '' @@ -838,20 +851,10 @@ const renderNodeToCode = ( return `${indent(``, level)}\n${children}\n${indent('
', level)}` } - if ( - node.type === 'PageContainer' || - node.type === 'TwoColumns' || - node.type === 'SidebarContent' || - node.type === 'HeaderContent' - ) { + if (node.type === 'PageContainer') { const gap = Number(node.props.gap) || 0 const className = JSON.stringify(String(node.props.className || '')) - const style = - 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 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%" }` const children = node.children .map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .join('\n') @@ -1048,7 +1051,7 @@ ${indent(')}', level)}` menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys) const menuPropsCode = propsToCode(menuNode, itemVariable, [], formScope) const optionsExpression = optionCollectionExpression(node, itemVariable) - return `${indent(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', level)}` + return `${indent(``, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => {option.icon ? : null}{String(option.label ?? option.value ?? \`Menü \${optionIndex + 1}\`)})}`, level + 1)}\n${indent('', level)}` } if (node.type === 'Dropdown') { diff --git a/ui/src/components/visualDesigner/types.ts b/ui/src/components/visualDesigner/types.ts index 8f6f8bf5..49db814a 100644 --- a/ui/src/components/visualDesigner/types.ts +++ b/ui/src/components/visualDesigner/types.ts @@ -764,6 +764,40 @@ export const SQL_DATA_SOURCE_TYPE = 'Form' */ 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) => { + 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) => type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE diff --git a/ui/src/views/developerKit/VisualComponentDesigner.tsx b/ui/src/views/developerKit/VisualComponentDesigner.tsx index 23a2f617..1b1af640 100644 --- a/ui/src/views/developerKit/VisualComponentDesigner.tsx +++ b/ui/src/views/developerKit/VisualComponentDesigner.tsx @@ -78,6 +78,7 @@ import { getSqlRecordProperty, isDesignerDateComponent, isDesignerDateOnlyComponent, + DESIGNER_ICON_PROPERTIES, isDesignerOptionComponent, isDesignerTabularComponent, isSqlDataSourceNode, @@ -115,6 +116,7 @@ import { type DesignerPropertyInfo, type SqlFormValueProperty, } from '@/components/visualDesigner/types' +import { IconPickerField } from '@/views/shared/MenuAddDialog' import { useLocalization } from '@/utils/hooks/useLocalization' type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript' @@ -122,7 +124,7 @@ type InspectorTab = 'properties' | 'data' | 'tree' type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string } type EndpointResultModal = { source: DesignerDataSource; result: unknown } 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 = { attached: boolean key: string @@ -357,6 +359,12 @@ const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [ '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 = { responsive: '100%', desktop: '1180px', @@ -575,14 +583,39 @@ const reorderNodeTree = ( 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).map(([key, item]) => [ + key, + localizeDesignerDefaults(item, translate), + ]), + ) + } + return value +} + +const definitionToNode = ( + definition: DesignerComponentDefinition, + translate: TranslateFn, +): DesignerNode => { const props: Record = {} const events: Record = {} definition.properties.forEach((property) => { if (property.category === 'events' || property.type === 'function') { events[property.name] = typeof property.value === 'string' ? property.value : '' } else { - props[property.name] = clone(property.value) + props[property.name] = localizeDesignerDefaults(clone(property.value), translate) } }) 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 = { + [LEGACY_SQL_DATA_SOURCE_TYPE]: SQL_DATA_SOURCE_TYPE, + TwoColumns: 'FlexRow', + SidebarContent: 'FlexRow', + HeaderContent: 'PageContainer', +} + const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => { const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] => nodes.map((node) => { @@ -629,6 +674,20 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument delete props.variant 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') { const legacyRows = Array.isArray(props.items) ? props.items @@ -664,10 +723,7 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument } return { ...node, - // The container used to be called SqlDataSource. Renaming it on load is - // 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, + type: LEGACY_NODE_TYPES[node.type] || node.type, props, events, bindings, @@ -1123,6 +1179,11 @@ const PropertyEditor = ({ ) } + // 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 + } const objectListSchema = OBJECT_LIST_PROPERTIES[`${componentType}.${name}`] if (objectListSchema) { return @@ -1428,7 +1489,7 @@ const VisualComponentDesigner = () => { (definitionName: string, parentId: string | null = null, slot?: string) => { const definition = catalogByName.get(definitionName) if (!definition) return - const node = definitionToNode(definition) + const node = definitionToNode(definition, translate) if (slot) node.slot = slot commitDocument((current) => ({ ...current, @@ -1449,7 +1510,7 @@ const VisualComponentDesigner = () => { (definitionName: string, targetId: string, placement: 'before' | 'after') => { const definition = catalogByName.get(definitionName) if (!definition) return - const node = definitionToNode(definition) + const node = definitionToNode(definition, translate) commitDocument((current) => { // Table cells and tab panes address their children by slot, so the new // node has to join the one it was dropped next to. @@ -2687,9 +2748,18 @@ const VisualComponentDesigner = () => { (group) => [ group, - [...(groups.get(group) || [])].sort((left, right) => - left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' }), - ), + [...(groups.get(group) || [])].sort((left, right) => { + 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, ).filter(([, definitions]) => definitions.length > 0) }, [filteredCatalog]) @@ -2779,6 +2849,12 @@ const VisualComponentDesigner = () => { translate('::App.DeveloperKitComponentDesigner.OptionN', { index: 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 } @@ -3316,7 +3392,8 @@ const VisualComponentDesigner = () => { {translate('::App.DeveloperKitComponentDesigner.StaticOptions')}
{staticSelectOptions.map((option, index) => ( -
+
+
{ > +
+ {selectedNode?.type === 'Menu' && ( + + updateStaticSelectOptions( + staticSelectOptions.map((current, optionIndex) => + optionIndex === index ? { ...current, icon: iconKey } : current, + ), + ) + } + /> + )} + {selectedNode?.type === 'Tabs' && ( + + updateStaticSelectOptions( + staticSelectOptions.map((current, optionIndex) => + optionIndex === index + ? { ...current, content: event.target.value } + : current, + ), + ) + } + /> + )}
))} + {/* + 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 açıyordu. Alt + sınır okunabilirliği, üst sınır geniş dialogdaki eski görünümü korur. + */} {open && ( -
+
{filtered.length} icons
-
+
{displayed.map(([key, Icon]) => (